From 76351ecb5c867da50f0f2f1b3575029faa38d285 Mon Sep 17 00:00:00 2001 From: nicodes Date: Mon, 3 Aug 2026 02:53:22 -0600 Subject: [PATCH] Bring your library down, not just up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds and removes have mirrored to a library on your account for a while, from the arcade, the CLI and the app alike, and nothing brought it back. The README said so. `termcade sync` installs everything on your account that is missing here, and `termcade login` runs it — signing in on a new machine is the moment your games should arrive, and nobody should have to learn a second command to get what they already own. Sync only adds. A game installed from a file stays put: a server having never heard of it is not a reason to delete somebody's game, and the opposite policy turns a login into data loss. One failure does not strand the rest. A release deleted after publishing is the publisher's doing, not this player's, so that game is reported and the others still install. The exit code still reflects it. Restore after login is best-effort. The login itself succeeded, and reporting failure because a download did not finish would be a lie about what happened. Verified on a scratch stack: a real account with tetris in its library, on a machine where tetris had been removed. Sync restored it, left asteroid and brickough — installed locally, absent from the library — alone, and did nothing on a second run. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 6 ++- account.go | 10 ++++ cli.go | 94 +++++++++++++++++++++++++++++++++++++ internal/registry/client.go | 7 +++ 4 files changed, 115 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fd806b0..837f071 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,10 @@ screens, so you never need the website. 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 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. +mirror to a library on your account, and `termcade sync` brings it back down +— which `termcade login` does for you, so signing in on a new machine is +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 segment of every game you release. `nicodes/pong` and `aviorstudio/tetris` are diff --git a/account.go b/account.go index 0a377cc..31b6dd0 100644 --- a/account.go +++ b/account.go @@ -66,6 +66,16 @@ func cmdLogin(args []string) error { return err } fmt.Printf("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 + // they already own, so this runs here rather than waiting to be asked. + // + // Best-effort: the login itself succeeded, and reporting it as a failure + // because a restore did not finish would be a lie about what happened. + if err := cmdSync(); err != nil { + fmt.Fprintf(os.Stderr, "note: could not restore your library: %v\n", err) + } return nil } diff --git a/cli.go b/cli.go index 79292a9..bb94083 100644 --- a/cli.go +++ b/cli.go @@ -25,6 +25,8 @@ usage: or a .tcade file or URL (needs an account) 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 + missing here termcade publish [asset] publish a release: point the marketplace at a @@ -55,6 +57,8 @@ func runCommand(args []string) bool { err = cmdRemove(args[1:]) case "list": err = cmdList() + case "sync": + err = cmdSync() case "login": err = cmdLogin(args[1:]) case "signup": @@ -412,3 +416,93 @@ func cmdDevBuild(args []string) error { fmt.Printf("built %s %s → %s\n", m.Game.ID, m.Game.Version, out) return nil } + +// cmdSync installs everything on your account that is missing from this +// machine. +// +// The library is the half of an account that follows you: adds and removes +// mirror to it from the arcade, the CLI and the app alike, and until now +// nothing brought it back down. A new machine, a reinstall, or a game added +// from your phone all end here. +// +// It only adds. A game installed locally but absent from the library is left +// alone — that is a local file someone installed on purpose, and deleting it +// because a server has not heard of it would be the wrong way round. +func cmdSync() error { + session, err := registry.LoadSession() + if err != nil { + return err + } + if session == nil { + return fmt.Errorf("syncing your library requires an account — run `termcade login`") + } + client := registry.New(registry.URL(session), session.Token) + + library, err := client.Library() + if errors.Is(err, registry.ErrLoginRequired) { + return fmt.Errorf("your session has expired — run `termcade login`") + } + if err != nil { + return err + } + if len(library) == 0 { + fmt.Println("nothing in your library yet — add a game and it will follow you") + return nil + } + + rt := plugin.NewRuntime(context.Background()) + installed := map[string]bool{} + for _, g := range discoverGames(rt) { + installed[g.Info.ID] = true + } + // Closed before installing: a runtime holding compiled modules from the + // games directory has no business being open while that directory is + // written to. + rt.Close() + + added, failed := 0, 0 + for _, game := range library { + if installed[game.ID] { + continue + } + author, slug, ok := strings.Cut(game.ID, "/") + if !ok { + continue + } + if !game.HasPackage { + fmt.Fprintf(os.Stderr, "skipping %s: nothing published to install yet\n", game.ID) + continue + } + path, err := client.Download(author, slug) + if err != nil { + // One unavailable game must not strand the rest — a deleted + // release is the publisher's doing, not this player's. + fmt.Fprintf(os.Stderr, "could not restore %s: %v\n", game.ID, err) + failed++ + continue + } + raw, readErr := os.ReadFile(path) + os.Remove(path) + if readErr != nil { + fmt.Fprintf(os.Stderr, "could not restore %s: %v\n", game.ID, readErr) + failed++ + continue + } + if err := installPackage(raw); err != nil { + fmt.Fprintf(os.Stderr, "could not restore %s: %v\n", game.ID, err) + failed++ + continue + } + added++ + } + + switch { + case added == 0 && failed == 0: + fmt.Println("your library is already here") + case failed > 0: + return fmt.Errorf("restored %d game(s); %d could not be installed", added, failed) + default: + fmt.Printf("restored %d game(s)\n", added) + } + return nil +} diff --git a/internal/registry/client.go b/internal/registry/client.go index b661b82..f3aa1b9 100644 --- a/internal/registry/client.go +++ b/internal/registry/client.go @@ -352,3 +352,10 @@ func (c *Client) LibraryAdd(author, slug string) error { func (c *Client) LibraryRemove(author, slug string) error { return c.do(http.MethodDelete, "/v1/library/"+author+"/"+slug, nil, nil) } + +// Library lists the games on this account, newest addition first. It is the +// server's copy: what you have added anywhere, not what is installed here. +func (c *Client) Library() ([]Game, error) { + var games []Game + return games, c.do(http.MethodGet, "/v1/library", nil, &games) +}