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) +}