diff --git a/README.md b/README.md index 837f071..d23a877 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 sync # bring your library to this machine +termcade keys new ci # a key for a release workflow termcade list # what's here termcade remove author/slug # take one off (updates your library too) ``` diff --git a/account.go b/account.go index 31b6dd0..ed5580a 100644 --- a/account.go +++ b/account.go @@ -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 `") + 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 ") + } + 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 ") + } + if err := client.DeleteKey(args[1]); err != nil { + return err + } + fmt.Println("revoked") + return nil + } + return fmt.Errorf("unknown keys subcommand; try: list · new · revoke ") +} diff --git a/cli.go b/cli.go index bb94083..6c55970 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 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 @@ -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": @@ -321,12 +325,23 @@ func cmdPublish(args []string) error { if len(args) < 2 || len(args) > 3 { return fmt.Errorf("usage: termcade publish [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] @@ -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 } diff --git a/internal/registry/client.go b/internal/registry/client.go index f3aa1b9..301c129 100644 --- a/internal/registry/client.go +++ b/internal/registry/client.go @@ -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) +}