Files
magpie/internal/provider/commandcode_plan.go
T
yetone f5f10bda4d plugins: a plugin's accounts show their allowance as the built-ins' do (the user: 所有插件要完整支持显示额度!跟内置一模一样!) — a plugin's auth.usage now tells plan, user, until, renew, balance, resets (count, until, by window, five hours, weekly) and windows (used past 100 when overspent, resets at/in, display, span, the models a window counts or leaves out, aside), and magpie shows them on the usage page, in the menu bar, beside each account and to the gateway, which passes over an account used up for the model asked; a built-in moved onto its plugin keeps its card's name and icon. Also: Command Code's subscription endpoint answering 200 {"success":false} was read as "No plan" by the built-in (and the plugin), it is now unread; a moved Devin was listed twice, the built-in still finding the Devin CLI's own sign-in; and moving back left the plugin's rows in logins.json, listed as empty accounts. Checked in a sandbox HOME with real Command Code and Devin accounts moved onto the community plugins (0.1.1): GOAT, its period, 5 hours / Weekly / Credits for Command Code, Teams, balance and Daily/Weekly for Devin, the same on the built-in after moving back, no plugin rows left. TestPluginUsage, TestCommandCodeSubscriptionUnread and TestMove (the plugin's rows after moving back) fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestClaudeSignedOut and TestUserPath timed out once at load 22, pass alone).
2026-09-30 22:58:56 +08:00

706 lines
24 KiB
Go

