From ae5bc36ba0800c20d77c5f05464228145fdb88e3 Mon Sep 17 00:00:00 2001 From: nicodes Date: Mon, 3 Aug 2026 22:59:00 -0600 Subject: [PATCH] Make marketplace library state authoritative --- README.md | 2 +- cli.go | 114 ++++++++++++++----------------- cli_test.go | 56 +++++++++++++++ docs/packaging.md | 4 +- docs/sdk.md | 4 +- internal/registry/client.go | 17 ++++- internal/registry/client_test.go | 16 +++++ market.go | 13 ++-- scaffold.go | 2 +- 9 files changed, 152 insertions(+), 76 deletions(-) create mode 100644 cli_test.go diff --git a/README.md b/README.md index 61599bb..b6c60b5 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ The same works from the command line: ```sh 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 dev install .tcade # install a local build while developing termcade whoami # who you are, and what you publish as termcade username # claim, rename, or check one is free termcade org new aviorstudio # a studio to publish under diff --git a/cli.go b/cli.go index 44e684c..7bedc5e 100644 --- a/cli.go +++ b/cli.go @@ -4,8 +4,6 @@ import ( "context" "errors" "fmt" - "io" - "net/http" "os" "os/exec" "path/filepath" @@ -21,8 +19,8 @@ const usage = `termcade — an arcade in your terminal usage: termcade play (marketplace included — press m) - termcade add add a game: author/slug from the marketplace, - or a .tcade file or URL (needs an account) + termcade add add a marketplace game to your account and + install it on this machine termcade remove remove an added game (id is author/slug) termcade list list the games in your arcade termcade sync install everything on your account that is @@ -47,8 +45,9 @@ usage: termcade dev new [dir] start your own game (id is author/slug) termcade dev build [dir] build a game directory into a .tcade package + termcade dev install install a local .tcade while developing -This arcade is also the whole dev kit: dev new, hack, dev build, add, +This arcade is also the whole dev kit: dev new, hack, dev build, dev install, play. See docs/sdk.md to get started. ` @@ -92,8 +91,10 @@ func runCommand(args []string) bool { err = cmdDevBuild(args[2:]) case len(args) >= 2 && args[1] == "new": err = cmdDevNew(args[2:]) + case len(args) >= 2 && args[1] == "install": + err = cmdDevInstall(args[2:]) default: - err = fmt.Errorf("unknown dev subcommand; try: termcade dev new · termcade dev build [dir]") + err = fmt.Errorf("unknown dev subcommand; try: termcade dev new · termcade dev build [dir] · termcade dev install ") } case "version", "-v", "--version": fmt.Println("termcade", resolveVersion()) @@ -120,59 +121,37 @@ var slugOnlyRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) func cmdAdd(args []string) error { if len(args) != 1 { - return fmt.Errorf("usage: termcade add ") + return fmt.Errorf("usage: termcade add ") } - src := args[0] + id := args[0] // Pinning is gone. Someone with the old spelling in their fingers or in a // script should be told that, not handed a missing-file error for what is // obviously a marketplace id. Before the account check, because this is // the command being wrong rather than the caller being anonymous — being // told to sign in and then told the syntax changed is two trips. - if id, _, pinned := strings.Cut(src, "@"); pinned && gameIDRe.MatchString(id) { - return fmt.Errorf("versions cannot be pinned — `termcade add %s` installs what %s currently ships", id, id) + if bare, _, pinned := strings.Cut(id, "@"); pinned && gameIDRe.MatchString(bare) { + return fmt.Errorf("versions cannot be pinned — `termcade add %s` installs what %s currently ships", bare, bare) } - - // Forgetting the author is the likeliest way to get this wrong, and - // "open asteroid: no such file or directory" answers a question about the - // filesystem that nobody asked. Anything with a slash, a scheme, or a - // .tcade on the end meant a file or a URL and is left alone. - if slugOnlyRe.MatchString(src) { - if _, statErr := os.Stat(src); statErr != nil { - return fmt.Errorf("%q is missing an author — marketplace ids look like author/slug, e.g. aviorstudio/%s", src, src) - } + if slugOnlyRe.MatchString(id) { + return fmt.Errorf("%q is missing an author — marketplace ids look like author/slug, e.g. aviorstudio/%s", id, id) + } + if !gameIDRe.MatchString(id) { + return fmt.Errorf("add installs marketplace ids only; use `termcade dev install ` for a local package") } session, err := registry.LoadSession() if err != nil { return err } - // Every install goes through an account, whatever the source. The bundled - // starter pack is what a signed-out arcade has to play; adding to it is - // the thing an account is for. + // Every marketplace install goes through an account. The bundled starter + // 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`)") } - // Marketplace id → fetch through the registry and sync the library. - if _, statErr := os.Stat(src); gameIDRe.MatchString(src) && statErr != nil { - return addFromRegistry(session, src) - } - - var raw []byte - if strings.Contains(src, "://") { - if !strings.HasPrefix(src, "https://") { - return fmt.Errorf("refusing to download over %s; games install code — use https", - strings.SplitN(src, "://", 2)[0]) - } - raw, err = download(src) - } else { - raw, err = os.ReadFile(src) - } - if err != nil { - return err - } - return installPackage(raw) + return addFromRegistry(session, id) } // addFromRegistry installs a marketplace id. The session is never nil: cmdAdd @@ -181,6 +160,12 @@ func addFromRegistry(session *registry.Session, id string) error { author, slug, _ := strings.Cut(id, "/") client := registry.New(registry.URL(session), session.Token) + // The account library is authoritative; the package on this machine is a + // cache of that choice. Record the choice first so a failed download is + // restored by the next sync rather than becoming an untracked install. + if err := client.LibraryAdd(author, slug); err != nil { + return err + } path, err := client.Download(author, slug) if errors.Is(err, registry.ErrLoginRequired) { return fmt.Errorf("your session has expired — run `termcade login`") @@ -197,13 +182,23 @@ func addFromRegistry(session *registry.Session, id string) error { if err := installPackage(raw); err != nil { return err } - // Library sync is best-effort: the game is already installed locally. - if err := client.LibraryAdd(author, slug); err != nil { - fmt.Fprintf(os.Stderr, "warning: could not add %s to your library: %v\n", id, err) - } return nil } +// cmdDevInstall is the deliberate local escape hatch for an author iterating +// on a package. Player installs stay behind the marketplace API; development +// bytes never pretend to be account library state. +func cmdDevInstall(args []string) error { + if len(args) != 1 { + return fmt.Errorf("usage: termcade dev install ") + } + raw, err := os.ReadFile(args[0]) + if err != nil { + return err + } + return installPackage(raw) +} + // installPackageBytes validates and installs a .tcade without printing, so // the TUI can call it too: manifest parses, ABI is speakable, the wasm // compiles and exports the termcade ABI — then an atomic extract into the @@ -243,18 +238,6 @@ func installPackage(raw []byte) error { return nil } -func download(url string) ([]byte, error) { - resp, err := http.Get(url) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("downloading %s: %s", url, resp.Status) - } - return io.ReadAll(io.LimitReader(resp.Body, 128<<20)) -} - // removeLocal deletes an installed game's directory. Silent, TUI-safe. func removeLocal(author, slug string) error { if author == "" || slug == "" || @@ -272,7 +255,10 @@ func removeLocal(author, slug string) error { return os.RemoveAll(dir) } -// syncLibraryRemove best-effort mirrors a removal to the signed-in library. +// syncLibraryRemove records a removal in the authoritative account library. +// A package absent from the library is a local/dev/starter package and is +// still removable. Other failures stop the local mutation so clients do not +// silently diverge from the account. func syncLibraryRemove(author, slug string) error { session, err := registry.LoadSession() if err != nil || session == nil { @@ -280,10 +266,12 @@ func syncLibraryRemove(author, slug string) error { } client := registry.New(registry.URL(session), session.Token) err = client.LibraryRemove(author, slug) - if err == nil || errors.Is(err, registry.ErrLoginRequired) || - strings.Contains(err.Error(), "not in your library") { + if err == nil || errors.Is(err, registry.ErrNotFound) { return nil } + if errors.Is(err, registry.ErrLoginRequired) { + return fmt.Errorf("your session has expired — run `termcade login`") + } return err } @@ -295,13 +283,13 @@ func cmdRemove(args []string) error { if !ok { return fmt.Errorf("id must look like author/slug") } + if err := syncLibraryRemove(author, slug); err != nil { + return err + } if err := removeLocal(author, slug); err != nil { return err } fmt.Printf("removed %s\n", args[0]) - if err := syncLibraryRemove(author, slug); err != nil { - fmt.Fprintf(os.Stderr, "warning: could not update your library: %v\n", err) - } return nil } diff --git a/cli_test.go b/cli_test.go new file mode 100644 index 0000000..a58febe --- /dev/null +++ b/cli_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + + "github.com/aviorstudio/termcade/internal/registry" +) + +func TestAddAcceptsMarketplaceIDsOnly(t *testing.T) { + for _, input := range []string{"game.tcade", "https://example.test/game.tcade", "./game.tcade"} { + err := cmdAdd([]string{input}) + if err == nil || !strings.Contains(err.Error(), "dev install") { + t.Errorf("cmdAdd(%q) = %v, want dev-install guidance", input, err) + } + } +} + +func TestExpiredSessionDoesNotPretendLibraryRemovalSucceeded(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + t.Cleanup(srv.Close) + + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + if err := registry.SaveSession(registry.Session{Registry: srv.URL, Token: "expired"}); err != nil { + t.Fatal(err) + } + err := syncLibraryRemove("aviorstudio", "tetris") + if err == nil || !strings.Contains(err.Error(), "session has expired") { + t.Fatalf("remove = %v, want expired-session error", err) + } +} + +func TestMarketplaceAddRequestOrderIsStable(t *testing.T) { + // Guard the exact invariant independently of any later diagnostic request: + // choosing the game is durable before attempting to fill the local cache. + want := []string{"PUT /v1/library/aviorstudio/tetris", "GET /v1/games/aviorstudio/tetris/resolve"} + var got []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = append(got, r.Method+" "+r.URL.Path) + if r.Method == http.MethodPut { + w.WriteHeader(http.StatusNoContent) + return + } + http.Error(w, `{"message":"stop"}`, http.StatusBadGateway) + })) + t.Cleanup(srv.Close) + _ = addFromRegistry(®istry.Session{Registry: srv.URL, Token: "session"}, "aviorstudio/tetris") + if !slices.Equal(got, want) { + t.Fatalf("requests = %v, want %v", got, want) + } +} diff --git a/docs/packaging.md b/docs/packaging.md index 0fb4e0b..d1829b2 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -48,8 +48,8 @@ mygame.tcade ## Installing ```sh -termcade add mygame.tcade # from a file -termcade add https://…/mygame.tcade # from a URL +termcade dev install mygame.tcade # local author iteration +termcade add you/mygame # player install through the marketplace API termcade list termcade remove you/mygame ``` diff --git a/docs/sdk.md b/docs/sdk.md index d8eed61..5d66710 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -13,7 +13,7 @@ The arcade is the dev kit — scaffold a working game and play it: termcade dev new you/mygame cd mygame go mod tidy -termcade dev build && termcade add build/mygame.tcade && termcade +termcade dev build && termcade dev install build/mygame.tcade && termcade ``` `dev new` generates a playable paddle-and-ball demo; edit `game.go` from @@ -96,7 +96,7 @@ Add a `termcade.toml` (see [packaging.md](packaging.md)), then: ```sh termcade dev build # → build/mygame.tcade -termcade add build/mygame.tcade +termcade dev install build/mygame.tcade termcade # your game is on the menu ``` diff --git a/internal/registry/client.go b/internal/registry/client.go index 9443261..120af8d 100644 --- a/internal/registry/client.go +++ b/internal/registry/client.go @@ -43,6 +43,21 @@ const maxPackageSize = 64 << 20 // ErrLoginRequired distinguishes "you need an account" from real failures. var ErrLoginRequired = errors.New("login required") +// ErrNotFound lets callers distinguish an absent library row from a network +// failure without replacing the API's useful human-readable message. +var ErrNotFound = errors.New("not found") + +type responseError struct { + status int + message string +} + +func (e responseError) Error() string { return e.message } + +func (e responseError) Is(target error) bool { + return target == ErrNotFound && e.status == http.StatusNotFound +} + // ErrUnreachable is the marketplace not answering at all: no network, or // nothing listening where the registry is supposed to be. // @@ -177,7 +192,7 @@ func (c *Client) do(method, path string, body, out any) error { var msg apiMessage raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) if json.Unmarshal(raw, &msg) == nil && msg.Message != "" { - return errors.New(msg.Message) + return responseError{status: resp.StatusCode, message: msg.Message} } if resp.StatusCode >= 500 { return fmt.Errorf("the marketplace is having trouble (HTTP %d) — try again shortly", resp.StatusCode) diff --git a/internal/registry/client_test.go b/internal/registry/client_test.go index 4a20439..e3ccd3b 100644 --- a/internal/registry/client_test.go +++ b/internal/registry/client_test.go @@ -141,6 +141,22 @@ func TestDownloadReportsAnUnauthorizedResponse(t *testing.T) { } } +func TestNotFoundKeepsItsMessageAndCanBeClassified(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"message":"not in your library"}`)) + })) + t.Cleanup(srv.Close) + + err := New(srv.URL, "session").LibraryRemove("aviorstudio", "tetris") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("error = %v, want ErrNotFound", err) + } + if err.Error() != "not in your library" { + t.Fatalf("message = %q", err) + } +} + // Resolve sends the ABI so the registry can pick a release this binary can // run, and sends no version at all — pinning is gone. func TestResolveSendsTheABIAndNoVersion(t *testing.T) { diff --git a/market.go b/market.go index fa7b10b..3e0c0e5 100644 --- a/market.go +++ b/market.go @@ -62,6 +62,11 @@ func newMarketplace(rt *plugin.Runtime) *shell.Marketplace { return fmt.Errorf("bad game id %q", id) } client := registry.New(registry.URL(session), session.Token) + // Account state is the source of truth; the local package is a cache + // which sync can restore if this download is interrupted. + if err := client.LibraryAdd(author, slug); err != nil { + return err + } path, err := client.Download(author, slug) if err != nil { return err @@ -74,9 +79,6 @@ func newMarketplace(rt *plugin.Runtime) *shell.Marketplace { if _, _, err := installPackageBytes(raw); err != nil { return err } - // Library sync is best-effort; the game is already installed - // locally either way. - _ = client.LibraryAdd(author, slug) return nil }, @@ -85,11 +87,10 @@ func newMarketplace(rt *plugin.Runtime) *shell.Marketplace { if !ok { return fmt.Errorf("bad game id %q", id) } - if err := removeLocal(author, slug); err != nil { + if err := syncLibraryRemove(author, slug); err != nil { return err } - _ = syncLibraryRemove(author, slug) - return nil + return removeLocal(author, slug) }, Account: func() (string, bool) { diff --git a/scaffold.go b/scaffold.go index 4088e44..1419c47 100644 --- a/scaffold.go +++ b/scaffold.go @@ -60,7 +60,7 @@ func cmdDevNew(args []string) error { next: cd %s go mod tidy # see go.mod if you develop against a local termcade checkout - termcade dev build && termcade add build/%s.tcade && termcade + termcade dev build && termcade dev install build/%s.tcade && termcade `, dir, dir, slug) return nil }