core/cmd/ninja/cmd/login.go
Alex Dunmow 7fc20a990b fix(cli): use renamed ListMyAccountsForCLI RPC
Tracks the shared proto rename that resolves the message-name collision
between PluginAuthService and AccountService.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 20:55:10 +08:00

151 lines
4.3 KiB
Go

package cmd
import (
"bufio"
"context"
"fmt"
"os"
"time"
"connectrpc.com/connect"
"github.com/spf13/cobra"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/creds"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/orchclient"
v1 "git.dev.alexdunmow.com/block/core/internal/api/orchestrator/v1"
)
func newLoginCmd() *cobra.Command {
var host string
cmd := &cobra.Command{
Use: "login",
Short: "Authenticate against the orchestrator using device flow",
RunE: func(c *cobra.Command, _ []string) error {
if host == "" {
host, _ = c.Flags().GetString("host")
}
if host == "" {
host = "https://my.blockninjacms.com"
}
cli := orchclient.New(host, "")
ctx := context.Background()
start, err := cli.Auth.StartDevice(ctx, connect.NewRequest(&v1.StartDeviceRequest{
Scopes: []string{"plugin:read", "plugin:publish", "scope:admin"},
}))
if err != nil {
return fmt.Errorf("start device: %w", err)
}
fmt.Printf("Visit %s?user_code=%s to authorize.\n", start.Msg.VerificationUri, start.Msg.UserCode)
interval := time.Duration(start.Msg.IntervalSeconds) * time.Second
deadline := time.Now().Add(time.Duration(start.Msg.ExpiresInSeconds) * time.Second)
for time.Now().Before(deadline) {
time.Sleep(interval)
poll, err := cli.Auth.PollDevice(ctx, connect.NewRequest(&v1.PollDeviceRequest{DeviceCode: start.Msg.DeviceCode}))
if err != nil {
return err
}
switch poll.Msg.Status {
case "pending":
continue
case "approved":
cr, err := creds.Load()
if err != nil {
return err
}
cr.DefaultHost = host
if cr.Hosts == nil {
cr.Hosts = map[string]creds.HostCreds{}
}
hc := creds.HostCreds{Token: poll.Msg.AccessToken}
authed := orchclient.New(host, hc.Token)
if err := selectActiveAccount(ctx, authed, &hc); err != nil {
return err
}
cr.Hosts[host] = hc
if err := cr.Save(); err != nil {
return err
}
fmt.Println("Logged in.")
return nil
case "expired":
return fmt.Errorf("device code expired; try again")
}
}
return fmt.Errorf("login timed out")
},
}
cmd.Flags().StringVar(&host, "host", "", "Orchestrator base URL")
return cmd
}
func newWhoamiCmd() *cobra.Command {
return &cobra.Command{
Use: "whoami",
Short: "Show the currently logged-in user",
RunE: func(c *cobra.Command, _ []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return err
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
return err
}
cli := orchclient.New(resolvedHost, hc.Token)
r, err := cli.Auth.Whoami(context.Background(), connect.NewRequest(&v1.WhoamiRequest{}))
if err != nil {
return err
}
fmt.Printf("%s <%s> at %s\n", r.Msg.DisplayName, r.Msg.Email, resolvedHost)
return nil
},
}
}
// selectActiveAccount fetches the user's accounts and writes the active one
// into hc. With 0 accounts it errors (the server contract guarantees every
// user has at least one). With 1 it auto-selects silently. With ≥2 it
// prompts interactively on stdin.
func selectActiveAccount(ctx context.Context, cli *orchclient.Client, hc *creds.HostCreds) error {
resp, err := cli.Auth.ListMyAccountsForCLI(ctx, connect.NewRequest(&v1.ListMyAccountsForCLIRequest{}))
if err != nil {
return fmt.Errorf("list accounts: %w", err)
}
accts := resp.Msg.Accounts
switch len(accts) {
case 0:
return fmt.Errorf("no accounts found for this user; contact support")
case 1:
hc.ActiveAccountID = accts[0].Id
hc.ActiveAccountSlug = accts[0].Slug
fmt.Printf("Active account: %s (%s)\n", accts[0].Slug, accts[0].Name)
return nil
}
chosen, err := pickAccountInteractive(bufio.NewScanner(os.Stdin), accts)
if err != nil {
return err
}
hc.ActiveAccountID = chosen.Id
hc.ActiveAccountSlug = chosen.Slug
fmt.Printf("Active account: %s (%s)\n", chosen.Slug, chosen.Name)
return nil
}
func newLogoutCmd() *cobra.Command {
return &cobra.Command{
Use: "logout",
Short: "Remove stored credentials",
RunE: func(c *cobra.Command, _ []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return err
}
resolvedHost, _, _ := cr.Resolve(host)
delete(cr.Hosts, resolvedHost)
return cr.Save()
},
}
}