From bf4a5816e373c314a74a94c0cdec853ddb7a6ce6 Mon Sep 17 00:00:00 2001 From: nicodes Date: Mon, 3 Aug 2026 12:38:25 -0600 Subject: [PATCH] Reach the rest of the registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An audit of the twenty-three endpoints against what any client calls found six nothing could reach. Three of those were bugs rather than missing features. Handle availability had no caller at all, so the only way to learn a name was taken was to fail signing up — the one moment that is awkward to retry. `termcade username ` answers it signed out, which is when somebody is choosing. Rename was unreachable. It exists precisely so a typo at signup is fixable, and the rule that makes it safe — refused once the handle has published games — means the window to use it closes. Shipping it with no way to call it wasted the feature. The stored session follows the rename, or every later command reports the old name. Account deletion was built for the end-to-end suite to clean up after itself and never given to a person, which is backwards for something that is a feature first. Also: whoami, org edit, org delete. Both destructive commands confirm by having the name typed rather than by a yes. Each releases a handle for anyone to claim and cannot be undone, and a y/n prompt is the wrong weight for that. Verified against production end to end: availability signed out for a reserved and a free name, whoami, a rename that the session followed, an org edited and read back, both confirmations refusing without the name, and an account deleted with its handle released. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 + account.go | 153 +++++++++++++++++++++++++++++++++++- cli.go | 11 +++ internal/registry/client.go | 57 ++++++++++++++ 4 files changed, 222 insertions(+), 1 deletion(-) 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) +}