// dialectic-cli — operator/admin commands for the running Dialectic // backend. Reads the same DB the backend reads + writes; takes no // HTTP path so it can run inside the same container as the backend // (docker exec dialectic-backend dialectic-cli ...) or against the // same DB from anywhere with credentials. // // Subcommands today: // // dialectic-cli config oidc [--issuer URL] [--client-id ID] // [--client-secret S] [--callback-url URL] // [--post-login-redirect URL] // [--scopes "openid email profile"] // [--enabled true|false] // [--show-secret] // // Pattern mirrors Fabric.Backend.Center's `node dist/cli.js config oidc`. // Only the flags you pass mutate; others stay unchanged. Default print // masks client_secret (use --show-secret to reveal — local audit only, // never paste output into chat). package main import ( "context" "encoding/json" "fmt" "os" "time" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/config" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/db" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/oidc" ) func main() { if len(os.Args) < 3 { usage() } subject := os.Args[1] action := os.Args[2] args := os.Args[3:] if subject != "config" || action != "oidc" { usage() } patch := oidc.ConfigPatch{} showSecret := false for i := 0; i < len(args); i++ { switch args[i] { case "--issuer": patch.Issuer = strArg(args, &i) case "--client-id": patch.ClientID = strArg(args, &i) case "--client-secret": patch.ClientSecret = strArg(args, &i) case "--callback-url": patch.RedirectURI = strArg(args, &i) case "--post-login-redirect": patch.PostLoginRedirect = strArg(args, &i) case "--scopes": patch.Scopes = strArg(args, &i) case "--enabled": v := strArg(args, &i) b := v != nil && *v == "true" patch.Enabled = &b case "--show-secret": showSecret = true default: fmt.Fprintf(os.Stderr, "unknown flag: %s\n", args[i]) usage() } } cfg, err := config.LoadFromEnv() check("config", err) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() conn, err := db.Open(ctx, cfg.DSN()) check("db", err) defer conn.Close() // Don't auto-run migrations from the CLI — the backend container's // startup is the canonical migration driver. The CLI assumes the // table is already in place. signingKey := []byte(cfg.SessionSigningKey) if len(signingKey) == 0 { signingKey = []byte("cli-only-irrelevant") } svc := oidc.NewService(conn, signingKey, 24*time.Hour) updated, err := svc.SetConfig(ctx, patch) check("set config", err) out := map[string]any{ "issuer": updated.Issuer, "client_id": updated.ClientID, "redirect_uri": updated.RedirectURI, "post_login_redirect": updated.PostLoginRedirect, "scopes": updated.Scopes, "enabled": updated.Enabled, } if showSecret { out["client_secret"] = updated.ClientSecret } else if updated.ClientSecret != "" { out["client_secret"] = "***set***" } else { out["client_secret"] = "" } b, _ := json.MarshalIndent(map[string]any{"ok": true, "config": out}, "", " ") fmt.Println(string(b)) } func strArg(args []string, i *int) *string { if *i+1 >= len(args) { fmt.Fprintf(os.Stderr, "%s requires a value\n", args[*i]) os.Exit(1) } *i++ v := args[*i] return &v } func check(label string, err error) { if err != nil { fmt.Fprintf(os.Stderr, "%s: %v\n", label, err) os.Exit(1) } } func usage() { fmt.Fprint(os.Stderr, `Usage: dialectic-cli config oidc [--issuer ] [--client-id ] [--client-secret ] [--callback-url ] [--post-login-redirect ] [--scopes "openid email profile"] [--enabled true|false] [--show-secret] Sets only the flags you pass; leaves the rest unchanged. Prints the post-update config with client_secret masked unless --show-secret. Reads the same DB env as the backend (DB_HOST/PORT/USER/PASSWORD/NAME). Run inside the backend container via: docker exec dialectic-backend dialectic-cli config oidc ... `) os.Exit(1) }