aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/mirumd/api_cli.go')
-rw-r--r--cmd/mirumd/api_cli.go424+424 −0
1 files changed, 424 insertions, 0 deletions
diff --git a/cmd/mirumd/api_cli.go b/cmd/mirumd/api_cli.go
new file mode 100644
--- /dev/null
+++ b/cmd/mirumd/api_cli.go
@@ -0,0 +1,424 @@
+// Copyright (c) 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package main
+
+// Admin CLI is generated from admin.proto at startup via protoreflect.
+// RPC name is camelCase-split into a cobra path: UserCreate -> "user create",
+// OrgMemberAdd -> "org member add". Flags come from request fields, dispatch
+// goes through reflect on pbconnect.AdminClient.
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/x509"
+ "encoding/base64"
+ "fmt"
+ "os"
+ "reflect"
+ "strings"
+ "unicode"
+
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+ "google.golang.org/protobuf/proto"
+ "google.golang.org/protobuf/reflect/protoreflect"
+ "google.golang.org/protobuf/reflect/protoregistry"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "dimidiumlabs/mirum/internal/protocol/pb"
+ "dimidiumlabs/mirum/internal/protocol/pb/pbconnect"
+)
+
+// mkClient is called per-invocation so persistent flags (e.g. --socket) are
+// already parsed by the time it runs.
+func buildAdminCLI(root *cobra.Command, mkClient func() pbconnect.AdminClient) {
+ methods := pb.File_admin_proto.Services().ByName("Admin").Methods()
+ for i := 0; i < methods.Len(); i++ {
+ md := methods.Get(i)
+ path := splitCamel(string(md.Name()))
+ parent := ensureGroups(root, path[:len(path)-1])
+ parent.AddCommand(buildMethodCmd(md, path[len(path)-1], mkClient))
+ }
+}
+
+// "OrgMemberAdd" -> ["org","member","add"].
+func splitCamel(s string) []string {
+ var parts []string
+ start := 0
+ for i := 1; i < len(s); i++ {
+ if unicode.IsUpper(rune(s[i])) {
+ parts = append(parts, strings.ToLower(s[start:i]))
+ start = i
+ }
+ }
+ return append(parts, strings.ToLower(s[start:]))
+}
+
+func ensureGroups(root *cobra.Command, path []string) *cobra.Command {
+ parent := root
+ for _, name := range path {
+ var next *cobra.Command
+ for _, c := range parent.Commands() {
+ if c.Name() == name {
+ next = c
+ break
+ }
+ }
+ if next == nil {
+ next = &cobra.Command{Use: name, Short: "Manage " + name}
+ parent.AddCommand(next)
+ }
+ parent = next
+ }
+ return parent
+}
+
+// fieldSetter writes one flag value into the request message.
+type fieldSetter func(*pflag.FlagSet, protoreflect.Message) error
+
+func buildMethodCmd(md protoreflect.MethodDescriptor, leaf string, mkClient func() pbconnect.AdminClient) *cobra.Command {
+ reqDesc := md.Input()
+ rpcName := string(md.Name())
+ cmd := &cobra.Command{
+ Use: leaf,
+ Short: rpcName,
+ }
+ setters := registerRequestFlags(cmd, reqDesc)
+ cmd.Run = func(c *cobra.Command, _ []string) {
+ req, err := buildRequest(reqDesc, c.Flags(), setters)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ resp, err := dispatchAdmin(mkClient(), rpcName, req)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ printResponse(resp)
+ }
+ return cmd
+}
+
+func registerRequestFlags(cmd *cobra.Command, desc protoreflect.MessageDescriptor) []fieldSetter {
+ var setters []fieldSetter
+ fields := desc.Fields()
+ for i := 0; i < fields.Len(); i++ {
+ if s := registerField(cmd, fields.Get(i)); s != nil {
+ setters = append(setters, s)
+ }
+ }
+ return setters
+}
+
+func registerField(cmd *cobra.Command, fd protoreflect.FieldDescriptor) fieldSetter {
+ flagName := strings.ReplaceAll(string(fd.Name()), "_", "-")
+ // Required iff non-optional in the schema; bools default to false, so
+ // marking them required makes no sense.
+ required := !fd.HasOptionalKeyword() && fd.Kind() != protoreflect.BoolKind
+ flags := cmd.Flags()
+
+ markRequired := func() {
+ if required {
+ _ = cmd.MarkFlagRequired(flagName)
+ }
+ }
+
+ switch fd.Kind() {
+ case protoreflect.StringKind:
+ flags.String(flagName, "", string(fd.Name()))
+ markRequired()
+ return func(fs *pflag.FlagSet, m protoreflect.Message) error {
+ v, _ := fs.GetString(flagName)
+ if v == "" {
+ return nil
+ }
+ m.Set(fd, protoreflect.ValueOfString(v))
+ return nil
+ }
+
+ case protoreflect.BoolKind:
+ flags.Bool(flagName, false, string(fd.Name()))
+ return func(fs *pflag.FlagSet, m protoreflect.Message) error {
+ // Preserve "unset" vs "false" for optional bools.
+ if fd.HasOptionalKeyword() && !fs.Changed(flagName) {
+ return nil
+ }
+ v, _ := fs.GetBool(flagName)
+ m.Set(fd, protoreflect.ValueOfBool(v))
+ return nil
+ }
+
+ case protoreflect.BytesKind:
+ flags.String(flagName, "", string(fd.Name()))
+ markRequired()
+ return func(fs *pflag.FlagSet, m protoreflect.Message) error {
+ v, _ := fs.GetString(flagName)
+ if v == "" {
+ return nil
+ }
+ b, err := parseBytesFlag(string(fd.Name()), v)
+ if err != nil {
+ return fmt.Errorf("--%s: %w", flagName, err)
+ }
+ m.Set(fd, protoreflect.ValueOfBytes(b))
+ return nil
+ }
+
+ case protoreflect.EnumKind:
+ flags.String(flagName, "", string(fd.Name()))
+ markRequired()
+ return func(fs *pflag.FlagSet, m protoreflect.Message) error {
+ v, _ := fs.GetString(flagName)
+ if v == "" {
+ return nil
+ }
+ n, err := parseEnumFlag(fd.Enum(), v)
+ if err != nil {
+ return fmt.Errorf("--%s: %w", flagName, err)
+ }
+ m.Set(fd, protoreflect.ValueOfEnum(n))
+ return nil
+ }
+
+ case protoreflect.MessageKind:
+ return registerMessageField(cmd, fd, flagName, required)
+ }
+
+ if required {
+ panic(fmt.Sprintf("admincli: unhandled required field %s (kind=%s)", fd.FullName(), fd.Kind()))
+ }
+ return nil
+}
+
+// Only UserRef/OrgRef are flattened (to the string arm of their oneof);
+// PageRequest is skipped; anything else required panics at startup so
+// schema changes can't silently send malformed requests.
+func registerMessageField(cmd *cobra.Command, fd protoreflect.FieldDescriptor, flagName string, required bool) fieldSetter {
+ flags := cmd.Flags()
+ markRequired := func() {
+ if required {
+ _ = cmd.MarkFlagRequired(flagName)
+ }
+ }
+
+ switch fd.Message().FullName() {
+ case "mirum.UserRef":
+ flags.String(flagName, "", "user email")
+ markRequired()
+ return func(fs *pflag.FlagSet, m protoreflect.Message) error {
+ v, _ := fs.GetString(flagName)
+ if v == "" {
+ return nil
+ }
+ ref := &pb.UserRef{Ref: &pb.UserRef_Email{Email: v}}
+ m.Set(fd, protoreflect.ValueOfMessage(ref.ProtoReflect()))
+ return nil
+ }
+
+ case "mirum.OrgRef":
+ flags.String(flagName, "", "org slug")
+ markRequired()
+ return func(fs *pflag.FlagSet, m protoreflect.Message) error {
+ v, _ := fs.GetString(flagName)
+ if v == "" {
+ return nil
+ }
+ ref := &pb.OrgRef{Ref: &pb.OrgRef_Slug{Slug: v}}
+ m.Set(fd, protoreflect.ValueOfMessage(ref.ProtoReflect()))
+ return nil
+ }
+
+ case "mirum.PageRequest":
+ return nil
+ }
+
+ if required {
+ panic(fmt.Sprintf("admincli: unhandled required message field %s (type=%s)", fd.FullName(), fd.Message().FullName()))
+ }
+ return nil
+}
+
+// Admin schema uses bytes only for UUIDs (id / *_id) and ed25519 PKIX keys.
+func parseBytesFlag(fieldName, v string) ([]byte, error) {
+ switch {
+ case fieldName == "id" || strings.HasSuffix(fieldName, "_id"):
+ id, err := ParseAnyID(v)
+ if err != nil {
+ return nil, fmt.Errorf("invalid id: %w", err)
+ }
+ return id[:], nil
+ case strings.Contains(fieldName, "key"):
+ der, err := base64.StdEncoding.DecodeString(v)
+ if err != nil {
+ return nil, fmt.Errorf("invalid base64: %w", err)
+ }
+ pub, err := x509.ParsePKIXPublicKey(der)
+ if err != nil {
+ return nil, fmt.Errorf("invalid public key: %w", err)
+ }
+ ed, ok := pub.(ed25519.PublicKey)
+ if !ok {
+ return nil, fmt.Errorf("not an ed25519 key")
+ }
+ return ed, nil
+ }
+ return nil, fmt.Errorf("unsupported bytes field %q", fieldName)
+}
+
+// Accepts both short ("admin") and full ("ROLE_ADMIN") forms.
+func parseEnumFlag(ed protoreflect.EnumDescriptor, v string) (protoreflect.EnumNumber, error) {
+ want := strings.ToUpper(v)
+ values := ed.Values()
+ if ev := values.ByName(protoreflect.Name(want)); ev != nil {
+ return ev.Number(), nil
+ }
+ prefix := strings.ToUpper(string(ed.Name())) + "_"
+ if ev := values.ByName(protoreflect.Name(prefix + want)); ev != nil {
+ return ev.Number(), nil
+ }
+ return 0, fmt.Errorf("unknown %s value %q", ed.Name(), v)
+}
+
+func buildRequest(desc protoreflect.MessageDescriptor, flags *pflag.FlagSet, setters []fieldSetter) (proto.Message, error) {
+ mt, err := protoregistry.GlobalTypes.FindMessageByName(desc.FullName())
+ if err != nil {
+ return nil, fmt.Errorf("find message %s: %w", desc.FullName(), err)
+ }
+ m := mt.New()
+ for _, set := range setters {
+ if err := set(flags, m); err != nil {
+ return nil, err
+ }
+ }
+ return m.Interface(), nil
+}
+
+// reflect.New on *connect.Request[T] is equivalent to connect.NewRequest(req):
+// Msg is the only public field, the rest are initialised lazily at send time.
+func dispatchAdmin(client pbconnect.AdminClient, name string, req proto.Message) (proto.Message, error) {
+ cv := reflect.ValueOf(client)
+ method := cv.MethodByName(name)
+ if !method.IsValid() {
+ return nil, fmt.Errorf("unknown admin method %q", name)
+ }
+ // method signature:
+ // func(context.Context, *connect.Request[T]) (*connect.Response[U], error)
+ reqPtrType := method.Type().In(1) // *connect.Request[T]
+ reqWrap := reflect.New(reqPtrType.Elem())
+ reqWrap.Elem().FieldByName("Msg").Set(reflect.ValueOf(req))
+
+ out := method.Call([]reflect.Value{
+ reflect.ValueOf(context.Background()),
+ reqWrap,
+ })
+ if errV := out[1]; !errV.IsNil() {
+ return nil, errV.Interface().(error)
+ }
+ return out[0].Elem().FieldByName("Msg").Interface().(proto.Message), nil
+}
+
+// Shape-driven printer: empty → "ok", single bytes id → UUID, single message
+// → TSV of scalars, repeated → one TSV line per element, anything else → TSV
+// of top-level scalars. PageResponse metadata is ignored.
+func printResponse(resp proto.Message) {
+ m := resp.ProtoReflect()
+ fields := m.Descriptor().Fields()
+
+ var meaningful []protoreflect.FieldDescriptor
+ for i := 0; i < fields.Len(); i++ {
+ f := fields.Get(i)
+ if f.Kind() == protoreflect.MessageKind && f.Message().FullName() == "mirum.PageResponse" {
+ continue
+ }
+ meaningful = append(meaningful, f)
+ }
+
+ if len(meaningful) == 0 {
+ fmt.Println("ok")
+ return
+ }
+ if len(meaningful) == 1 {
+ f := meaningful[0]
+ v := m.Get(f)
+ switch {
+ case f.IsList():
+ list := v.List()
+ for j := 0; j < list.Len(); j++ {
+ item := list.Get(j)
+ if f.Kind() == protoreflect.MessageKind {
+ fmt.Println(formatMessageTSV(item.Message()))
+ } else {
+ fmt.Println(formatScalar(f, item))
+ }
+ }
+ case f.Kind() == protoreflect.MessageKind:
+ fmt.Println(formatMessageTSV(v.Message()))
+ default:
+ fmt.Println(formatScalar(f, v))
+ }
+ return
+ }
+ fmt.Println(formatMessageTSV(m))
+}
+
+func formatMessageTSV(m protoreflect.Message) string {
+ var parts []string
+ fields := m.Descriptor().Fields()
+ for i := 0; i < fields.Len(); i++ {
+ f := fields.Get(i)
+ if f.IsList() || f.IsMap() {
+ continue
+ }
+ if f.HasOptionalKeyword() && !m.Has(f) {
+ continue
+ }
+ if f.Kind() == protoreflect.MessageKind {
+ if f.Message().FullName() == "google.protobuf.Timestamp" {
+ ts := m.Get(f).Message().Interface().(*timestamppb.Timestamp)
+ parts = append(parts, ts.AsTime().Format("2006-01-02"))
+ continue
+ }
+ nested := m.Get(f).Message()
+ if nested.IsValid() {
+ parts = append(parts, formatMessageTSV(nested))
+ }
+ continue
+ }
+ parts = append(parts, formatScalar(f, m.Get(f)))
+ }
+ return strings.Join(parts, "\t")
+}
+
+func formatScalar(fd protoreflect.FieldDescriptor, v protoreflect.Value) string {
+ switch fd.Kind() {
+ case protoreflect.StringKind:
+ return v.String()
+ case protoreflect.BoolKind:
+ if v.Bool() {
+ return "true"
+ }
+ return "false"
+ case protoreflect.BytesKind:
+ return formatBytes(v.Bytes())
+ case protoreflect.EnumKind:
+ ev := fd.Enum().Values().ByNumber(v.Enum())
+ if ev == nil {
+ return fmt.Sprintf("%d", v.Enum())
+ }
+ name := string(ev.Name())
+ if idx := strings.IndexByte(name, '_'); idx >= 0 {
+ name = name[idx+1:]
+ }
+ return strings.ToLower(name)
+ }
+ return v.String()
+}
+
+func formatBytes(b []byte) string {
+ if len(b) == 16 {
+ return FormatAnyID(b)
+ }
+ return base64.StdEncoding.EncodeToString(b)
+}