github.com/apex/up@v1.7.1/internal/cli/team/team.go (about) 1 package team 2 3 import ( 4 "context" 5 "encoding/base64" 6 "encoding/json" 7 "fmt" 8 "sort" 9 "strings" 10 "time" 11 12 "github.com/pkg/errors" 13 "github.com/segmentio/go-snakecase" 14 "github.com/stripe/stripe-go" 15 "github.com/stripe/stripe-go/token" 16 "github.com/tj/go/clipboard" 17 "github.com/tj/go/env" 18 "github.com/tj/go/http/request" 19 "github.com/tj/go/term" 20 "github.com/tj/kingpin" 21 "github.com/tj/survey" 22 23 "github.com/apex/log" 24 "github.com/apex/up/internal/account" 25 "github.com/apex/up/internal/cli/root" 26 "github.com/apex/up/internal/colors" 27 "github.com/apex/up/internal/stats" 28 "github.com/apex/up/internal/userconfig" 29 "github.com/apex/up/internal/util" 30 "github.com/apex/up/platform/event" 31 "github.com/apex/up/reporter" 32 ) 33 34 // api endpoint. 35 var api = env.GetDefault("APEX_TEAMS_API", "https://teams.apex.sh") 36 37 // api client. 38 var a = account.New(api) 39 40 // plan amounts. 41 var amounts = map[string]int{ 42 "monthly": 2000, 43 "annually": 21600, 44 } 45 46 // plan amount select options. 47 var amountOptions = map[string]string{ 48 "Monthly at $20.00 USD": "monthly", 49 "Annually at $216.00 USD": "annually", 50 } 51 52 func init() { 53 cmd := root.Command("team", "Manage team members, plans, and billing.") 54 cmd.Example(`up team`, "Show active team and subscription status.") 55 cmd.Example(`up team login`, "Sign in or create account with interactive prompt.") 56 cmd.Example(`up team login --email tj@example.com --team apex-software`, "Sign in to a team.") 57 cmd.Example(`up team add "Apex Software"`, "Add a new team.") 58 cmd.Example(`up team subscribe`, "Subscribe to the Pro plan.") 59 cmd.Example(`up team members add asya@example.com`, "Invite a team member to your active team.") 60 cmd.Example(`up team members rm tobi@example.com`, "Remove a team member from your active team.") 61 cmd.Example(`up team card change`, "Change the default credit card.") 62 cmd.Example(`up team switch`, "Switch teams interactively.") 63 status(cmd) 64 switchTeam(cmd) 65 login(cmd) 66 logout(cmd) 67 members(cmd) 68 subscribe(cmd) 69 unsubscribe(cmd) 70 card(cmd) 71 copy(cmd) 72 add(cmd) 73 } 74 75 // add command. 76 func add(cmd *kingpin.Cmd) { 77 c := cmd.Command("add", "Add a new team.") 78 name := c.Arg("name", "Name of the team.").Required().String() 79 80 c.Action(func(_ *kingpin.ParseContext) error { 81 var config userconfig.Config 82 if err := config.Load(); err != nil { 83 return errors.Wrap(err, "loading config") 84 } 85 86 if !config.Authenticated() { 87 return errors.New("Must sign in to create a new team.") 88 } 89 90 team := strings.Replace(snakecase.Snakecase(*name), "_", "-", -1) 91 92 stats.Track("Add Team", map[string]interface{}{ 93 "team": team, 94 "name": name, 95 }) 96 97 t := config.GetActiveTeam() 98 99 if err := a.AddTeam(t.Token, team, *name); err != nil { 100 return errors.Wrap(err, "creating team") 101 } 102 103 defer util.Pad()() 104 util.Log("Created team %s with id %s", *name, team) 105 106 code, err := a.LoginWithToken(t.Token, t.Email, team) 107 if err != nil { 108 return errors.Wrap(err, "login") 109 } 110 111 // access key 112 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) 113 defer cancel() 114 115 token, err := a.PollAccessToken(ctx, t.Email, team, code) 116 if err != nil { 117 return errors.Wrap(err, "getting access token") 118 } 119 120 err = userconfig.Alter(func(c *userconfig.Config) { 121 c.Team = team 122 c.AddTeam(&userconfig.Team{ 123 Token: token, 124 ID: team, 125 Email: t.Email, 126 }) 127 }) 128 129 if err != nil { 130 return errors.Wrap(err, "config") 131 } 132 133 util.Log("%s is now the active team.\n", *name) 134 util.Log("Use `up team switch` to change teams.") 135 136 return nil 137 }) 138 } 139 140 // copy commands. 141 func copy(cmd *kingpin.Cmd) { 142 c := cmd.Command("ci", "Credentials for CI.") 143 copy := c.Flag("copy", "Credentials to the clipboard.").Short('c').Bool() 144 145 c.Action(func(_ *kingpin.ParseContext) error { 146 var config userconfig.Config 147 if err := config.Load(); err != nil { 148 return errors.Wrap(err, "loading") 149 } 150 151 stats.Track("Copy Credentials", map[string]interface{}{ 152 "copy": *copy, 153 }) 154 155 b, err := json.Marshal(config) 156 if err != nil { 157 return errors.Wrap(err, "marshaling") 158 } 159 160 s := base64.StdEncoding.EncodeToString(b) 161 162 if *copy { 163 clipboard.Write(s) 164 fmt.Println("Copied to clipboard!") 165 return nil 166 } 167 168 fmt.Printf("%s\n", s) 169 170 return nil 171 }) 172 } 173 174 // status of account. 175 func status(cmd *kingpin.Cmd) { 176 c := cmd.Command("status", "Status of your account.").Default() 177 178 c.Action(func(_ *kingpin.ParseContext) error { 179 var config userconfig.Config 180 if err := config.Load(); err != nil { 181 return errors.Wrap(err, "loading config") 182 } 183 184 defer util.Pad()() 185 stats.Track("Account Status", nil) 186 187 if !config.Authenticated() { 188 util.LogName("status", "Signed out") 189 return nil 190 } 191 192 t := config.GetActiveTeam() 193 194 defer util.Pad()() 195 util.LogName("team", t.ID) 196 197 team, err := a.GetTeam(t.Token) 198 if err != nil { 199 return errors.Wrap(err, "fetching team") 200 } 201 202 plans, err := a.GetPlans(t.Token) 203 if err != nil { 204 return errors.Wrap(err, "fetching plans") 205 } 206 207 if len(plans) == 0 { 208 util.LogName("subscription", "none") 209 return nil 210 } 211 212 if c := team.Card; c != nil { 213 util.LogName("card", "%s ending with %s", c.Brand, c.LastFour) 214 } 215 216 // TODO: filter on plan type (later may be other products) 217 p := plans[0] 218 219 if d := p.Discount; d != nil { 220 p.Amount = d.Coupon.Discount(p.Amount) 221 util.LogName("coupon", d.Coupon.ID) 222 } 223 224 util.LogName("amount", "%s USD per %s", currency(p.Amount), p.Interval) 225 util.LogName("owner", team.Owner) 226 util.LogName("created", p.CreatedAt.Format("January 2, 2006")) 227 if p.Canceled { 228 util.LogName("canceled", p.CanceledAt.Format("January 2, 2006")) 229 } 230 231 return nil 232 }) 233 } 234 235 // switchTeam team. 236 func switchTeam(cmd *kingpin.Cmd) { 237 c := cmd.Command("switch", "Switch active team.") 238 c.Example(`up team switch`, "Switch teams interactively.") 239 240 c.Action(func(_ *kingpin.ParseContext) error { 241 defer util.Pad()() 242 243 var config userconfig.Config 244 if err := config.Load(); err != nil { 245 return errors.Wrap(err, "loading user config") 246 } 247 248 var options []string 249 for _, t := range config.GetTeams() { 250 options = append(options, t.ID) 251 } 252 sort.Strings(options) 253 254 var team string 255 prompt := &survey.Select{ 256 Message: "", 257 Options: options, 258 Default: config.Team, 259 } 260 261 if err := survey.AskOne(prompt, &team, survey.Required); err != nil { 262 return err 263 } 264 265 stats.Track("Switch Team", nil) 266 267 err := userconfig.Alter(func(c *userconfig.Config) { 268 c.Team = team 269 }) 270 271 if err != nil { 272 return errors.Wrap(err, "saving config") 273 } 274 275 return nil 276 }) 277 } 278 279 // login user. 280 func login(cmd *kingpin.Cmd) { 281 c := cmd.Command("login", "Sign in to your account.") 282 c.Example(`up team login`, "Sign in or create account with interactive prompt.") 283 c.Example(`up team login --team apex-software`, "Sign in to a team using your existing email.") 284 c.Example(`up team login --email tj@example.com --team apex-software`, "Sign in to a team with email.") 285 email := c.Flag("email", "Email address.").String() 286 team := c.Flag("team", "Team id.").String() 287 288 c.Action(func(_ *kingpin.ParseContext) error { 289 var config userconfig.Config 290 if err := config.Load(); err != nil { 291 return errors.Wrap(err, "loading user config") 292 } 293 294 defer util.Pad()() 295 296 stats.Track("Login", map[string]interface{}{ 297 "team_count": len(config.GetTeams()), 298 "has_email": *email != "", 299 "has_team": *team != "", 300 }) 301 302 t := config.GetActiveTeam() 303 304 // both team and email are specified, 305 // so we want to disregard the active team 306 // entirely and sign in using these creds. 307 if *email != "" && *team != "" { 308 t = nil 309 } 310 311 // ensure we have an email 312 if *email == "" { 313 if t == nil { 314 if err := prompt("email:", email); err != nil { 315 return err 316 } 317 } else { 318 *email = t.Email 319 } 320 } 321 322 // ensure we have a team if already signed-in, 323 // this lets the user specify only --team xxx to 324 // join a team they were invited to, or add one 325 // which they own. 326 if t != nil && *team == "" { 327 util.Log("Already signed in as %s on team %s.", t.Email, t.ID) 328 util.Log("Use `up team login --team <id>` to join a team.") 329 return nil 330 } 331 332 // events 333 events := make(event.Events) 334 go reporter.Text(events) 335 events.Emit("account.login.verify", nil) 336 337 l := log.WithFields(log.Fields{ 338 "email": *email, 339 "team": *team, 340 }) 341 342 // authenticate 343 var code string 344 var err error 345 if t == nil { 346 l.Debug("login without token") 347 code, err = a.Login(*email, *team) 348 } else { 349 l.Debug("login with token") 350 code, err = a.LoginWithToken(t.Token, *email, *team) 351 } 352 353 if err != nil { 354 return errors.Wrap(err, "login") 355 } 356 357 // personal team 358 if *team == "" { 359 team = email 360 } 361 362 // access key 363 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) 364 defer cancel() 365 366 l.WithField("team", *team).Debug("poll for access token") 367 token, err := a.PollAccessToken(ctx, *email, *team, code) 368 if err != nil { 369 return errors.Wrap(err, "getting access token") 370 } 371 372 events.Emit("account.login.verified", nil) 373 err = userconfig.Alter(func(c *userconfig.Config) { 374 c.Team = *team 375 c.AddTeam(&userconfig.Team{ 376 Token: token, 377 ID: *team, 378 Email: *email, 379 }) 380 }) 381 382 if err != nil { 383 return errors.Wrap(err, "config") 384 } 385 386 return nil 387 }) 388 } 389 390 // logout user. 391 func logout(cmd *kingpin.Cmd) { 392 c := cmd.Command("logout", "Sign out of your account.") 393 394 c.Action(func(_ *kingpin.ParseContext) error { 395 stats.Track("Logout", nil) 396 397 var config userconfig.Config 398 if err := config.Save(); err != nil { 399 return errors.Wrap(err, "saving") 400 } 401 402 util.LogPad("Signed out") 403 404 return nil 405 }) 406 } 407 408 // subscribe to plan. 409 func subscribe(cmd *kingpin.Cmd) { 410 c := cmd.Command("subscribe", "Subscribe to the Pro plan.") 411 412 c.Action(func(_ *kingpin.ParseContext) error { 413 t, err := userconfig.Require() 414 if err != nil { 415 return err 416 } 417 418 defer util.Pad()() 419 420 // plan 421 util.LogTitle("Subscription") 422 util.Log("Choose a monthly billing period, or 10%% off annually.") 423 println() 424 425 var interval string 426 err = survey.AskOne(&survey.Select{ 427 Message: "Plan:", 428 Options: keys(amountOptions), 429 }, &interval, survey.Required) 430 431 // amount 432 interval = amountOptions[interval] 433 amount := amounts[interval] 434 435 util.LogTitle("Coupon") 436 util.Log("Enter a coupon, or press enter to skip this step") 437 util.Log("and move on to adding a credit card.") 438 println() 439 440 // coupon 441 var couponID string 442 err = survey.AskOne(&survey.Input{ 443 Message: "Coupon:", 444 }, &couponID, nil) 445 446 if err != nil { 447 return err 448 } 449 450 // coupon 451 if strings.TrimSpace(couponID) == "" { 452 util.LogClear("No coupon provided") 453 } else { 454 coupon, err := a.GetCoupon(couponID) 455 if err != nil && !request.IsNotFound(err) { 456 return errors.Wrap(err, "fetching coupon") 457 } 458 459 if coupon == nil { 460 util.LogClear("Coupon is invalid") 461 } else { 462 amount = coupon.Discount(amount) 463 msg := colors.Gray(fmt.Sprintf("%s — now %s %s", coupon.Description(), currency(amount), interval)) 464 util.LogClear("Savings: %s", msg) 465 } 466 } 467 468 // add card 469 util.LogTitle("Credit Card") 470 util.Log("First add your credit card details, these are transferred") 471 util.Log("directly to Stripe over HTTPS and never touch our servers.") 472 println() 473 474 card, err := account.PromptForCard() 475 if err != nil { 476 return errors.Wrap(err, "prompting for card") 477 } 478 479 tok, err := token.New(&stripe.TokenParams{ 480 Card: &card, 481 }) 482 483 if err != nil { 484 return errors.Wrap(err, "requesting card token") 485 } 486 487 if err := a.AddCard(t.Token, tok.ID); err != nil { 488 return errors.Wrap(err, "adding card") 489 } 490 491 util.LogTitle("Confirm") 492 493 // confirm 494 var ok bool 495 err = survey.AskOne(&survey.Confirm{ 496 Message: fmt.Sprintf("Subscribe to Up Pro for %s USD %s?", currency(amount), interval), 497 }, &ok, nil) 498 499 if err != nil { 500 return err 501 } 502 503 if !ok { 504 util.LogPad("Aborted") 505 stats.Track("Abort Subscription", nil) 506 return nil 507 } 508 509 stats.Track("Subscribe", map[string]interface{}{ 510 "coupon": couponID, 511 "interval": interval, 512 }) 513 514 if err := a.AddPlan(t.Token, "up", interval, couponID); err != nil { 515 return errors.Wrap(err, "subscribing") 516 } 517 518 util.LogClear("Thanks for subscribing! Run `up upgrade` to install the Pro release.") 519 520 return nil 521 }) 522 } 523 524 // unsubscribe from plan. 525 func unsubscribe(cmd *kingpin.Cmd) { 526 c := cmd.Command("unsubscribe", "Unsubscribe from the Pro plan.") 527 528 c.Action(func(_ *kingpin.ParseContext) error { 529 config, err := userconfig.Require() 530 if err != nil { 531 return err 532 } 533 534 defer util.Pad()() 535 536 // confirm 537 var ok bool 538 err = survey.AskOne(&survey.Confirm{ 539 Message: "Are you sure you want to unsubscribe?", 540 }, &ok, nil) 541 542 if err != nil { 543 return err 544 } 545 546 if !ok { 547 util.LogPad("Aborted") 548 return nil 549 } 550 551 term.MoveUp(1) 552 term.ClearLine() 553 554 msg, err := feedback() 555 if err != nil { 556 util.LogPad("Aborted") 557 return nil 558 } 559 560 if strings.TrimSpace(msg) != "" { 561 util.Log("Thanks for the feedback!") 562 _ = a.AddFeedback(config.Token, msg) 563 } 564 565 stats.Track("Unsubscribe", nil) 566 567 if err := a.RemovePlan(config.Token, "up"); err != nil { 568 return errors.Wrap(err, "unsubscribing") 569 } 570 571 util.LogClear("Unsubscribed!") 572 573 return nil 574 }) 575 } 576 577 // members commands. 578 func members(cmd *kingpin.Cmd) { 579 c := cmd.Command("members", "Member management.") 580 addMember(c) 581 removeMember(c) 582 listMembers(c) 583 } 584 585 // addMember command. 586 func addMember(cmd *kingpin.Cmd) { 587 c := cmd.Command("add", "Add invites a team member.") 588 c.Example(`up team members add asya@apex.sh`, "Invite a team member to the active team.") 589 email := c.Arg("email", "Email address.").Required().String() 590 591 c.Action(func(_ *kingpin.ParseContext) error { 592 t, err := userconfig.Require() 593 if err != nil { 594 return err 595 } 596 597 stats.Track("Add Member", map[string]interface{}{ 598 "team": t.ID, 599 "email": *email, 600 }) 601 602 if err := a.AddInvite(t.Token, *email); err != nil { 603 return errors.Wrap(err, "adding invite") 604 } 605 606 util.LogPad("Invited %s to team %s", *email, t.ID) 607 608 return nil 609 }) 610 } 611 612 // removeMember command. 613 func removeMember(cmd *kingpin.Cmd) { 614 c := cmd.Command("rm", "Remove a member or invite.").Alias("remove") 615 c.Example(`up team members rm tobi@apex.sh`, "Remove a team member or invite from the active team.") 616 email := c.Arg("email", "Email address.").Required().String() 617 618 c.Action(func(_ *kingpin.ParseContext) error { 619 t, err := userconfig.Require() 620 if err != nil { 621 return err 622 } 623 624 stats.Track("Remove Member", map[string]interface{}{ 625 "team": t.ID, 626 "email": *email, 627 }) 628 629 if err := a.RemoveMember(t.Token, *email); err != nil { 630 return errors.Wrap(err, "removing member") 631 } 632 633 util.LogPad("Removed %s from team %s", *email, t.ID) 634 635 return nil 636 }) 637 } 638 639 // list members 640 func listMembers(cmd *kingpin.Cmd) { 641 c := cmd.Command("ls", "List team members and invites.").Alias("list").Default() 642 643 c.Action(func(_ *kingpin.ParseContext) error { 644 t, err := userconfig.Require() 645 if err != nil { 646 return err 647 } 648 649 stats.Track("List Members", map[string]interface{}{ 650 "team": t.ID, 651 }) 652 653 team, err := a.GetTeam(t.Token) 654 if err != nil { 655 return errors.Wrap(err, "fetching team") 656 } 657 658 defer util.Pad()() 659 660 util.LogName("team", t.ID) 661 662 if len(team.Members) > 0 { 663 util.LogTitle("Members") 664 for _, u := range team.Members { 665 util.LogListItem(u.Email) 666 } 667 } 668 669 if len(team.Invites) > 0 { 670 util.LogTitle("Invites") 671 for _, email := range team.Invites { 672 util.LogListItem(email) 673 } 674 } 675 676 return nil 677 }) 678 } 679 680 // card commands. 681 func card(cmd *kingpin.Cmd) { 682 c := cmd.Command("card", "Card management.") 683 changeCard(c) 684 } 685 686 // changeCard command. 687 func changeCard(cmd *kingpin.Cmd) { 688 c := cmd.Command("change", "Change the default card.") 689 690 c.Action(func(_ *kingpin.ParseContext) error { 691 t, err := userconfig.Require() 692 if err != nil { 693 return err 694 } 695 696 defer util.Pad()() 697 698 util.LogTitle("Credit Card") 699 util.Log("Enter new credit card details to replace the existing card.") 700 println() 701 702 card, err := account.PromptForCard() 703 if err != nil { 704 return errors.Wrap(err, "prompting for card") 705 } 706 707 tok, err := token.New(&stripe.TokenParams{ 708 Card: &card, 709 }) 710 711 if err != nil { 712 return errors.Wrap(err, "requesting card token") 713 } 714 715 if err := a.AddCard(t.Token, tok.ID); err != nil { 716 return errors.Wrap(err, "adding card") 717 } 718 719 println() 720 util.Log("Updated") 721 722 return nil 723 }) 724 } 725 726 // prompt helper. 727 func prompt(name string, s *string) error { 728 prompt := &survey.Input{Message: name} 729 return survey.AskOne(prompt, s, survey.Required) 730 } 731 732 // feedback prompt helper. 733 func feedback() (string, error) { 734 var s string 735 prompt := &survey.Input{Message: "Have any feedback? (optional)"} 736 if err := survey.AskOne(prompt, &s, nil); err != nil { 737 return "", err 738 } 739 return s, nil 740 } 741 742 // currency returns formatted currency. 743 func currency(n int) string { 744 return fmt.Sprintf("$%0.2f", float64(n)/100) 745 } 746 747 // keys returns the keys of a string map. 748 func keys(m map[string]string) (v []string) { 749 for k := range m { 750 v = append(v, k) 751 } 752 753 sort.Slice(v, func(i int, j int) bool { 754 return v[i] > v[j] 755 }) 756 757 return 758 }