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
+40 -53
View File
@@ -8,7 +8,6 @@
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
@@ -23,18 +22,12 @@ func main() {
}
func run(args []string) int {
fs := flag.NewFlagSet("nidusctl", flag.ContinueOnError)
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 {
if len(args) < 1 {
usage()
return 2
}
cfg, err := config.Load(*cfgPath)
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "error loading config: %v\n", err)
return 1
@@ -54,20 +47,20 @@ func run(args []string) int {
return 1
}
switch rest[0] {
switch args[0] {
case "user":
return runUser(dbase, rest[1:])
return runUser(dbase, args[1:])
case "calendar", "cal":
return runCalendar(dbase, st, rest[1:])
return runCalendar(dbase, st, args[1:])
case "addressbook", "card":
return runAddressBook(dbase, st, rest[1:])
return runAddressBook(dbase, st, args[1:])
case "migrate":
return runMigrate(st, rest[1:])
return runMigrate(st, args[1:])
case "help", "-h", "--help":
usage()
return 0
default:
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", rest[0])
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
usage()
return 2
}
@@ -77,27 +70,32 @@ func usage() {
fmt.Fprint(os.Stderr, `nidusctl - manage nidus DAV server users, resources, and sharing grants
Usage:
nidusctl [-config config.yaml] user create <username> [--display-name NAME] [--email EMAIL] [--password PW]
nidusctl [-config config.yaml] user delete <username>
nidusctl [-config config.yaml] user list
nidusctl [-config config.yaml] user passwd <username> [--password PW]
nidusctl user create <username> [--display-name NAME] [--email EMAIL] [--password PW]
nidusctl user delete <username>
nidusctl user list
nidusctl user passwd <username> [--password PW]
nidusctl [-config config.yaml] calendar create <owner> <calendar> [--color '#RRGGBB']
nidusctl [-config config.yaml] calendar color <owner> <calendar> <hex-color>
nidusctl [-config config.yaml] calendar delete <owner> <calendar>
nidusctl [-config config.yaml] calendar list <owner>
nidusctl [-config config.yaml] calendar share <owner> <calendar> <user> <read|write>
nidusctl [-config config.yaml] calendar unshare <owner> <calendar> <user>
nidusctl [-config config.yaml] calendar shares <owner> <calendar>
nidusctl calendar create <owner> <calendar> [--color '#RRGGBB']
nidusctl calendar color <owner> <calendar> <hex-color>
nidusctl calendar delete <owner> <calendar>
nidusctl calendar list <owner>
nidusctl calendar share <owner> <calendar> <user> <read|write>
nidusctl calendar unshare <owner> <calendar> <user>
nidusctl calendar shares <owner> <calendar>
nidusctl [-config config.yaml] addressbook create <owner> <book>
nidusctl [-config config.yaml] addressbook delete <owner> <book>
nidusctl [-config config.yaml] addressbook list <owner>
nidusctl [-config config.yaml] addressbook share <owner> <book> <user> <read|write>
nidusctl [-config config.yaml] addressbook unshare <owner> <book> <user>
nidusctl [-config config.yaml] addressbook shares <owner> <book>
nidusctl addressbook create <owner> <book>
nidusctl addressbook delete <owner> <book>
nidusctl addressbook list <owner>
nidusctl addressbook share <owner> <book> <user> <read|write>
nidusctl addressbook unshare <owner> <book> <user>
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:
nidusctl user create alice --display-name "Alice Smith" --email alice@example.com
@@ -106,14 +104,7 @@ Examples:
nidusctl calendar shares alice work
nidusctl calendar unshare alice work bob
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 {
@@ -123,17 +114,17 @@ func runCalendar(dbase *db.DB, st *store.Store, args []string) int {
}
switch args[0] {
case "create":
fs := newFlagSet("calendar create")
color := fs.String("color", "", "hex color like #3b82f6 (optional)")
if err := fs.Parse(args[1:]); err != nil {
return 2
color := ""
if len(args) > 2 && args[1] == "--color" {
color = args[2]
args = args[:1]
}
if fs.NArg() != 2 {
if len(args) != 3 {
fmt.Fprintln(os.Stderr, "usage: nidusctl calendar create <owner> <calendar> [--color '#RRGGBB']")
return 2
}
owner, calName := fs.Arg(0), fs.Arg(1)
if err := dbase.CreateCalendarWithColor(owner, calName, *color); err != nil {
owner, calName := args[1], args[2]
if err := dbase.CreateCalendarWithColor(owner, calName, color); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
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 {
fs := newFlagSet("migrate")
if err := fs.Parse(args); err != nil {
return 2
}
// Ignore args for now (could add --verbose flag in future if needed)
if err := st.Migrate(); err != nil {
fmt.Fprintf(os.Stderr, "migration failed: %v\n", err)
return 1
+18 -38
View File
@@ -12,25 +12,6 @@ import (
"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) {
t.Helper()
@@ -56,9 +37,9 @@ func runCLI(t *testing.T, args ...string) (int, string) {
func TestCalendarShareUnshareLifecycle(t *testing.T) {
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 {
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)
}
code, out = runCLI(t, "-config", cfgPath, "calendar", "shares", "alice", "work")
code, out = runCLI(t, "calendar", "shares", "alice", "work")
if code != 0 {
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)
}
code, out = runCLI(t, "-config", cfgPath, "calendar", "unshare", "alice", "work", "bob")
code, out = runCLI(t, "calendar", "unshare", "alice", "work", "bob")
if code != 0 {
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 {
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) {
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 {
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) {
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 {
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) {
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 {
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 {
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)
}
code, out = runCLI(t, "-config", cfgPath, "addressbook", "unshare", "alice", "contacts", "bob")
code, out = runCLI(t, "addressbook", "unshare", "alice", "contacts", "bob")
if code != 0 {
t.Fatalf("unshare exit code = %d, output: %s", code, out)
}
@@ -139,9 +120,9 @@ func TestAddressBookShareUnshareLifecycle(t *testing.T) {
func TestUnknownUserWarningDoesNotBlockShare(t *testing.T) {
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 {
t.Fatalf("exit code = %d, output: %s", code, out)
}
@@ -154,10 +135,9 @@ func TestUnknownUserWarningDoesNotBlockShare(t *testing.T) {
}
func TestNoArgsShowsUsage(t *testing.T) {
dir := t.TempDir()
// No config needed since usage() is printed before config.Load for
// missing subcommands.
code, out := runCLI(t, "-config", filepath.Join(dir, "missing.yaml"))
code, out := runCLI(t)
if code != 2 {
t.Errorf("exit code = %d, want 2", code)
}
@@ -168,9 +148,9 @@ func TestNoArgsShowsUsage(t *testing.T) {
func TestUnknownCommand(t *testing.T) {
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 {
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 {
fs := newFlagSet("nidusctl user create")
displayName := fs.String("display-name", "", "display name shown in DAV clients")
email := fs.String("email", "", "email address")
password := fs.String("password", "", "password (omit to be prompted, recommended)")
if err := fs.Parse(args); err != nil {
return 2
var displayName, email, password string
var rest []string
for i := 0; i < len(args); i++ {
switch args[i] {
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 {
fmt.Fprintln(os.Stderr, "usage: nidusctl user create <username> [--display-name NAME] [--email EMAIL] [--password PASSWORD]")
return 2
}
username := rest[0]
pw := *password
if pw == "" {
if password == "" {
var err error
pw, err = promptPassword(username)
password, err = promptPassword(username)
if err != nil {
fmt.Fprintf(os.Stderr, "error reading password: %v\n", err)
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)
return 1
}
@@ -98,29 +114,37 @@ func userList(dbase *db.DB, args []string) int {
}
func userPasswd(dbase *db.DB, args []string) int {
fs := newFlagSet("nidusctl user passwd")
password := fs.String("password", "", "new password (omit to be prompted, recommended)")
if err := fs.Parse(args); err != nil {
return 2
var password string
var rest []string
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 {
fmt.Fprintln(os.Stderr, "usage: nidusctl user passwd <username> [--password PASSWORD]")
return 2
}
username := rest[0]
pw := *password
if pw == "" {
if password == "" {
var err error
pw, err = promptPassword(username)
password, err = promptPassword(username)
if err != nil {
fmt.Fprintf(os.Stderr, "error reading password: %v\n", err)
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)
return 1
}