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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions account.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
94 changes: 94 additions & 0 deletions cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ usage:
or a .tcade file or URL (needs an account)
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
missing here

termcade publish <repo> <tag> [asset]
publish a release: point the marketplace at a
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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
}
7 changes: 7 additions & 0 deletions internal/registry/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}