diff --git a/README.md b/README.md index 21011dc..61599bb 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,8 @@ The same works from the command line: termcade signup # create an account + claim a handle termcade add aviorstudio/brickough # add straight from the marketplace termcade add .tcade # or from a package you have (also signed in) +termcade whoami # who you are, and what you publish as +termcade username # claim, rename, or check one is free termcade org new aviorstudio # a studio to publish under termcade sync # bring your library to this machine termcade keys new ci # a key for a release workflow diff --git a/account.go b/account.go index ac56b70..978a921 100644 --- a/account.go +++ b/account.go @@ -286,6 +286,33 @@ func cmdOrg(args []string) error { fmt.Printf("%s is now %s of %s\n", args[2], role, args[1]) return nil + case args[0] == "edit": + if len(args) != 3 { + return fmt.Errorf("usage: termcade org edit ") + } + bio := args[2] + org, err := client.UpdateOrg(args[1], &bio, nil) + if err != nil { + return err + } + fmt.Printf("updated %s\n", org.Username) + return nil + + case args[0] == "delete": + // Confirmed by typing the name. Dissolving a studio releases its + // handle for anyone to claim, and cannot be undone. + if len(args) != 3 || args[2] != args[1] { + return fmt.Errorf( + "this dissolves the studio and releases its handle for anyone to claim.\n"+ + "it cannot be undone. to confirm:\n\n termcade org delete %s %s", + valueOr(args, 1), valueOr(args, 1)) + } + if err := client.DeleteOrg(args[1]); err != nil { + return err + } + fmt.Printf("dissolved %s\n", args[1]) + return nil + case args[0] == "remove": if len(args) != 3 { return fmt.Errorf("usage: termcade org remove ") @@ -298,5 +325,129 @@ func cmdOrg(args []string) error { } return fmt.Errorf( "unknown org subcommand; try: list · new [bio] · show · " + - "add [admin] · remove ") + "edit · add [admin] · remove · " + + "delete ") +} + +// valueOr keeps a usage message readable when the argument it wants to quote +// was the thing that was missing. +func valueOr(args []string, i int) string { + if i < len(args) { + return args[i] + } + return "" +} + +// cmdWhoami reports the account, its handle, and every studio it can publish +// under. The first thing to reach for when a publish is refused and the reason +// is "you are not a member of" something. +func cmdWhoami() error { + session, err := registry.LoadSession() + if err != nil { + return err + } + if session == nil { + return fmt.Errorf("not signed in — run `termcade login`") + } + me, err := registry.New(registry.URL(session), session.Token).Me() + if err != nil { + return err + } + + fmt.Printf("%s (%s)\n", me.Email, session.Registry) + if me.Username == "" { + // A real state: an account whose signup lost a race for its handle. + fmt.Println("\nno username yet — claim one with `termcade username `") + } else { + fmt.Printf("\npublish as:\n %-24s you\n", me.Username) + } + for _, org := range me.Orgs { + role := "member" + if org.Admin { + role = "admin" + } + fmt.Printf(" %-24s studio (%s)\n", org.Username, role) + } + return nil +} + +// cmdUsername claims or renames this account's handle, and with no argument +// reports whether one is free. +func cmdUsername(args []string) error { + if len(args) != 1 { + return fmt.Errorf("usage: termcade username ") + } + session, err := registry.LoadSession() + if err != nil { + return err + } + + // Checking availability needs no account, so it works before signing up — + // which is when somebody is choosing a name. + client := registry.New(registry.URL(session), "") + owner, taken, err := client.HandleTaken(args[0]) + if err != nil { + return err + } + if session == nil { + if taken { + kind := "an account" + if owner.IsOrg { + kind = "a studio" + } + return fmt.Errorf("%s is taken (by %s)", args[0], kind) + } + fmt.Printf("%s is available — claim it with `termcade signup`\n", args[0]) + return nil + } + + claimed, err := registry.New(registry.URL(session), session.Token).SetUsername(args[0]) + if err != nil { + return err + } + // The stored session carries the handle, so it has to follow the rename or + // every later command reports the old one. + session.Username = claimed.Name + if err := registry.SaveSession(*session); err != nil { + return err + } + fmt.Printf("you are %s — publish as %s/\n", claimed.Name, claimed.Name) + return nil +} + +// cmdAccountDelete removes the account, after saying what that means and +// waiting for the word. +func cmdAccountDelete(args []string) error { + session, err := registry.LoadSession() + if err != nil { + return err + } + if session == nil { + return fmt.Errorf("not signed in — run `termcade login`") + } + + // Confirmed by typing the handle, not by pressing y. This releases a name + // somebody else can then claim, and it cannot be undone. + if len(args) != 1 || args[0] != session.Username { + return fmt.Errorf( + "this deletes %s and releases the handle %q for anyone to claim.\n"+ + "it cannot be undone. to confirm:\n\n termcade account delete %s", + session.Email, session.Username, session.Username) + } + + if err := registry.New(registry.URL(session), session.Token).DeleteAccount(); err != nil { + return err + } + if err := registry.ClearSession(); err != nil { + return err + } + fmt.Println("account deleted") + return nil +} + +func cmdAccount(args []string) error { + if len(args) >= 1 && args[0] == "delete" { + return cmdAccountDelete(args[1:]) + } + return fmt.Errorf("unknown account subcommand; try: termcade account delete ") } diff --git a/cli.go b/cli.go index 20b7dae..44e684c 100644 --- a/cli.go +++ b/cli.go @@ -36,6 +36,11 @@ usage: add · remove) termcade keys publish keys for CI (list · new · revoke) + termcade whoami who you are and what you can publish under + termcade username claim or rename your handle (or check one is + free, signed out) + termcade account delete delete your account + termcade signup [email] create a marketplace account termcade login [email] sign in (publishing and your account library) termcade logout sign out @@ -75,6 +80,12 @@ func runCommand(args []string) bool { err = cmdKeys(args[1:]) case "org": err = cmdOrg(args[1:]) + case "whoami": + err = cmdWhoami() + case "username": + err = cmdUsername(args[1:]) + case "account": + err = cmdAccount(args[1:]) case "dev": switch { case len(args) >= 2 && args[1] == "build": diff --git a/internal/registry/client.go b/internal/registry/client.go index 39451b5..83f6193 100644 --- a/internal/registry/client.go +++ b/internal/registry/client.go @@ -434,3 +434,60 @@ func (c *Client) RemoveMember(org, email string) error { return c.do(http.MethodDelete, "/v1/orgs/"+url.PathEscape(org)+"/members/"+url.PathEscape(email), nil, nil) } + +// HandleOwner is what a handle resolves to. Public, because a namespace +// nobody can inspect is a namespace people collide with. +type HandleOwner struct { + Name string `json:"name"` + IsOrg bool `json:"is_org"` +} + +// HandleTaken reports whether a handle is claimed, and by what kind of owner. +// A free handle is a 404 from the registry, which is an answer rather than a +// failure — so it comes back as ok=false, not an error. +func (c *Client) HandleTaken(name string) (owner HandleOwner, taken bool, err error) { + err = c.do(http.MethodGet, "/v1/usernames/"+url.PathEscape(name), nil, &owner) + if err != nil { + if strings.Contains(err.Error(), "is available") { + return HandleOwner{}, false, nil + } + return HandleOwner{}, false, err + } + return owner, true, nil +} + +// SetUsername claims a handle, or renames the one this account holds. +// +// The registry refuses a rename once the handle has published games: packages +// assert their own id and players install to /, so a rename +// would leave shipped packages claiming a name their author no longer holds. +func (c *Client) SetUsername(name string) (HandleOwner, error) { + var out HandleOwner + body := map[string]string{"username": name} + return out, c.do(http.MethodPatch, "/v1/me/username", body, &out) +} + +// DeleteAccount removes the caller's account. Refused while its handle holds +// published games, or while it is the only admin of a studio. +func (c *Client) DeleteAccount() error { + return c.do(http.MethodDelete, "/v1/me", nil, nil) +} + +// UpdateOrg edits a studio. Nil leaves a field alone, which is not the same as +// the empty string — clearing a bio is a real edit. +func (c *Client) UpdateOrg(name string, bio, link *string) (Org, error) { + var out Org + body := map[string]any{} + if bio != nil { + body["bio"] = *bio + } + if link != nil { + body["link"] = *link + } + return out, c.do(http.MethodPatch, "/v1/orgs/"+url.PathEscape(name), body, &out) +} + +// DeleteOrg dissolves a studio. Refused while its handle holds published games. +func (c *Client) DeleteOrg(name string) error { + return c.do(http.MethodDelete, "/v1/orgs/"+url.PathEscape(name), nil, nil) +}