705 lines
23 KiB
Go
705 lines
23 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 {
|
|
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 {
|
|
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
|
|
}
|