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 sync # bring your library to this machine
termcade keys new ci <username> # a key for a release workflow
termcade list # what's here
termcade remove author/slug # take one off (updates your library too)
```
Expand Down
59 changes: 59 additions & 0 deletions account.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,62 @@ func cmdLogout() error {
fmt.Println("logged out")
return nil
}

// cmdKeys manages publish keys: the credential a release workflow holds so
// publishing does not need a password on a machine nobody is sitting at.
func cmdKeys(args []string) error {
session, err := registry.LoadSession()
if err != nil {
return err
}
if session == nil {
return fmt.Errorf("managing keys requires an account — run `termcade login`")
}
client := registry.New(registry.URL(session), session.Token)

switch {
case len(args) == 0 || args[0] == "list":
keys, err := client.Keys()
if err != nil {
return err
}
if len(keys) == 0 {
fmt.Println("no publish keys — create one with `termcade keys new <name> <username>`")
return nil
}
for _, k := range keys {
used := "never used"
if k.LastUsed != "" {
used = "last used " + k.LastUsed
}
fmt.Printf("%-24s %-20s %s\n %s\n", k.Name, k.Username, used, k.ID)
}
return nil

case args[0] == "new":
if len(args) != 3 {
return fmt.Errorf("usage: termcade keys new <name> <username>")
}
key, err := client.CreateKey(args[1], args[2])
if err != nil {
return err
}
// Printed once because it exists once. The registry stores a hash and
// cannot produce this again, so anything that loses it needs a new key.
fmt.Printf("created %q, publishing as %s\n\n %s\n\n", key.Name, key.Username, key.Token)
fmt.Fprintln(os.Stderr,
"that token is shown once and cannot be recovered — put it somewhere safe now")
return nil

case args[0] == "revoke":
if len(args) != 2 {
return fmt.Errorf("usage: termcade keys revoke <id>")
}
if err := client.DeleteKey(args[1]); err != nil {
return err
}
fmt.Println("revoked")
return nil
}
return fmt.Errorf("unknown keys subcommand; try: list · new <name> <username> · revoke <id>")
}
21 changes: 18 additions & 3 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 keys publish keys for CI (list · new · revoke)

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 @@ -67,6 +69,8 @@ func runCommand(args []string) bool {
err = cmdLogout()
case "publish":
err = cmdPublish(args[1:])
case "keys":
err = cmdKeys(args[1:])
case "dev":
switch {
case len(args) >= 2 && args[1] == "build":
Expand Down Expand Up @@ -321,12 +325,23 @@ func cmdPublish(args []string) error {
if len(args) < 2 || len(args) > 3 {
return fmt.Errorf("usage: termcade publish <repo-url> <tag> [asset]")
}
// TERMCADE_TOKEN is how CI publishes: a scoped key instead of a session,
// on a machine nobody is logged in to. It wins over a stored session so a
// workflow cannot accidentally publish as whoever last used the runner.
session, err := registry.LoadSession()
if err != nil {
return err
}
if session == nil {
return fmt.Errorf("publishing requires an account — run `termcade login` (or `termcade signup`)")
token := strings.TrimSpace(os.Getenv("TERMCADE_TOKEN"))
switch {
case token != "":
// A key names its own handle, so no session is needed or wanted.
case session != nil:
token = session.Token
default:
return fmt.Errorf(
"publishing requires an account — run `termcade login`, " +
"or set TERMCADE_TOKEN to a publish key (`termcade keys new`)")
}

repo, tag := args[0], args[1]
Expand All @@ -347,7 +362,7 @@ func cmdPublish(args []string) error {
asset = m.Slug() + ".tcade"
}

out, err := registry.New(registry.URL(session), session.Token).Publish(repo, tag, asset)
out, err := registry.New(registry.URL(session), token).Publish(repo, tag, asset)
if err != nil {
return err
}
Expand Down
27 changes: 27 additions & 0 deletions internal/registry/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,3 +359,30 @@ func (c *Client) Library() ([]Game, error) {
var games []Game
return games, c.do(http.MethodGet, "/v1/library", nil, &games)
}

// Key is a publish credential. Token is set only by CreateKey, in the one
// response that carries it.
type Key struct {
ID string `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
LastUsed string `json:"last_used_at,omitempty"`
Created string `json:"created_at"`
Token string `json:"token,omitempty"`
}

// CreateKey mints a publish key scoped to one handle.
func (c *Client) CreateKey(name, username string) (Key, error) {
var out Key
body := map[string]string{"name": name, "username": username}
return out, c.do(http.MethodPost, "/v1/keys", body, &out)
}

func (c *Client) Keys() ([]Key, error) {
var keys []Key
return keys, c.do(http.MethodGet, "/v1/keys", nil, &keys)
}

func (c *Client) DeleteKey(id string) error {
return c.do(http.MethodDelete, "/v1/keys/"+url.PathEscape(id), nil, nil)
}