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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,10 @@ The arcade opens on your recently played games, with the library (`l`) and
marketplace (`m`) one keystroke away. Press `m` to browse — that much is
anonymous — and sign in to install. The bundled games are what a signed-out
arcade plays; an account is what adds to them, and it is also what publishing
and the library mirror hang off. The arcade has its own sign-in/sign-up
screens, so you never need the website.
and the library mirror hang off. Run `termcade login`; it displays a short
one-time code for `https://app.termca.de/pair`, where the browser handles
account authentication. Termcade never asks for account credentials in the
terminal.

The gate is a product decision, not a security boundary: packages are public
GitHub release assets, so an account is not what keeps anyone out. What it
Expand All @@ -111,7 +113,7 @@ mirror to a library on your account, and `termcade sync` brings it back down
enough. Sync only adds: a game you installed from a file stays put, because a
server having never heard of it is not a reason to delete it.

Signing up claims a **username** — your publishing handle, and the author
Creating an account in the app claims a **username** — your publishing handle, and the author
segment of every game you release. `nicodes/pong` and `aviorstudio/tetris` are
the same kind of name: the second belongs to an org, which is a studio more
than one person can publish under. Being a member is enough to publish; admin
Expand All @@ -120,7 +122,7 @@ governs the studio itself.
The same works from the command line:

```sh
termcade signup # create an account + claim a handle
termcade login # approve this CLI in your browser
termcade add aviorstudio/brickough # add straight from the marketplace
termcade dev install <file>.tcade # install a local build while developing
termcade whoami # who you are, and what you publish as
Expand Down
122 changes: 21 additions & 101 deletions account.go
Original file line number Diff line number Diff line change
@@ -1,71 +1,40 @@
package main

import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"strings"

"golang.org/x/term"
"os/signal"

"github.com/aviorstudio/termcade/internal/registry"
)

// promptCredentials collects email (arg or prompt) and a hidden password.
func promptCredentials(args []string, confirm bool) (email, password string, err error) {
reader := bufio.NewReader(os.Stdin)
if len(args) >= 1 {
email = args[0]
} else {
fmt.Print("email: ")
line, err := reader.ReadString('\n')
if err != nil {
return "", "", err
}
email = strings.TrimSpace(line)
}
if email == "" {
return "", "", fmt.Errorf("an email is required")
}

fmt.Print("password: ")
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return "", "", fmt.Errorf("reading password: %w", err)
}
password = string(raw)
if confirm {
fmt.Print("confirm password: ")
again, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return "", "", fmt.Errorf("reading password: %w", err)
}
if string(again) != password {
return "", "", fmt.Errorf("passwords do not match")
}
}
return email, password, nil
func cmdLogin(args []string) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
return cmdLoginContext(ctx, args, os.Stdout)
}

func cmdLogin(args []string) error {
if len(args) > 1 {
return fmt.Errorf("usage: termcade login [email]")
}
email, password, err := promptCredentials(args, false)
if err != nil {
return err
func cmdLoginContext(ctx context.Context, args []string, out io.Writer) error {
if len(args) != 0 {
return fmt.Errorf("usage: termcade login")
}
client := registry.New(registry.URL(nil), "")
session, err := client.Login(email, password)
session, err := client.DeviceLogin(ctx, "Termcade CLI", func(uri, code string) {
fmt.Fprintf(out, "\nUsing a browser, visit:\n %s\n\nAnd enter this code:\n %s\n\nWaiting for approval (Ctrl+C to cancel)…\n", uri, code)
})
if err != nil {
if context.Cause(ctx) != nil {
return errors.New("login canceled")
}
return err
}
if err := registry.SaveSession(session); err != nil {
return err
}
fmt.Printf("logged in as %s (%s)\n", session.Email, session.Registry)
fmt.Fprintf(out, "logged in as %s (%s)\n", session.Email, session.Registry)

// Signing in on a new machine is the moment your library should arrive.
// Nobody should have to know a second command exists to get the games
Expand All @@ -79,55 +48,6 @@ func cmdLogin(args []string) error {
return nil
}

// promptUsername collects the handle a new account claims. It is not
// optional: a handle is the author segment of every game published from this
// account, and an account without one cannot publish at all.
func promptUsername(reader *bufio.Reader) (string, error) {
fmt.Print("username (this is your publishing handle, e.g. nicodes): ")
line, err := reader.ReadString('\n')
if err != nil {
return "", err
}
name := strings.TrimSpace(line)
if name == "" {
return "", fmt.Errorf("a username is required")
}
return name, nil
}

func cmdSignup(args []string) error {
if len(args) > 1 {
return fmt.Errorf("usage: termcade signup [email]")
}
username, err := promptUsername(bufio.NewReader(os.Stdin))
if err != nil {
return err
}
email, password, err := promptCredentials(args, true)
if err != nil {
return err
}
client := registry.New(registry.URL(nil), "")
session, err := client.Signup(email, password, username)
if err != nil {
return err
}
if err := registry.SaveSession(session); err != nil {
return err
}
// The handle is the useful half of the greeting: it is what a game id
// starts with, so it is what an author needs to know they have.
if session.Username != "" {
fmt.Printf("welcome to termcade, %s — publish as %s/<game>\n", session.Email, session.Username)
} else {
fmt.Printf("welcome to termcade, %s\n", session.Email)
}
if session.Notice != "" {
fmt.Fprintln(os.Stderr, "note:", session.Notice)
}
return nil
}

func cmdLogout() error {
if err := registry.ClearSession(); err != nil {
return err
Expand All @@ -137,7 +57,7 @@ func cmdLogout() error {
}

// cmdKeys manages publish keys: the credential a release workflow holds so
// publishing does not need a password on a machine nobody is sitting at.
// publishing does not need an interactive account login on a CI runner.
func cmdKeys(args []string) error {
session, err := registry.LoadSession()
if err != nil {
Expand Down Expand Up @@ -369,7 +289,7 @@ func cmdWhoami() error {

fmt.Printf("%s (%s)\n", me.Email, session.Registry)
if me.Username == "" {
// A real state: an account whose signup lost a race for its handle.
// A real state: an account whose creation lost a race for its handle.
fmt.Println("\nno username yet — claim one with `termcade username <name>`")
} else {
fmt.Printf("\npublish as:\n %-24s you\n", me.Username)
Expand Down Expand Up @@ -410,7 +330,7 @@ func cmdUsername(args []string) error {
}
return fmt.Errorf("%s is taken (by %s)", args[0], kind)
}
fmt.Printf("%s is available — claim it with `termcade signup`\n", args[0])
fmt.Printf("%s is available — create an account in the app, then claim it after `termcade login`\n", args[0])
return nil
}

Expand Down
72 changes: 72 additions & 0 deletions account_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package main

import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/aviorstudio/termcade/internal/registry"
)

func TestLoginDisplaysOnlyPairingMaterialAndPersistsIssuedToken(t *testing.T) {
const (
device = "tcd_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
token = "tcc_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/device/start":
json.NewEncoder(w).Encode(map[string]any{
"device_code": device, "user_code": "BCDF-GHJK",
"verification_uri": registry.PairingURI, "expires_in": 30, "interval": 1,
})
case "/v1/device/poll":
json.NewEncoder(w).Encode(map[string]any{
"status": "approved", "token": token, "credential_id": "credential-id",
"expires_at": time.Now().Add(30 * 24 * time.Hour).Format(time.RFC3339),
})
case "/v1/me":
json.NewEncoder(w).Encode(map[string]any{"email": "player@example.test", "username": "player", "orgs": []any{}, "handles": []any{}})
case "/v1/library":
json.NewEncoder(w).Encode([]any{})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
t.Setenv("TERMCADE_REGISTRY", srv.URL)
t.Setenv("XDG_CONFIG_HOME", t.TempDir())

var out bytes.Buffer
if err := cmdLoginContext(context.Background(), nil, &out); err != nil {
t.Fatal(err)
}
displayed := out.String()
for _, want := range []string{registry.PairingURI, "BCDF-GHJK", "logged in as player@example.test"} {
if !strings.Contains(displayed, want) {
t.Errorf("login output missing %q: %s", want, displayed)
}
}
if strings.Contains(displayed, "tcd_") || strings.Contains(displayed, "tcc_") {
t.Fatalf("login output exposed credential material: %s", displayed)
}
session, err := registry.LoadSession()
if err != nil || session == nil || session.Token != token {
t.Fatalf("saved session = %#v, %v", session, err)
}
}

func TestLoginCancellationIsClean(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := cmdLoginContext(ctx, nil, &bytes.Buffer{})
if err == nil || !strings.Contains(err.Error(), "login canceled") || !errors.Is(context.Cause(ctx), context.Canceled) {
t.Fatalf("cancel error = %v", err)
}
}
2 changes: 1 addition & 1 deletion activity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func session(t *testing.T, f *fakeRegistry) *scores.Store {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("TERMCADE_REGISTRY", f.URL)
if err := registry.SaveSession(registry.Session{
Registry: f.URL, Email: "p@t.dev", Token: "good",
Registry: f.URL, Email: "p@t.dev", Token: "tcc_" + strings.Repeat("A", 43),
}); err != nil {
t.Fatalf("saving a session: %v", err)
}
Expand Down
21 changes: 21 additions & 0 deletions auth_absence_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package main

import (
"os"
"strings"
"testing"
)

func TestTerminalAuthContainsNoLegacySecretHandling(t *testing.T) {
for _, path := range []string{"account.go", "cli.go", "internal/registry/client.go", "internal/shell/market.go"} {
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
for _, forbidden := range []string{"auth/login", "auth/signup", "ReadPassword", "promptCredentials"} {
if strings.Contains(string(raw), forbidden) {
t.Errorf("%s still contains legacy terminal secret handling %q", path, forbidden)
}
}
}
}
10 changes: 5 additions & 5 deletions cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@ usage:
free, signed out)
termcade account delete <u> delete your account

termcade signup [email] create a marketplace account
termcade login [email] sign in (publishing and your account library)
termcade login sign in securely in your browser
termcade logout sign out

termcade dev new <id> [dir] start your own game (id is author/slug)
Expand Down Expand Up @@ -69,8 +68,6 @@ func runCommand(args []string) bool {
err = cmdSync()
case "login":
err = cmdLogin(args[1:])
case "signup":
err = cmdSignup(args[1:])
case "logout":
err = cmdLogout()
case "publish":
Expand Down Expand Up @@ -148,7 +145,7 @@ func cmdAdd(args []string) error {
// pack is what a signed-out arcade has to play; local author iteration is
// the explicit `dev install` path above this product boundary.
if session == nil {
return fmt.Errorf("installing a game requires an account — run `termcade login` (or `termcade signup`)")
return fmt.Errorf("installing a game requires an account — run `termcade login`")
}

return addFromRegistry(session, id)
Expand Down Expand Up @@ -339,6 +336,9 @@ func cmdPublish(args []string) error {
switch {
case token != "":
// A key names its own handle, so no session is needed or wanted.
if !registry.IsPublishKey(token) {
return fmt.Errorf("TERMCADE_TOKEN must be a scoped publish key created by `termcade keys new`")
}
case session != nil:
token = session.Token
default:
Expand Down
11 changes: 10 additions & 1 deletion cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func TestExpiredSessionDoesNotPretendLibraryRemovalSucceeded(t *testing.T) {
t.Cleanup(srv.Close)

t.Setenv("XDG_CONFIG_HOME", t.TempDir())
if err := registry.SaveSession(registry.Session{Registry: srv.URL, Token: "expired"}); err != nil {
if err := registry.SaveSession(registry.Session{Registry: srv.URL, Token: "tcc_" + strings.Repeat("A", 43)}); err != nil {
t.Fatal(err)
}
err := syncLibraryRemove("aviorstudio", "tetris")
Expand Down Expand Up @@ -54,3 +54,12 @@ func TestMarketplaceAddRequestOrderIsStable(t *testing.T) {
t.Fatalf("requests = %v, want %v", got, want)
}
}

func TestPublishEnvironmentAcceptsOnlyPublishKeys(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("TERMCADE_TOKEN", "tcc_"+strings.Repeat("A", 43))
err := cmdPublish([]string{"https://github.com/acme/game", "v1.0.0", "game.tcade"})
if err == nil || !strings.Contains(err.Error(), "scoped publish key") {
t.Fatalf("CLI credential used as publishing-key environment default: %v", err)
}
}
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ require (
charm.land/lipgloss/v2 v2.0.5
github.com/BurntSushi/toml v1.6.0
github.com/tetratelabs/wazero v1.12.0
golang.org/x/term v0.45.0
)

require (
Expand Down
4 changes: 0 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/aviorstudio/termcade/sdk v0.0.1 h1:yUeOCoyOi/RUg8TH/T7X2/ZpoWULP41CLBXQ4M83KHA=
github.com/aviorstudio/termcade/sdk v0.0.1/go.mod h1:sIU1bL36vUsEunUh1mw6Pr9AXMT5UmesaZsT8uSfKhU=
github.com/aviorstudio/termcade/sdk v0.0.2 h1:xUECPAM42Ot++8NAXGp7IPrkj0rtuAv1PtSNoxdPfyQ=
github.com/aviorstudio/termcade/sdk v0.0.2/go.mod h1:sIU1bL36vUsEunUh1mw6Pr9AXMT5UmesaZsT8uSfKhU=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
Expand Down Expand Up @@ -46,5 +44,3 @@ golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
Loading