From a20ec741bcef2159c56a0cc46d3329d77ca825b4 Mon Sep 17 00:00:00 2001 From: nicodes Date: Mon, 3 Aug 2026 02:12:34 -0600 Subject: [PATCH] Claim a username when you sign up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An account's handle is the author segment of every game it publishes, so signup asks for one and the session carries it. `termcade signup` prompts for it first, before the email — it is the decision being made, not a field below the password — and the TUI's create-account form grows the same field, shown only when signing up. The greeting reports it, because "publish as nicodes/" is the half an author actually needs. An account without a handle is a real state rather than an error: if the name is taken between the check and the claim, the session is still good and the server says what is missing. Nothing here treats that as a failed signup, which would send someone to retry with an email that is now their own. Pairs with the registry change in termcade-be, which is where handles, orgs and membership live. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 8 +++++- account.go | 33 +++++++++++++++++++++-- internal/registry/client.go | 19 ++++++++++--- internal/shell/market.go | 52 +++++++++++++++++++++++++++--------- internal/shell/shell_test.go | 35 +++++++++++++++++++----- market.go | 4 +-- 6 files changed, 123 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 2f3edbc..fd806b0 100644 --- a/README.md +++ b/README.md @@ -108,10 +108,16 @@ does is give every installed game somewhere to belong — your adds and removes mirror to a library on your account, though nothing restores that onto a new machine yet. +Signing up 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 +governs the studio itself. + The same works from the command line: ```sh -termcade signup # create an account (or: termcade login) +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 list # what's here diff --git a/account.go b/account.go index 117238d..0a377cc 100644 --- a/account.go +++ b/account.go @@ -69,23 +69,52 @@ 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) + session, err := client.Signup(email, password, username) if err != nil { return err } if err := registry.SaveSession(session); err != nil { return err } - fmt.Printf("welcome to termcade, %s\n", session.Email) + // 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/\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 } diff --git a/internal/registry/client.go b/internal/registry/client.go index 4b01877..b661b82 100644 --- a/internal/registry/client.go +++ b/internal/registry/client.go @@ -102,6 +102,13 @@ type Session struct { Registry string `json:"registry"` Email string `json:"email"` Token string `json:"token"` + // Username is the handle this account publishes under. Empty is a real + // state — an account whose signup lost a handle race still logs in — and + // means publishing is refused until one is claimed. + Username string `json:"username,omitempty"` + // Notice is a server-side remark about an otherwise usable session. Not + // persisted: it describes the moment the session was created. + Notice string `json:"notice,omitempty"` } type Client struct { @@ -308,11 +315,14 @@ func (c *Client) Publish(repo, tag, asset string) (Published, error) { type credentials struct { Email string `json:"email"` Password string `json:"password"` + // Username is sent on signup and omitted on login, where the account + // already has one. + Username string `json:"username,omitempty"` } func (c *Client) Login(email, password string) (Session, error) { var out Session - err := c.do(http.MethodPost, "/v1/auth/login", credentials{email, password}, &out) + err := c.do(http.MethodPost, "/v1/auth/login", credentials{Email: email, Password: password}, &out) if errors.Is(err, ErrLoginRequired) { return Session{}, errors.New("wrong email or password") } @@ -323,9 +333,12 @@ func (c *Client) Login(email, password string) (Session, error) { return out, nil } -func (c *Client) Signup(email, password string) (Session, error) { +// Signup creates an account and claims its handle in one call. The handle is +// required: it is the author segment of every game this account publishes. +func (c *Client) Signup(email, password, username string) (Session, error) { var out Session - if err := c.do(http.MethodPost, "/v1/auth/signup", credentials{email, password}, &out); err != nil { + body := credentials{Email: email, Password: password, Username: username} + if err := c.do(http.MethodPost, "/v1/auth/signup", body, &out); err != nil { return Session{}, err } out.Registry = c.baseURL diff --git a/internal/shell/market.go b/internal/shell/market.go index 8fa45f0..c1217c8 100644 --- a/internal/shell/market.go +++ b/internal/shell/market.go @@ -28,7 +28,7 @@ type Marketplace struct { // Account reports the signed-in email, or ok=false when signed out. Account func() (string, bool) SignIn func(email, password string) error - SignUp func(email, password string) error + SignUp func(username, email, password string) error SignOut func() error // Reload re-discovers installed games after an install/remove. Reload func() []engine.Registration @@ -64,11 +64,24 @@ type authState struct { stage int chooseIdx int signup bool - focus int // 0 email, 1 password - email string - password string - err string - busy bool + // focus indexes authFields, which is one longer when signing up: a new + // account claims a handle, and an existing one already has it. + focus int + username string + email string + password string + err string + busy bool +} + +// authFields is the form, in tab order. Signing up asks for a handle first — +// it is the name games are published under, so it is the decision being made, +// not an afterthought below the password. +func (a *authState) fields() []*string { + if a.signup { + return []*string{&a.username, &a.email, &a.password} + } + return []*string{&a.email, &a.password} } func (m Model) loadMarket() tea.Cmd { @@ -93,11 +106,11 @@ func (m Model) removeCmd(id string) tea.Cmd { } } -func (m Model) authCmd(signup bool, email, password string) tea.Cmd { +func (m Model) authCmd(signup bool, username, email, password string) tea.Cmd { mp := m.mp return func() tea.Msg { if signup { - return authDoneMsg{err: mp.SignUp(email, password)} + return authDoneMsg{err: mp.SignUp(username, email, password)} } return authDoneMsg{err: mp.SignIn(email, password)} } @@ -264,19 +277,25 @@ func (m Model) updateAuthKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.auth.err = "" return m, nil case "tab", "down": - m.auth.focus = (m.auth.focus + 1) % 2 + n := len(m.auth.fields()) + m.auth.focus = (m.auth.focus + 1) % n return m, nil case "shift+tab", "up": - m.auth.focus = (m.auth.focus + 1) % 2 + n := len(m.auth.fields()) + m.auth.focus = (m.auth.focus + n - 1) % n return m, nil case "enter": if m.auth.email == "" || m.auth.password == "" { m.auth.err = "email and password are required" return m, nil } + if m.auth.signup && m.auth.username == "" { + m.auth.err = "a username is required — it is what your games are published under" + return m, nil + } m.auth.busy = true m.auth.err = "" - return m, m.authCmd(m.auth.signup, m.auth.email, m.auth.password) + return m, m.authCmd(m.auth.signup, m.auth.username, m.auth.email, m.auth.password) case "backspace": field := m.authField() if *field != "" { @@ -294,10 +313,11 @@ func (m Model) updateAuthKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } func (m *Model) authField() *string { - if m.auth.focus == 0 { + fields := m.auth.fields() + if m.auth.focus < 0 || m.auth.focus >= len(fields) { return &m.auth.email } - return &m.auth.password + return fields[m.auth.focus] } // --------------------------------------------------------------- rendering -- @@ -390,6 +410,12 @@ func (m Model) viewAuth() string { {"email ", m.auth.email, false}, {"password", m.auth.password, true}, } + if m.auth.signup { + fields = append([]struct { + label, value string + mask bool + }{{"username", m.auth.username, false}}, fields...) + } for i, f := range fields { value := f.value if f.mask { diff --git a/internal/shell/shell_test.go b/internal/shell/shell_test.go index 187f5b4..6f896dd 100644 --- a/internal/shell/shell_test.go +++ b/internal/shell/shell_test.go @@ -363,8 +363,12 @@ func fakeMarket(signedIn *bool, installed *[]engine.Registration) *Marketplace { } return "", false }, - SignIn: func(email, password string) error { *signedIn = true; return nil }, - SignUp: func(email, password string) error { *signedIn = true; return nil }, + SignIn: func(email, password string) error { *signedIn = true; return nil }, + SignUp: func(username, email, password string) error { + *signedIn = true + signedUpAs = username + return nil + }, SignOut: func() error { *signedIn = false; return nil }, Reload: func() []engine.Registration { g := &fakeGame{} @@ -377,6 +381,10 @@ func fakeMarket(signedIn *bool, installed *[]engine.Registration) *Marketplace { } } +// signedUpAs records the handle the signup form submitted, so a test can +// prove the field is wired rather than merely present. +var signedUpAs string + func newMarketShell(t *testing.T) (Model, *bool, *[]engine.Registration) { t.Helper() t.Setenv("XDG_CONFIG_HOME", t.TempDir()) @@ -385,6 +393,7 @@ func newMarketShell(t *testing.T) (Model, *bool, *[]engine.Registration) { t.Fatal(err) } signedIn := false + signedUpAs = "" var installed []engine.Registration mp := fakeMarket(&signedIn, &installed) m := New(mp.Reload(), st, sdk.Quadrant, mp) @@ -508,19 +517,31 @@ func TestMarketSignup(t *testing.T) { mm, _ = step(t, mm, key("l")) mm, _ = step(t, mm, key("j")) mm, _ = step(t, mm, key("enter")) - for _, r := range "p@t.dev" { - mm, _ = step(t, mm, key(string(r))) + + // Signing up asks for a handle first: it is what games are published + // under, so it is the decision being made rather than a field below the + // password. + typeIn := func(m Model, text string) Model { + for _, r := range text { + m, _ = step(t, m, key(string(r))) + } + return m } + mm = typeIn(mm, "nicodes") mm, _ = step(t, mm, key("tab")) - for _, r := range "password123" { - mm, _ = step(t, mm, key(string(r))) - } + mm = typeIn(mm, "p@t.dev") + mm, _ = step(t, mm, key("tab")) + mm = typeIn(mm, "password123") + mm, cmd = step(t, mm, key("enter")) if cmd == nil { t.Fatal("no signup command issued") } mm = drain(t, mm, cmd) + if signedUpAs != "nicodes" { + t.Errorf("signup submitted username %q, want nicodes", signedUpAs) + } if !*signedIn { t.Fatal("signup hook not called") } diff --git a/market.go b/market.go index 95a400f..fa7b10b 100644 --- a/market.go +++ b/market.go @@ -108,8 +108,8 @@ func newMarketplace(rt *plugin.Runtime) *shell.Marketplace { return registry.SaveSession(session) }, - SignUp: func(email, password string) error { - session, err := registry.New(registry.URL(nil), "").Signup(email, password) + SignUp: func(username, email, password string) error { + session, err := registry.New(registry.URL(nil), "").Signup(email, password, username) if err != nil { return err }