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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file-or-url>.tcade # or from a package you have (also signed in)
termcade dev install <file>.tcade # install a local build while developing
termcade whoami # who you are, and what you publish as
termcade username <name> # claim, rename, or check one is free
termcade org new aviorstudio # a studio to publish under
Expand Down
114 changes: 51 additions & 63 deletions cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
Expand All @@ -21,8 +19,8 @@ const usage = `termcade — an arcade in your terminal

usage:
termcade play (marketplace included — press m)
termcade add <game> add a game: author/slug from the marketplace,
or a .tcade file or URL (needs an account)
termcade add <author/slug> add a marketplace game to your account and
install it on this machine
termcade remove <id> 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
Expand All @@ -47,8 +45,9 @@ usage:

termcade dev new <id> [dir] start your own game (id is author/slug)
termcade dev build [dir] build a game directory into a .tcade package
termcade dev install <file> 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.
`

Expand Down Expand Up @@ -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 <author/slug> · termcade dev build [dir]")
err = fmt.Errorf("unknown dev subcommand; try: termcade dev new <author/slug> · termcade dev build [dir] · termcade dev install <file>")
}
case "version", "-v", "--version":
fmt.Println("termcade", resolveVersion())
Expand All @@ -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 <author/slug | file | url>")
return fmt.Errorf("usage: termcade add <author/slug>")
}
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 <file>` 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
Expand All @@ -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`")
Expand All @@ -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 <file.tcade>")
}
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
Expand Down Expand Up @@ -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 == "" ||
Expand All @@ -272,18 +255,23 @@ 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 {
return nil // purely local when signed out
}
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
}

Expand All @@ -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
}

Expand Down
56 changes: 56 additions & 0 deletions cli_test.go
Original file line number Diff line number Diff line change
@@ -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(&registry.Session{Registry: srv.URL, Token: "session"}, "aviorstudio/tetris")
if !slices.Equal(got, want) {
t.Fatalf("requests = %v, want %v", got, want)
}
}
4 changes: 2 additions & 2 deletions docs/packaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
4 changes: 2 additions & 2 deletions docs/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```

Expand Down
17 changes: 16 additions & 1 deletion internal/registry/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions internal/registry/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading