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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file-or-url>.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 <username> # a key for a release workflow
termcade list # what's here
Expand Down
106 changes: 106 additions & 0 deletions account.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,109 @@ func cmdKeys(args []string) error {
}
return fmt.Errorf("unknown keys subcommand; try: list · new <name> <username> · revoke <id>")
}

// 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 <username>`")
}
return nil

case args[0] == "new":
if len(args) < 2 || len(args) > 3 {
return fmt.Errorf("usage: termcade org new <username> [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/<game>, and add people with `termcade org add %s <email>`\n",
org.Username, org.Username)
return nil

case args[0] == "show":
if len(args) != 2 {
return fmt.Errorf("usage: termcade org show <username>")
}
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 <org> <email> [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 <org> <email>")
}
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 <username> [bio] · show <username> · " +
"add <org> <email> [admin] · remove <org> <email>")
}
4 changes: 4 additions & 0 deletions cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand Down
48 changes: 48 additions & 0 deletions internal/registry/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}