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) +}