package provider
// A Command Code subscription is a commandcode.ai plan (Pro, GOAT, Max,
// Ultra — every one but Go comes with API access). Signing in to it, as
// `cmd auth login` does, mints an API key for the account; that key is
// served on Command Code's Provider API (/provider/v1: Chat, Responses and
// Anthropic's Messages) and billed against the plan's own credits and its
// 5-hour and weekly limits, not as pay-as-you-go.
//
// Go has no API access: its key is only taken where the CLI itself asks,
// POST /alpha/generate, in the CLI's own format (the gateway's
// commandcode.go). Which plan an account is on is asked of billing/
// subscriptions, and kept a while (cmdPlanNow).
//
// The CLI's own account is read, never changed, from ~/.commandcode/
// auth.json. Further accounts are signed in by magpie with the CLI's own
// browser sign-in: commandcode.ai/studio/auth/cli sends the new key to a
// callback on this machine, which answers as the CLI's does; their keys
// are kept in logins.json.
//
// Its id is commandcode-plan: "commandcode" is the keyed preset's, which a
// provider added with a key from Studio may already have.
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/catalog"
)
// CommandCodePlanID is the subscription's id, and its sign-in's.
const CommandCodePlanID = "commandcode-plan"
// Where Command Code's API and its sign-in page are; vars so tests can
// point them elsewhere.
var (
cmdAPI = "https://api.commandcode.ai"
cmdStudio = "https://commandcode.ai"
)
// cmdModels are the plan's best-known models, before its list is fetched.
var cmdModels = []catalog.Model{
{ID: "claude-sonnet-5", Name: "Claude Sonnet 5", Context: 1_000_000},
{ID: "claude-opus-5-5", Name: "Claude Opus 5.5", Context: 1_000_000},
{ID: "gpt-6-sol", Name: "GPT-6 Sol", Context: 1_050_000},
{ID: "deepseek/deepseek-v4-pro", Name: "DeepSeek V4 Pro", Context: 1_000_000},
{ID: "deepseek/deepseek-v4-flash", Name: "DeepSeek V4 Flash", Context: 1_000_000},
{ID: "moonshotai/Kimi-K3", Name: "Kimi K3", Context: 1_000_000},
{ID: "zai-org/GLM-5.3", Name: "GLM-5.3", Context: 1_000_000},
{ID: "MiniMaxAI/MiniMax-M3", Name: "MiniMax M3", Context: 1_000_000},
}
// cmdGoModels are the models the Go plan is let use, as the CLI's own
// table has it (1.72.2): the "opensource" ones, less those it blocks for
// Go (gpt-5.6-sol, claude-sonnet-5-5, grok-4.6 and the like) —
//
// "individual-go":{allowedCategories:[zr],blockedModels:Qr=[…]}
//
// Command Code has no list to ask for them: a model the plan hasn't is
// refused with MODEL_NOT_IN_PLAN.
var cmdGoModels = []catalog.Model{
{ID: "gpt-6-luna", Name: "GPT-6 Luna", Context: 1_050_000, Images: true, Efforts: []string{"low", "medium", "high", "xhigh", "max"}},
{ID: "gpt-5.6-luna", Name: "GPT-5.6 Luna", Context: 1_050_000, Images: true, Efforts: []string{"low", "medium", "high", "xhigh", "max"}},
{ID: "deepseek/deepseek-v4-pro", Name: "DeepSeek V4 Pro", Context: 1_000_000, Efforts: []string{"high", "max"}},
{ID: "deepseek/deepseek-v4-flash", Name: "DeepSeek V4 Flash", Context: 1_000_000, Efforts: []string{"high", "max"}},
{ID: "moonshotai/Kimi-K3", Name: "Kimi K3", Context: 1_000_000, Images: true, Efforts: []string{"low", "high", "max"}},
{ID: "zai-org/GLM-5.3", Name: "GLM-5.3", Context: 1_000_000, Efforts: []string{"low", "high", "max"}},
{ID: "MiniMaxAI/MiniMax-M3", Name: "MiniMax M3", Context: 1_000_000},
{ID: "Qwen/Qwen3.8-Max", Name: "Qwen 3.8 Max", Context: 1_000_000, Images: true, Efforts: []string{"low", "medium", "xhigh"}},
{ID: "Qwen/Qwen3.8-Flash", Name: "Qwen 3.8 Flash", Context: 1_000_000},
{ID: "xiaomi/mimo-v2.6-pro", Name: "MiMo V2.6 Pro", Context: 1_048_576, Images: true},
}
// cmdAuth is an account's key, as auth.json and the sign-in name it.
type cmdAuth struct {
APIKey string `json:"apiKey"`
UserID string `json:"userId,omitempty"`
UserName string `json:"userName,omitempty"`
KeyName string `json:"keyName,omitempty"`
}
// ---- the CLI's own account ----------------------------------------------------
func cmdAuthPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".commandcode", "auth.json")
}
// cmdOwn is the account Command Code's CLI is signed in to; ok is false
// when it has none.
func cmdOwn() (who string, a cmdAuth, ok bool) {
if !readJSON(cmdAuthPath(), &a) || strings.TrimSpace(a.APIKey) == "" {
return "", cmdAuth{}, false
}
return cmdWho(a), a, true
}
// cmdWho names an account: its user name, or its id.
func cmdWho(a cmdAuth) string {
return firstNonEmpty(a.UserName, a.UserID, "Command Code")
}
// ---- the accounts -------------------------------------------------------------
type cmdLogin struct {
Login
auth cmdAuth
}
func cmdSaved(l savedLogin) (cmdAuth, bool) {
var a cmdAuth
if json.Unmarshal(l.Auth, &a) != nil || a.APIKey == "" {
return cmdAuth{}, false
}
return a, true
}
// cmdLogins is every Command Code account signed in, the first in use first.
func cmdLogins() []cmdLogin {
ownUser, own, hasOwn := cmdOwn()
if !hasOwn {
ownUser = ""
}
var out []cmdLogin
for _, l := range sideLogins(CommandCodePlanID, ownUser, func(l savedLogin) bool {
_, ok := cmdSaved(l)
return ok
}) {
a := own
if !l.saved.own() {
a, _ = cmdSaved(l.saved)
}
out = append(out, cmdLogin{l.Login, a})
}
return out
}
func cmdSide() []sideLogin {
var out []sideLogin
for _, l := range cmdLogins() {
out = append(out, sideLogin{Login: l.Login})
}
return out
}
func cmdLoginList() []Login { return loginsOf(cmdSide()) }
func switchCommandCodeLogin(user string) error {
return switchSideLogin(CommandCodePlanID, user, cmdSide())
}
func setCommandCodeLoginOn(user string, on bool) error {
return setSideLoginOn(CommandCodePlanID, user, on, cmdSide())
}
func forgetCommandCodeLogin(user string) error {
return forgetSideLogin(CommandCodePlanID, user, cmdSide(), nil)
}
func commandCodeAccount() (Provider, bool) {
ls := cmdLogins()
if len(ls) == 0 {
return Provider{}, false
}
return cmdProvider(ls[0].User, ls[0].Plan, ls[0].auth), true
}
// commandCodeAlsoOn is the Command Code accounts in use behind the first.
func commandCodeAlsoOn() []Provider {
var out []Provider
for _, l := range cmdLogins() {
if !l.Active && l.On {
out = append(out, cmdProvider(l.User, l.Plan, l.auth))
}
}
return out
}
func cmdProvider(who, plan string, a cmdAuth) Provider {
p := Provider{ID: CommandCodePlanID, Name: "Command Code Plan", Icon: "commandcode",
Chat: cmdAPI + "/provider/v1", Responses: cmdAPI + "/provider/v1", Anthropic: cmdAPI + "/provider",
Website: cmdStudio}
acct := &Account{Agent: CommandCodePlanID, User: who, Plan: cmdPlanKnown(a, plan)}
acct.sign = func(ctx context.Context, req *http.Request, body []byte) error {
req.Header.Del("Authorization")
req.Header.Set("Authorization", "Bearer "+a.APIKey)
req.Header.Set("x-api-key", a.APIKey)
return nil
}
acct.models = func() []catalog.Model {
if cmdPlanKnown(a, plan) == "Go" {
return cmdGoModels
}
return cmdModels
}
// the plan's list is the Provider API's, with what each model is
// served on; it is asked as a keyed provider's is. Go's key has no
// Provider API: its list is the CLI's.
acct.fetch = func(ctx context.Context) ([]catalog.Model, error) {
if cmdPlanNow(ctx, a, plan) == "Go" {
return cmdGoModels, nil
}
keyed := p
keyed.Account, keyed.Key = nil, a.APIKey
return keyed.Fetch(ctx)
}
acct.generate = func(ctx context.Context) (string, bool) {
return a.APIKey, cmdPlanNow(ctx, a, plan) == "Go"
}
p.Account = acct
return p
}
// CommandCodeGenerate says whether p is a Command Code account on the Go
// plan, which is asked at api+"/alpha/generate" in the CLI's own format
// rather than on the Provider API, with key; ok is false for every other
// account and provider.
func CommandCodeGenerate(ctx context.Context, p Provider) (api, key string, ok bool) {
if p.Account == nil || p.Account.generate == nil {
return "", "", false
}
if key, ok = p.Account.generate(ctx); !ok {
return "", "", false
}
return cmdAPI, key, true
}
// ---- which plan ---------------------------------------------------------------
// cmdPlansSeen is each key's plan, as billing/subscriptions last said
// ("" when it couldn't be read), and when.
var cmdPlansSeen = struct {
sync.Mutex
m map[string]cmdSeen
}{m: map[string]cmdSeen{}}
type cmdSeen struct {
plan string
at time.Time
}
// How long a plan read is kept, and a failure to read it before it is
// asked again.
var (
cmdPlanKeep = 10 * time.Minute
cmdPlanRetry = time.Minute
)
// cmdRemember keeps what billing/subscriptions said of a key's plan.
func cmdRemember(key, plan string) {
cmdPlansSeen.Lock()
cmdPlansSeen.m[key] = cmdSeen{plan, time.Now()}
cmdPlansSeen.Unlock()
}
// cmdPlanKnown is the account's plan as last read, else saved (the one
// its sign-in said), asking no one.
func cmdPlanKnown(a cmdAuth, saved string) string {
cmdPlansSeen.Lock()
seen := cmdPlansSeen.m[a.APIKey]
cmdPlansSeen.Unlock()
return firstNonEmpty(seen.plan, saved)
}
// cmdPlanNow is the account's plan, read again once what was read is
// older than cmdPlanKeep; saved while it can't be read. The CLI's own
// account has none saved, so it is always asked.
func cmdPlanNow(ctx context.Context, a cmdAuth, saved string) string {
cmdPlansSeen.Lock()
seen, ok := cmdPlansSeen.m[a.APIKey]
cmdPlansSeen.Unlock()
switch {
case ok && seen.plan != "" && time.Since(seen.at) < cmdPlanKeep:
return seen.plan
case ok && seen.plan == "" && time.Since(seen.at) < cmdPlanRetry:
return saved
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
_, plan, _, _, read := cmdSubscription(ctx, a)
if !read {
plan = ""
}
cmdRemember(a.APIKey, plan)
return firstNonEmpty(plan, saved)
}
// ---- allowance ----------------------------------------------------------------
// cmdPlans names the plans as the CLI does, by their id's start, longest
// first, with the dollars of credits each gives a month (its getPlanInfo;
// "individual-pro-v1" is the old Pro, $80 of them).
var cmdPlans = []struct {
id, name string
monthly float64
}{
{"individual-provider", "Provider", 15}, {"individual-pro-v1", "Pro", 80}, {"individual-goat", "GOAT", 70},
{"individual-ultra", "Ultra", 300}, {"individual-max", "Max", 150}, {"individual-pro", "Pro", 30},
{"individual-go", "Go", 10}, {"teams-pro", "Teams Pro", 40},
}
func cmdPlanOf(id string) (name string, monthly float64) {
id = strings.ReplaceAll(strings.ToLower(id), "_", "-")
for _, p := range cmdPlans {
if strings.HasPrefix(id, p.id) {
return p.name, p.monthly
}
}
return "", 0
}
func cmdPlanName(id string) string {
name, _ := cmdPlanOf(id)
return name
}
// cmdCredits is /alpha/billing/credits as the CLI's /usage reads it
// (projectUsageView): the dollars left of the month's credits, and of the
// bought and free ones beside them, under "credits"; the plan's windows
// next to it, not in it —
//
// {"credits":{"planId":"individual-goat-monthly","monthlyCredits":41.2,
// "purchasedCredits":5,"freeCredits":0},
// "windowLimits":{"limited":true,
// "fiveHour":{"used":3.1,"cap":10,"resetAt":1790000000000},
// "weekly":{"used":12,"cap":40,"resetAt":1790400000000}},
// "sandboxMinutes":{…},"sandboxAccess":false}
//
// resetAt is in milliseconds (the CLI compares it with Date.now()); used
// and cap are only ever divided, one by the other.
type cmdWindow struct {
Used any `json:"used"`
Cap any `json:"cap"`
ResetAt any `json:"resetAt"` // unix ms (seconds or a time taken too)
}
type cmdCredits struct {
Credits struct {
PlanID string `json:"planId"`
Monthly any `json:"monthlyCredits"`
Purchased any `json:"purchasedCredits"`
Free any `json:"freeCredits"`
} `json:"credits"`
Windows *struct {
FiveHour *cmdWindow `json:"fiveHour"`
Weekly *cmdWindow `json:"weekly"`
} `json:"windowLimits"`
}
// cmdTime reads a reset time: unix seconds or milliseconds, or RFC 3339.
func cmdTime(v any) *time.Time {
if s, ok := v.(string); ok {
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return &t
}
}
n, ok := number(v)
if !ok || n <= 0 {
return nil
}
if n < 1e12 {
n *= 1000
}
t := time.UnixMilli(int64(n))
return &t
}
// cmdWindows is the plan's 5-hour and weekly windows in a credits reply,
// those it gives a cap.
func cmdWindows(c cmdCredits) []QuotaWindow {
out := []QuotaWindow{}
if c.Windows == nil {
return out
}
for _, w := range []struct {
name string
span time.Duration
w *cmdWindow
}{{"5 hours", 5 * time.Hour, c.Windows.FiveHour}, {"Weekly", 7 * 24 * time.Hour, c.Windows.Weekly}} {
if w.w == nil {
continue
}
used, ok1 := number(w.w.Used)
limit, ok2 := number(w.w.Cap)
if !ok1 || !ok2 || limit <= 0 {
continue
}
out = append(out, QuotaWindow{Name: w.name, Used: min(100, 100*max(0, used)/limit), Span: w.span,
ResetsAt: cmdTime(w.w.ResetAt)})
}
return out
}
// cmdLeft is the dollars left on the account, the month's credits and the
// bought and free ones together (the CLI's totalRemaining), and the
// month's alone; ok is false when the reply tells none of them.
func cmdLeft(c cmdCredits) (monthly, left float64, ok bool) {
for i, v := range []any{c.Credits.Monthly, c.Credits.Purchased, c.Credits.Free} {
if n, is := number(v); is {
n = max(0, n)
if i == 0 {
monthly = n
}
left += n
ok = true
}
}
return monthly, left, ok
}
// cmdQuotaOf makes the credits reply into the plan's windows: the 5-hour
// and weekly limits, and the month's credits as one more. A card's
// Balance is shown instead of its windows — it is a key's money, not an
// allowance — so, set beside them, it hid both limits on the Usage page,
// the panel and the TUI. The dollars are a window when the plan is known,
// used of the CLI's pool (the plan's month of credits, or what is left of
// it if more, and the bought and free ones) as its /usage bar is; a reply
// with neither windows nor a plan is a Balance still.
func cmdQuotaOf(q SubscriptionQuota, c cmdCredits) SubscriptionQuota {
name, planMonthly := cmdPlanOf(c.Credits.PlanID)
if name != "" {
q.Plan = name
}
q.Windows = cmdWindows(c)
monthly, left, known := cmdLeft(c)
switch {
case !known:
case planMonthly > 0:
pool := max(planMonthly, monthly) + left - monthly
q.Windows = append(q.Windows, QuotaWindow{Name: "Credits", Used: min(100, 100*(pool-left)/pool),
Display: money("$", pool-left) + " / " + money("$", pool)})
case len(q.Windows) == 0:
q.Balance = money("$", left)
}
return q
}
func cmdQuota(ctx context.Context, l Login, a cmdAuth) SubscriptionQuota {
q := SubscriptionQuota{Provider: CommandCodePlanID, Name: "Command Code", Icon: "commandcode", Plan: l.Plan, User: l.User, Windows: []QuotaWindow{}}
// the plan is said even while the credits can't be read (Command Code
// answers 503, "Couldn't verify your credit balance just now", at times)
id, plan, until, renew, planOK := cmdSubscription(ctx, a)
var c cmdCredits
if err := accountJSON(ctx, cmdAPI+"/alpha/billing/credits", a.APIKey, nil, &c); err != nil {
q.Error = err.Error()
} else {
// billing/credits leaves the plan out: the month of credits the pool
// is made of is the subscription's plan's
if c.Credits.PlanID == "" {
c.Credits.PlanID = id
}
q = cmdQuotaOf(q, c)
}
if planOK {
q.Plan, q.Until, q.Renew = plan, until, renew
cmdRemember(a.APIKey, plan)
}
return q
}
// cmdNoPlan is the plan of an account with no subscription: its key is
// billed against the credits it bought, if any.
const cmdNoPlan = "No plan"
// cmdSubscription is the account's plan (its id, and its name), when its period ends and whether
// it renews then, as billing/subscriptions says; cmdNoPlan when it has
// none. ok is false when that can't be read.
func cmdSubscription(ctx context.Context, a cmdAuth) (id, plan string, until *time.Time, renew string, ok bool) {
var r struct {
Success *bool `json:"success"` // false when Command Code couldn't tell ("write CONNECTION_CLOSED …"), though a 200
Data *struct {
PlanID string `json:"planId"`
Status string `json:"status"`
CurrentPeriodEnd any `json:"currentPeriodEnd"`
CancelAtEnd *bool `json:"cancelAtPeriodEnd"`
} `json:"data"`
}
if accountJSON(ctx, cmdAPI+"/alpha/billing/subscriptions", a.APIKey, nil, &r) != nil || (r.Success != nil && !*r.Success) {
return "", "", nil, "", false
}
if d := r.Data; d != nil && d.PlanID != "" && d.Status != "canceled" && d.Status != "incomplete_expired" {
if d.CancelAtEnd != nil {
renew = map[bool]string{true: "off", false: "auto"}[*d.CancelAtEnd]
}
return d.PlanID, firstNonEmpty(cmdPlanName(d.PlanID), d.PlanID), cmdTime(d.CurrentPeriodEnd), renew, true
}
return "", cmdNoPlan, nil, "", true
}
func cmdLoginQuota(ctx context.Context, l Login) SubscriptionQuota {
for _, c := range cmdLogins() {
if strings.EqualFold(c.User, l.User) {
return cmdQuota(ctx, l, c.auth)
}
}
return SubscriptionQuota{Provider: CommandCodePlanID, Plan: l.Plan, Windows: []QuotaWindow{}, Error: "not signed in"}
}
// ---- signing in ---------------------------------------------------------------
// startCommandCodeSignIn is the CLI's browser sign-in: Studio asks the
// user to approve a key for this machine, then posts it (a form, or JSON
// from an older Studio) to the callback, which sends the browser on to a
// page saying it is done.
func startCommandCodeSignIn(s *signInFlow) error {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return err
}
port := ln.Addr().(*net.TCPAddr).Port
q := url.Values{}
q.Set("callback", fmt.Sprintf("http://127.0.0.1:%d/callback", port))
q.Set("state", s.state)
q.Set("mode", "redirect")
srv := &http.Server{Handler: http.HandlerFunc(s.commandCodeCallback), ReadHeaderTimeout: 10 * time.Second}
s.mu.Lock()
s.st.URL = cmdStudio + "/studio/auth/cli?" + q.Encode()
s.srv = srv
s.mu.Unlock()
go func() { _ = srv.Serve(ln) }()
return nil
}
// cmdWhoamiWait is how long a sign-in waits to ask whoami again.
var cmdWhoamiWait = time.Second
// cmdOrigins are the Studio pages that may post to the callback.
var cmdOrigins = []string{"https://commandcode.ai", "https://staging.commandcode.ai"}
func (s *signInFlow) commandCodeCallback(w http.ResponseWriter, r *http.Request) {
origin := cmdOrigins[0]
for _, o := range cmdOrigins {
if r.Header.Get("Origin") == o {
origin = o
}
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Header.Get("Access-Control-Request-Private-Network") == "true" {
w.Header().Set("Access-Control-Allow-Private-Network", "true")
}
switch {
case r.Method == http.MethodOptions:
w.WriteHeader(http.StatusNoContent)
return
case r.URL.Path == "/callback/complete" && r.Method == http.MethodGet:
st := s.status()
if st.State == "done" {
signInPage(w, true, "You're signed in", fmt.Sprintf("%s is added to magpie. You can close this tab.", st.User))
} else {
signInPage(w, false, "Sign-in didn't finish", firstNonEmpty(st.Error, "Start it again from magpie."))
}
return
case r.URL.Path == "/cancel":
s.finish(SignInState{State: "canceled"})
w.WriteHeader(http.StatusNoContent)
return
case r.URL.Path != "/callback":
http.NotFound(w, r)
return
case r.Method != http.MethodPost:
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
body, _ := io.ReadAll(io.LimitReader(r.Body, 10_000))
var got struct {
cmdAuth
State string `json:"state"`
Error string `json:"error"`
Desc string `json:"error_description"`
}
isJSON := strings.HasPrefix(strings.TrimSpace(strings.ToLower(r.Header.Get("Content-Type"))), "application/json")
if isJSON {
_ = json.Unmarshal(body, &got)
} else {
f, _ := url.ParseQuery(string(body))
got.APIKey, got.UserID, got.UserName, got.KeyName = f.Get("apiKey"), f.Get("userId"), f.Get("userName"), f.Get("keyName")
got.State, got.Error, got.Desc = f.Get("state"), f.Get("error"), f.Get("error_description")
}
answer := func(ok bool, msg string) {
if isJSON {
w.Header().Set("Content-Type", "application/json")
if !ok {
w.WriteHeader(http.StatusBadRequest)
}
_ = json.NewEncoder(w).Encode(map[string]any{"success": ok, "error": msg})
return
}
// as the CLI answers: on to the page that says how it went
w.Header().Set("Location", "/callback/complete?state="+url.QueryEscape(got.State))
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusSeeOther)
}
if got.State != s.state {
// not ours: someone else's page, or a stale tab
w.WriteHeader(http.StatusForbidden)
signInPage(w, false, "This link isn't from magpie's sign-in", "Start it again from magpie.")
return
}
if s.status().State != "waiting" {
answer(false, "this sign-in is over")
return
}
if got.Error != "" {
msg := firstNonEmpty(got.Desc, got.Error)
if got.Error == "access_denied" {
msg = "the sign-in was denied"
}
s.finish(SignInState{State: "failed", Error: msg})
answer(false, msg)
return
}
if got.APIKey == "" {
answer(false, "Command Code sent back no key")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
who, plan, err := cmdSignedIn(ctx, got.cmdAuth)
if err != nil {
s.finish(SignInState{State: "failed", Error: err.Error()})
answer(false, err.Error())
return
}
a := got.cmdAuth
a.UserName = who
auth, _ := json.Marshal(a)
ownUser, _, hasOwn := cmdOwn()
if !hasOwn {
ownUser = ""
}
if err := addSideLogin(savedLogin{Agent: CommandCodePlanID, User: who, Plan: plan, Auth: auth}, ownUser, func(savedLogin) {}); err != nil {
s.finish(SignInState{State: "failed", Error: err.Error()})
answer(false, err.Error())
return
}
// the browser comes back for its page after this: the server stays up
// a while for it, where finish would close it within a second
s.mu.Lock()
srv := s.srv
s.srv = nil
s.mu.Unlock()
if srv != nil {
time.AfterFunc(10*time.Second, func() { _ = srv.Close() })
}
s.finish(SignInState{State: "done", User: who, Plan: plan, Using: hasOwn && strings.EqualFold(ownUser, who)})
answer(true, "")
}
// cmdSignedIn names a new key's account with whoami, and reads its plan.
// Only whoami turning the key down (401, 403) fails the sign-in: the CLI's
// own browser sign-in keeps the key Studio posts without asking whoami at
// all, and whoami has answered a key just made with a 500, so a whoami
// that errs is asked again a few times and then passed over for the name
// Studio sent with the key.
func cmdSignedIn(ctx context.Context, a cmdAuth) (who, plan string, err error) {
var me struct {
User struct {
ID string `json:"id"`
UserName string `json:"userName"`
Name string `json:"name"`
Email string `json:"email"`
} `json:"user"`
}
for try := 0; ; try++ {
err := accountJSON(ctx, cmdAPI+"/alpha/whoami", a.APIKey, nil, &me)
var st *accountStatusError
if err == nil {
break
}
if errors.As(err, &st) && (st.status == http.StatusUnauthorized || st.status == http.StatusForbidden) {
return "", "", fmt.Errorf("Command Code didn't take the new key: %w", err)
}
if try == 2 || ctx.Err() != nil {
if firstNonEmpty(a.UserName, a.UserID) == "" {
return "", "", fmt.Errorf("Command Code couldn't say which account signed in: %w", err)
}
break
}
select {
case <-ctx.Done():
case <-time.After(cmdWhoamiWait):
}
}
who = firstNonEmpty(me.User.UserName, a.UserName, me.User.Email, me.User.ID, a.UserID)
if who == "" {
return "", "", fmt.Errorf("Command Code didn't say which account signed in")
}
if _, p, _, _, ok := cmdSubscription(ctx, a); ok {
plan = p
}
return who, plan, nil
}