Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file-or-url>.tcade # or from a package you have (also signed in)
termcade whoami # who you are, and what you publish as
termcade username <name> # 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 <username> # a key for a release workflow
Expand Down
153 changes: 152 additions & 1 deletion account.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <org> <bio>")
}
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 <org> <email>")
Expand All @@ -298,5 +325,129 @@ func cmdOrg(args []string) error {
}
return fmt.Errorf(
"unknown org subcommand; try: list · new <username> [bio] · show <username> · " +
"add <org> <email> [admin] · remove <org> <email>")
"edit <org> <bio> · add <org> <email> [admin] · remove <org> <email> · " +
"delete <org> <org>")
}

// 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 "<org>"
}

// 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 <name>`")
} 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 <name>")
}
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/<game>\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 <username>")
}
11 changes: 11 additions & 0 deletions cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> claim or rename your handle (or check one is
free, signed out)
termcade account delete <u> delete your account

termcade signup [email] create a marketplace account
termcade login [email] sign in (publishing and your account library)
termcade logout sign out
Expand Down Expand Up @@ -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":
Expand Down
57 changes: 57 additions & 0 deletions internal/registry/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <author>/<slug>, 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)
}