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
68 changes: 44 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ you install, built from source in
[termcade-games](https://github.com/aviorstudio/termcade-games) and vendored
here as packages. Nothing is compiled into the binary as a game.

Remove one and it stays removed — the arcade seeds a game once, not every
run. For anything else, press `m` for the marketplace.
Uninstall one and it stays uninstalled — the arcade seeds a game once, not every
run. The TUI opens directly on Marketplace; Library includes installed games
even while signed out.

## Run

Expand Down Expand Up @@ -57,8 +58,9 @@ requirements change.
TERMCADE_PIXELS=sextant go run .
```

In the arcade, `p` on the index or library cycles the pixel style and
persists it to `~/.config/termcade/settings.json`; `TERMCADE_PIXELS`
In the arcade, the pixel-style control in Settings or the pause menu cycles
the style for the next game start and persists it to
`~/.config/termcade/settings.json`; `TERMCADE_PIXELS`
overrides the saved choice for a run. Games never change for any of this —
the renderer owns the look.

Expand All @@ -77,41 +79,59 @@ the auto-repeat heuristic.

| Key | Action |
| --- | --- |
| ↑/↓ or j/k | menu / pause navigation |
| enter | select / launch |
| Tab / Shift+Tab | focus navigation, page content, or actions; move between form controls |
| ↑/↓ | select a row or pause-menu item |
| ←/→ (actions) | select the focused action |
| enter | activate a focused item/button; ordinary game rows open details |
| Page Up / Page Down | page lists or scroll long detail/documentation text |
| ←/→ (or a/d, h/l) | move paddle / turn ship / shift piece |
| ↑ (or w) | thrust (Asteroid) · rotate (Tetris) |
| ↓ (or s) | soft drop (Tetris) |
| space or z | A button: launch ball / fire / hard drop |
| x | B button |
| esc or p | pause |
| l (index) | library — every game you have |
| m (index/library) | marketplace |
| r (index/library) | remove the selected added game |
| p (index/library) | cycle pixel style (incl. ASCII art) |
| q | quit (from menu) |
| esc (outside gameplay) | dismiss/back; stop waiting and reconcile a pending form mutation |
| Ctrl+C | quit and save local scores |

While a game runs, game inputs cannot operate the sidebar. Pause first, then
Tab into navigation. The sidebar is 22 columns wide at 120+ terminal columns;
below that it overlays the content on demand. Non-game content is capped at
100 columns. Gameplay keeps its fixed cell footprint and hides the sidebar
when needed to fit. See [TUI workflows](docs/tui.md) for the complete contract.

High scores persist to `~/.config/termcade/scores.json`, and are yours whether
or not you have an account — see [Your history](#your-history).

## The marketplace

The arcade opens on your recently played games, with the library (`l`) and
marketplace (`m`) one keystroke away. Press `m` to browse — that much is
anonymous — and sign in to install. The bundled games are what a signed-out
arcade plays; an account is what adds to them, and it is also what publishing
and the library mirror hang off. Run `termcade login`; it displays a short
one-time code for `https://app.termca.de/pair`, where the browser handles
account authentication. Termcade never asks for account credentials in the
terminal.
The TUI opens on Marketplace and shares the web app's Marketplace, Docs,
Library, owner/game pages, and account/settings destinations. Browsing is
anonymous. Library joins account games with installed packages, with clear
installation/membership badges and Continue Playing entries.

In the **TUI**, Add saves account membership without downloading. Play installs
a missing compatible account game; healthy installed games remain playable
offline. Remove from Library changes only account membership; Uninstall here
removes only the local package, after confirmation. No automatic updates replace
an existing local copy.

Sign in from Settings without exiting the TUI: it shows the trusted pairing URL
and one-time code, and opens a browser only when you choose Open browser.
Account authentication stays in the browser. Settings also supports handles,
org/member administration, CLI-session revocation, account deletion, and
revoke-then-clear sign-out. The normal pairing destination remains
`https://app.termca.de/pair`.

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, 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.
does is give account games a library that follows you between devices.

**Existing shell commands keep their established behavior:** `termcade add`
saves membership and installs; `termcade remove` removes membership and the local
copy; `termcade login` restores missing account packages; `termcade sync` adds
missing packages without deleting local-only games or automatically updating
existing copies. The new TUI's separate actions do not silently change those
command contracts.

Creating an account in the app claims a **username** — your publishing handle, and the author
segment of every game you release. `nicodes/pong` and `aviorstudio/tetris` are
Expand Down
12 changes: 11 additions & 1 deletion cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,13 @@ func cmdDevInstall(args []string) error {
// compiles and exports the termcade ABI — then an atomic extract into the
// games directory.
func installPackageBytes(raw []byte) (*manifest.Package, string, error) {
return installPackageBytesContext(context.Background(), raw)
}

func installPackageBytesContext(ctx context.Context, raw []byte) (*manifest.Package, string, error) {
if err := ctx.Err(); err != nil {
return nil, "", err
}
pkg, err := manifest.ReadPackage(raw)
if err != nil {
return nil, "", err
Expand All @@ -209,7 +216,7 @@ func installPackageBytes(raw []byte) (*manifest.Package, string, error) {
return nil, "", fmt.Errorf("%s needs ABI v%d; this termcade speaks v%d",
pkg.Manifest.Game.ID, pkg.Manifest.Requirements.ABI, 1)
}
rt := plugin.NewRuntime(context.Background())
rt := plugin.NewRuntime(ctx)
defer rt.Close()
if _, err := rt.Compile(pkg.Manifest.Game.ID, pkg.Wasm); err != nil {
return nil, "", err
Expand All @@ -219,6 +226,9 @@ func installPackageBytes(raw []byte) (*manifest.Package, string, error) {
if err != nil {
return nil, "", err
}
if err := ctx.Err(); err != nil {
return nil, "", err
}
dest, err := pkg.Install(gamesDir)
if err != nil {
return nil, "", err
Expand Down
120 changes: 120 additions & 0 deletions docs/tui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# TUI workflows

The terminal application follows the web product's destinations and information
hierarchy, while keeping terminal-cell graphics and keyboard input. The web app,
backend behavior, game ABI, and sandbox limits are not changed by this shell.

## Navigation and focus

- Marketplace is the initial destination, including while signed out.
- Tab/Shift+Tab cycles navigation, content, and actions. Arrows select within the
focused area; Enter activates. A normal game row opens details; Continue
Playing and the explicit Play action launch the game.
- At 120 or more columns the sidebar takes 22 columns. At smaller widths it is
an on-demand overlay. Non-game content is centered and capped at 100 columns.
- The supported baseline is 80×24. Lists and long text scroll. Page Up/Down
moves list selection with the viewport, so actions do not target a hidden row.
- During active gameplay, keys belong to the game. Esc/P pauses; Tab can then
focus navigation. Leaving Play closes the guest and records an abandoned run.
- Pause retains Resume, Restart, pixel selection for the next start, and Leave
Play. Pixel changes do not stretch or mutate an existing guest framebuffer.
- Forms consume typed/pasted characters. Tab moves between fields/buttons;
left/right, Home/End, Backspace/Delete and Ctrl+U edit a field. Enter activates
a button rather than silently submitting from a text field.
- Ctrl+C quits from every state. Escape dismisses/backtracks outside gameplay.

## Library and package actions

Library is an ID-keyed union of account membership and installed packages.
Installed versions are shown independently of the newest registry metadata.

| Action | Account membership | Local package |
| --- | --- | --- |
| Add | Added | Unchanged |
| Play, healthy installed copy | Unchanged | Runs as-is, including offline |
| Play, missing account game | Unchanged | Downloaded, digest/identity/ABI validated, installed, launched |
| Remove from Library | Removed | Preserved |
| Uninstall here | Preserved | Removed after typed confirmation |

Account-only entries show Not installed. Local-only is asserted only when
membership is known; unavailable account reads show unknown state, and
signed-out copies are described as on this machine. Broken/incompatible packages
remain visible with an explanation instead of being treated as playable. The
TUI does not silently update or overwrite existing copies.

Sign-in loads account metadata and the library, not all packages. Existing
`termcade add/remove/login/sync` command-line semantics are unchanged.

## Details and settings

Owner/game pages expose public metadata, repository/website links, game actions,
and UTC year-to-date play counts. A seven-row cell heatmap uses the web's relative
intensity thresholds; Enter opens the scrollable exact daily counts. Public
aggregate activity is distinct from personal/machine-local high scores.

Settings provides handle availability/claim/rename, org creation, member
addition/role changes/removal, org dissolution, CLI login-session listing and
revocation, account deletion, and sign-out. The API remains authoritative for
permissions and refusal rules. Publish keys are not CLI login sessions.

Uninstall, membership removal from an org, org/account deletion and revocation
show explicit confirmations. Escape during a submitted mutation stops waiting
and refreshes actual state: a confirmed remote/local operation may already have
committed, so cancellation does not promise rollback.

New logins retain their credential ID and expiry in the atomic mode-0600 session
file. TUI sign-out attempts revocation and then clears the local credential even
when the remote call fails. Failures are reported honestly. Legacy sessions with
no ID clear locally with a warning; the TUI never guesses another session to
revoke. Account changes/expiry clear cached private member/session views.

## Local development pairing

Use the actual API/app ports printed by `make dev` in the backend checkout:

```sh
TERMCADE_REGISTRY=http://127.0.0.1:8080 \
TERMCADE_DEV_APP_URL=http://127.0.0.1:8081 \
mise exec -- go run .
```

`TERMCADE_DEV_APP_URL` is an explicit **TUI-only** local pairing opt-in. Both
origins must be HTTP with a port, have the same literal loopback host, and carry
no credentials, path, query or fragment. DNS names, LAN hosts and remote apps are
refused. The API's normal trusted pairing response is still validated; the
display/open target is the explicitly configured local `/pair` page.

Local credentials live under
`<config>/termcade/local/<registry-hash>/session.json`; this mode never loads the
normal production session file. Changing a registry override without matching
credentials cannot forward the old registry's credential. Bound TUI HTTP clients
also refuse cross-origin redirects.

The ordinary `termcade login` command and its production pairing policy remain
unchanged. If testing shell commands as well, use separate XDG_CONFIG_HOME and
XDG_DATA_HOME directories; do not mix production sessions into a local registry.
Changing the local API port selects a different local credential store.

## Verification

Use the pinned Go toolchain and the existing CI gates:

```sh
mise install
mise exec -- gofmt -l .
mise exec -- go vet ./...
mise exec -- go vet ./sdk/...
mise exec -- go test -race -count=1 -timeout 10m ./... ./sdk/...
GOWORK=off mise exec -- go build ./...
GOWORK=off mise exec -- go vet ./...
```

Formatting output must be empty. Cross-compile the targets and flags in
`.github/actions/build/action.yml` before a release. The product-model tests use
non-nil ProductServices and verify the real application path; older nil-service
shell tests remain controls for standalone game-loop behavior.

HTTP tests cover read contracts, cancellation, credential-origin isolation,
revocation, and the independent effects of package/library actions. Local PTY
walkthroughs must use isolated configuration/data and disposable local accounts,
never production credentials or account data.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ require (
github.com/aviorstudio/termcade/sdk v0.0.2
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/ansi v0.11.7
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
Expand Down
46 changes: 42 additions & 4 deletions internal/registry/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,13 +133,16 @@ type Session struct {
Username string `json:"username,omitempty"`
// Notice is a server-side remark about an otherwise usable session. Not
// persisted: it describes the moment the session was created.
Notice string `json:"notice,omitempty"`
Notice string `json:"notice,omitempty"`
CredentialID string `json:"credential_id,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
}

type Client struct {
baseURL string
token string
http *http.Client
ctx context.Context
}

// URL resolves the registry base URL: explicit env wins, then the session's
Expand Down Expand Up @@ -168,7 +171,32 @@ type apiMessage struct {
}

func (c *Client) do(method, path string, body, out any) error {
return c.doContext(context.Background(), method, path, body, out)
return c.doContext(c.requestContext(), method, path, body, out)
}

// WithContext scopes a TUI operation without mutating a client used by another command.
func (c *Client) WithContext(ctx context.Context) *Client {
next := *c
next.ctx = ctx
transport := *c.http
transport.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) > 0 && (req.URL.Scheme != via[0].URL.Scheme || req.URL.Host != via[0].URL.Host) {
return fmt.Errorf("refusing a registry redirect to another origin")
}
if len(via) >= 10 {
return fmt.Errorf("too many registry redirects")
}
return nil
}
next.http = &transport
return &next
}

func (c *Client) requestContext() context.Context {
if c.ctx != nil {
return c.ctx
}
return context.Background()
}

func (c *Client) doContext(ctx context.Context, method, path string, body, out any) error {
Expand Down Expand Up @@ -326,8 +354,15 @@ const maxCatalogPages = 20
// for games this arcade can run: a marketplace full of entries that refuse to
// install is worse than a shorter one.
func (c *Client) Games() ([]Game, error) {
return c.catalog(CatalogQuery{ABI: sdk.ABIVersion})
}

// Catalog matches the web marketplace, including entries this runtime cannot
// play. Availability belongs on each action, not in an invisible list filter.
func (c *Client) Catalog() ([]Game, error) { return c.catalog(CatalogQuery{}) }

func (c *Client) catalog(query CatalogQuery) ([]Game, error) {
var all []Game
query := CatalogQuery{ABI: sdk.ABIVersion}
for range maxCatalogPages {
page, err := c.CatalogPage(query)
if err != nil {
Expand Down Expand Up @@ -383,7 +418,7 @@ func (c *Client) Download(author, slug string) (string, error) {

q := url.Values{}
q.Set("abi", strconv.Itoa(sdk.ABIVersion))
req, err := http.NewRequest(http.MethodGet,
req, err := http.NewRequestWithContext(c.requestContext(), http.MethodGet,
c.baseURL+"/v1/games/"+author+"/"+slug+"/download?"+q.Encode(), nil)
if err != nil {
return "", err
Expand Down Expand Up @@ -626,6 +661,9 @@ func (c *Client) RemoveMember(org, email string) error {
type HandleOwner struct {
Name string `json:"name"`
IsOrg bool `json:"is_org"`
Bio string `json:"bio,omitempty"`
Link string `json:"link,omitempty"`
Games []Game `json:"games,omitempty"`
}

// HandleTaken reports whether a handle is claimed, and by what kind of owner.
Expand Down
32 changes: 31 additions & 1 deletion internal/registry/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,36 @@ func (c *Client) deviceLogin(ctx context.Context, deviceName string, display fun
}
verifyBackoff = nextBackoff(verifyBackoff)
}
return Session{Registry: c.baseURL, Email: me.Email, Username: me.Username, Token: approved.Token}, nil
return Session{Registry: c.baseURL, Email: me.Email, Username: me.Username, Token: approved.Token, CredentialID: approved.CredentialID, ExpiresAt: approved.ExpiresAt}, nil
}
}

// CompleteDevice is a single bounded round for an interactive TUI. Unlike the
// shell command, it returns expiry so the person can explicitly start again.
func (c *Client) CompleteDevice(ctx context.Context, round DeviceRound) (Session, error) {
approved, err := c.pollRound(ctx, round, productionDevicePolicy)
if err != nil {
return Session{}, err
}
client := New(c.baseURL, approved.Token).WithContext(ctx)
deadline, _ := time.Parse(time.RFC3339, approved.ExpiresAt)
backoff := time.Second
var me Me
for {
me, err = client.MeContext(ctx)
if err == nil {
break
}
if !transient(err) {
return Session{}, fmt.Errorf("verifying issued CLI credential: %w", err)
}
if err = waitWithin(ctx, productionDevicePolicy, backoff, deadline); err != nil {
return Session{}, fmt.Errorf("verifying issued CLI credential: %w", err)
}
backoff = nextBackoff(backoff)
}
if strings.TrimSpace(me.Email) == "" {
return Session{}, fmt.Errorf("the marketplace returned an incomplete account identity")
}
return Session{Registry: c.baseURL, Email: me.Email, Username: me.Username, Token: approved.Token, CredentialID: approved.CredentialID, ExpiresAt: approved.ExpiresAt}, nil
}
Loading