From 95eca530bcefd70aa6d655dfe609fb1275da6a1e Mon Sep 17 00:00:00 2001 From: nicodes Date: Mon, 3 Aug 2026 12:25:24 -0600 Subject: [PATCH] Give studios a front door The registry has grown orgs, membership and admin rules, with tests for all of them, and no way to reach any of it. Somebody who signed up could see they had a handle and could not discover that studios existed, let alone make one. `termcade org` covers the whole surface: list what you can publish under, create a studio, look one up, add somebody, remove them. Adding and promoting are one command, because they are the same intent at different times and a separate `promote` would only work in one order. Verified against production: created a studio, saw it appear as admin in the listing, read its page back, and removed it along with the account. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 1 + account.go | 106 ++++++++++++++++++++++++++++++++++++ cli.go | 4 ++ internal/registry/client.go | 48 ++++++++++++++++ 4 files changed, 159 insertions(+) diff --git a/README.md b/README.md index d23a877..21011dc 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ 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 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 termcade list # what's here diff --git a/account.go b/account.go index ed5580a..ac56b70 100644 --- a/account.go +++ b/account.go @@ -194,3 +194,109 @@ func cmdKeys(args []string) error { } return fmt.Errorf("unknown keys subcommand; try: list · new · revoke ") } + +// cmdOrg manages studios: a handle more than one person publishes under. +// +// An org exists so `aviorstudio/tetris` can outlive any one account, and so a +// second person can release it. Membership is enough to publish; admin governs +// the studio itself. +func cmdOrg(args []string) error { + session, err := registry.LoadSession() + if err != nil { + return err + } + if session == nil { + return fmt.Errorf("managing studios requires an account — run `termcade login`") + } + client := registry.New(registry.URL(session), session.Token) + + switch { + case len(args) == 0 || args[0] == "list": + me, err := client.Me() + if err != nil { + return err + } + if me.Username != "" { + fmt.Printf("%-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) + } + if len(me.Orgs) == 0 { + fmt.Println("\nno studios — create one with `termcade org new `") + } + return nil + + case args[0] == "new": + if len(args) < 2 || len(args) > 3 { + return fmt.Errorf("usage: termcade org new [bio]") + } + bio := "" + if len(args) == 3 { + bio = args[2] + } + org, err := client.CreateOrg(args[1], bio, "") + if err != nil { + return err + } + fmt.Printf("created %s — you are its admin\n", org.Username) + fmt.Printf("publish as %s/, and add people with `termcade org add %s `\n", + org.Username, org.Username) + return nil + + case args[0] == "show": + if len(args) != 2 { + return fmt.Errorf("usage: termcade org show ") + } + org, err := client.Org(args[1]) + if err != nil { + return err + } + fmt.Printf("%s\n", org.Username) + if org.Bio != "" { + fmt.Printf(" %s\n", org.Bio) + } + if len(org.Games) == 0 { + fmt.Println(" no published games") + } + for _, game := range org.Games { + fmt.Printf(" %s\n", game) + } + return nil + + case args[0] == "add": + // One call for adding and for promoting: they are the same intent at + // different times, and a separate `promote` would only work in one + // order. + if len(args) < 3 || len(args) > 4 { + return fmt.Errorf("usage: termcade org add [admin]") + } + admin := len(args) == 4 && args[3] == "admin" + if err := client.AddMember(args[1], args[2], admin); err != nil { + return err + } + role := "a member" + if admin { + role = "an admin" + } + fmt.Printf("%s is now %s of %s\n", args[2], role, args[1]) + return nil + + case args[0] == "remove": + if len(args) != 3 { + return fmt.Errorf("usage: termcade org remove ") + } + if err := client.RemoveMember(args[1], args[2]); err != nil { + return err + } + fmt.Printf("removed %s from %s\n", args[2], args[1]) + return nil + } + return fmt.Errorf( + "unknown org subcommand; try: list · new [bio] · show · " + + "add [admin] · remove ") +} diff --git a/cli.go b/cli.go index 6c55970..20b7dae 100644 --- a/cli.go +++ b/cli.go @@ -32,6 +32,8 @@ usage: publish a release: point the marketplace at a .tcade on one of your GitHub releases + termcade org studios you publish under (list · new · show · + add · remove) termcade keys publish keys for CI (list · new · revoke) termcade signup [email] create a marketplace account @@ -71,6 +73,8 @@ func runCommand(args []string) bool { err = cmdPublish(args[1:]) case "keys": err = cmdKeys(args[1:]) + case "org": + err = cmdOrg(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 301c129..39451b5 100644 --- a/internal/registry/client.go +++ b/internal/registry/client.go @@ -386,3 +386,51 @@ func (c *Client) Keys() ([]Key, error) { func (c *Client) DeleteKey(id string) error { return c.do(http.MethodDelete, "/v1/keys/"+url.PathEscape(id), nil, nil) } + +// Org is a studio: a handle more than one person can publish under. +type Org struct { + Username string `json:"username"` + Link string `json:"link,omitempty"` + Bio string `json:"bio,omitempty"` + Admin bool `json:"admin"` + Games []string `json:"games,omitempty"` +} + +// Me is who the caller is, and every handle they may publish under. Clients +// read Handles to offer a choice at publish time rather than making an author +// guess which names are theirs. +type Me struct { + Email string `json:"email"` + Username string `json:"username,omitempty"` + Orgs []Org `json:"orgs"` + Handles []string `json:"handles"` +} + +func (c *Client) Me() (Me, error) { + var out Me + return out, c.do(http.MethodGet, "/v1/me", nil, &out) +} + +// CreateOrg creates a studio and claims its handle, with the caller as its +// first admin. +func (c *Client) CreateOrg(username, bio, link string) (Org, error) { + var out Org + body := map[string]string{"username": username, "bio": bio, "link": link} + return out, c.do(http.MethodPost, "/v1/orgs", body, &out) +} + +func (c *Client) Org(name string) (Org, error) { + var out Org + return out, c.do(http.MethodGet, "/v1/orgs/"+url.PathEscape(name), nil, &out) +} + +// AddMember adds somebody to a studio, or changes whether they administer it. +func (c *Client) AddMember(org, email string, admin bool) error { + body := map[string]any{"email": email, "admin": admin} + return c.do(http.MethodPost, "/v1/orgs/"+url.PathEscape(org)+"/members", body, nil) +} + +func (c *Client) RemoveMember(org, email string) error { + return c.do(http.MethodDelete, + "/v1/orgs/"+url.PathEscape(org)+"/members/"+url.PathEscape(email), nil, nil) +}