@trolleroof/tui 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/.claude-plugin/plugin.json +10 -0
  2. package/.codex-plugin/plugin.json +21 -0
  3. package/README.md +32 -8
  4. package/dist/index.js +286 -19
  5. package/overlay/dist/app.js +74 -0
  6. package/overlay/dist/index.html +1589 -0
  7. package/overlay-release.json +2 -2
  8. package/package.json +28 -4
  9. package/scripts/install-adapters.sh +25 -14
  10. package/scripts/install-adapters.test.sh +12 -2
  11. package/scripts/materialize-agents-plugin.sh +8 -6
  12. package/scripts/npm-pack.test.ts +51 -0
  13. package/scripts/overlay.ts +1 -1
  14. package/src/analytics/events.test.ts +77 -0
  15. package/src/analytics/events.ts +85 -0
  16. package/src/analytics/posthog.test.ts +85 -0
  17. package/src/analytics/posthog.ts +88 -0
  18. package/src/auth-pages.ts +108 -0
  19. package/src/auth.test.ts +255 -0
  20. package/src/auth.ts +511 -0
  21. package/src/core/game.ts +35 -0
  22. package/src/core/key.ts +12 -0
  23. package/src/core/rng.ts +41 -0
  24. package/src/data/bc-export.test.ts +153 -0
  25. package/src/data/bc-export.ts +125 -0
  26. package/src/data/trajectory.ts +103 -0
  27. package/src/games/connect4/connect4.ts +81 -0
  28. package/src/games/connect4/engine.test.ts +77 -0
  29. package/src/games/connect4/engine.ts +89 -0
  30. package/src/games/g2048/engine.test.ts +138 -0
  31. package/src/games/g2048/engine.ts +109 -0
  32. package/src/games/g2048/game2048.ts +104 -0
  33. package/src/games/minesweeper/engine.test.ts +83 -0
  34. package/src/games/minesweeper/engine.ts +112 -0
  35. package/src/games/minesweeper/minesweeper.ts +107 -0
  36. package/src/games/registry.test.ts +16 -0
  37. package/src/games/registry.ts +130 -0
  38. package/src/games/snake/engine.test.ts +89 -0
  39. package/src/games/snake/engine.ts +95 -0
  40. package/src/games/snake/snake.ts +77 -0
  41. package/src/games/sokoban/engine.test.ts +49 -0
  42. package/src/games/sokoban/engine.ts +152 -0
  43. package/src/games/sokoban/sokoban.ts +103 -0
  44. package/src/games/sudoku/engine.test.ts +88 -0
  45. package/src/games/sudoku/engine.ts +99 -0
  46. package/src/games/sudoku/sudoku.ts +100 -0
  47. package/src/games/tetris/engine.test.ts +378 -0
  48. package/src/games/tetris/engine.ts +236 -0
  49. package/src/games/tetris/tetris.ts +220 -0
  50. package/src/index.ts +82 -0
  51. package/src/recording/chunks.ts +93 -0
  52. package/src/recording/config.test.ts +31 -0
  53. package/src/recording/config.ts +37 -0
  54. package/src/recording/contracts.test.ts +71 -0
  55. package/src/recording/gameplay.test.ts +70 -0
  56. package/src/recording/gameplay.ts +18 -0
  57. package/src/recording/http-transport.test.ts +197 -0
  58. package/src/recording/identity.test.ts +26 -0
  59. package/src/recording/identity.ts +38 -0
  60. package/src/recording/ids.ts +27 -0
  61. package/src/recording/permissions.ts +10 -0
  62. package/src/recording/recorder.test.ts +388 -0
  63. package/src/recording/recorder.ts +464 -0
  64. package/src/recording/store.test.ts +231 -0
  65. package/src/recording/store.ts +305 -0
  66. package/src/recording/terminal-grid.test.ts +88 -0
  67. package/src/recording/terminal-grid.ts +248 -0
  68. package/src/recording/trace-recorder.test.ts +287 -0
  69. package/src/recording/trace-recorder.ts +560 -0
  70. package/src/recording/transport.ts +170 -0
  71. package/src/recording/types.test.ts +18 -0
  72. package/src/recording/types.ts +326 -0
  73. package/src/recording/uploader.test.ts +193 -0
  74. package/src/recording/uploader.ts +103 -0
  75. package/src/results/completion.ts +100 -0
  76. package/src/results/http-result-transport.test.ts +75 -0
  77. package/src/results/local-score.test.ts +32 -0
  78. package/src/results/local-score.ts +39 -0
  79. package/src/results/results.test.ts +383 -0
  80. package/src/results/store.ts +149 -0
  81. package/src/results/transport.ts +79 -0
  82. package/src/results/types.ts +20 -0
  83. package/src/results/uploader.ts +84 -0
  84. package/src/sync/config.test.ts +108 -0
  85. package/src/sync/config.ts +115 -0
  86. package/src/sync/coordinator.test.ts +423 -0
  87. package/src/sync/coordinator.ts +277 -0
  88. package/src/sync/game-start-queue.test.ts +93 -0
  89. package/src/sync/game-start-queue.ts +45 -0
  90. package/src/sync/identity-client.test.ts +34 -0
  91. package/src/sync/identity-client.ts +43 -0
@@ -0,0 +1,108 @@
1
+ // The pages the browser lands on at the end of a sign-in handoff, served by the
2
+ // localhost listener in `auth.ts`. This is the last thing a user sees before
3
+ // returning to the terminal, so it confirms the outcome instead of leaving a
4
+ // bare sentence on a white page.
5
+ //
6
+ // The same design is rendered by `cloudflare/auth-worker/src/handoff-page.ts`
7
+ // and `tauri/src/main.rs`; the three copies exist because they run in three
8
+ // different runtimes. Keep the copy and palette in sync when changing one.
9
+
10
+ const STYLE = `
11
+ :root{color-scheme:light}
12
+ *{box-sizing:border-box}
13
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;
14
+ background:radial-gradient(900px 500px at 50% -20%,#fff 0%,#eef2f8 60%,#e3eaf4 100%);color:#0f172a;
15
+ font:15px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;-webkit-font-smoothing:antialiased}
16
+ .card{position:relative;overflow:hidden;width:min(100%,420px);padding:34px 28px 30px;text-align:center;
17
+ background:#fff;border:1px solid #e2e8f0;border-radius:16px;
18
+ box-shadow:0 20px 50px rgba(15,23,42,.12),0 8px 20px rgba(15,23,42,.06)}
19
+ .card::before{content:"";position:absolute;top:0;left:0;right:0;height:4px;
20
+ background:linear-gradient(90deg,#2955c9 0%,#3772ff 38%,#3bb273 72%,#e6a830 100%)}
21
+ .badge{display:flex;align-items:center;justify-content:center;width:44px;height:44px;margin:0 auto 16px;border-radius:999px}
22
+ .badge svg{width:22px;height:22px}
23
+ [data-tone=ok] .badge{background:rgba(59,178,115,.14);color:#2e8f5c}
24
+ [data-tone=warn] .badge{background:rgba(223,41,53,.12);color:#b81f2a}
25
+ .eyebrow{margin:0 0 6px;font-size:11px;font-weight:600;letter-spacing:.14em;text-transform:uppercase;color:#2955c9}
26
+ h1{margin:0 0 10px;font-size:23px;line-height:1.25;letter-spacing:-.01em}
27
+ .lead{margin:0;color:#475569}
28
+ .steps{margin:18px 0 0;padding:0;list-style:none;text-align:left;border-top:1px solid #e2e8f0}
29
+ .steps li{position:relative;margin:0;padding:10px 0 0 18px;font-size:13.5px;color:#475569}
30
+ .steps li:first-child{padding-top:14px}
31
+ .steps li::before{content:"";position:absolute;left:2px;top:18px;width:5px;height:5px;border-radius:999px;background:#cbd5e1}
32
+ .steps li:first-child::before{top:22px}
33
+ .hint{margin:16px 0 0;font-size:12.5px;color:#64748b}
34
+ code{font:600 12.5px/1.4 ui-monospace,"SF Mono",Menlo,monospace;background:#f3f6fb;border:1px solid #e2e8f0;border-radius:5px;padding:2px 5px;overflow-wrap:anywhere;-webkit-box-decoration-break:clone;box-decoration-break:clone}
35
+ @media (max-width:400px){.card{padding:28px 20px 24px}h1{font-size:21px}}
36
+ `.trim();
37
+
38
+ const CHECK_ICON =
39
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>';
40
+ const ALERT_ICON =
41
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5.5"/><path d="M12 16.5h.01"/></svg>';
42
+
43
+ export function escapeHtml(value: string): string {
44
+ return value.replace(
45
+ /[&<>"']/g,
46
+ (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]!,
47
+ );
48
+ }
49
+
50
+ // `lead` and `steps` are interpolated as trusted HTML so copy can carry <code>
51
+ // spans; the title is escaped. Escape anything request-derived at the call site.
52
+ interface PageOptions {
53
+ tone: "ok" | "warn";
54
+ title: string;
55
+ lead: string;
56
+ steps?: string[];
57
+ hint?: string;
58
+ }
59
+
60
+ /** Renders one self-contained Arcade page — no external CSS, fonts, or images. */
61
+ function arcadePage({ tone, title, lead, steps, hint }: PageOptions): string {
62
+ const list = steps?.length
63
+ ? `<ul class="steps">${steps.map((step) => `<li>${step}</li>`).join("")}</ul>`
64
+ : "";
65
+ return `<!doctype html>
66
+ <html lang="en">
67
+ <head>
68
+ <meta charset="utf-8">
69
+ <meta name="viewport" content="width=device-width, initial-scale=1">
70
+ <meta name="robots" content="noindex">
71
+ <title>Retro Arcade — ${escapeHtml(title)}</title>
72
+ <style>${STYLE}</style>
73
+ </head>
74
+ <body>
75
+ <main class="card" data-tone="${tone}">
76
+ <span class="badge">${tone === "ok" ? CHECK_ICON : ALERT_ICON}</span>
77
+ <p class="eyebrow">Retro Arcade</p>
78
+ <h1>${escapeHtml(title)}</h1>
79
+ <p class="lead">${lead}</p>
80
+ ${list}
81
+ ${hint ? `<p class="hint">${hint}</p>` : ""}
82
+ </main>
83
+ </body>
84
+ </html>`;
85
+ }
86
+
87
+ export function signedInPage(): string {
88
+ return arcadePage({
89
+ tone: "ok",
90
+ title: "You're signed in",
91
+ lead: "Retro Arcade has your session. You can close this tab and return to your terminal.",
92
+ hint: "Your session is saved in <code>~/.tui-gamepigeon/auth.json</code>, so you stay signed in next time.",
93
+ });
94
+ }
95
+
96
+ /** `reason` is shown verbatim — pass short, already-escaped copy. */
97
+ export function signInFailedPage(reason: string): string {
98
+ return arcadePage({
99
+ tone: "warn",
100
+ title: "Sign-in didn't finish",
101
+ lead: reason,
102
+ steps: [
103
+ "Return to your terminal and run <code>gamepigeon auth login</code> again.",
104
+ "If the browser blocked the redirect back, allow pop-ups for the Arcade login page.",
105
+ "Signing in with Google or GitHub? Approve the consent screen rather than closing it.",
106
+ ],
107
+ });
108
+ }
@@ -0,0 +1,255 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { runAuthCommand, startOAuthCallbackServer } from "./auth.js";
6
+ import { HttpTransport } from "./recording/transport.js";
7
+ import type { PersistedChunk } from "./recording/types.js";
8
+ import { HttpResultTransport } from "./results/transport.js";
9
+ import type { PersistedGameResult } from "./results/types.js";
10
+ import { loadBackendConfig } from "./sync/config.js";
11
+ import { IdentityClient } from "./sync/identity-client.js";
12
+
13
+ const argv = process.argv;
14
+ const home = process.env.HOME;
15
+ const authUrl = process.env.GAMEPIGEON_AUTH_URL;
16
+ const backendUrl = process.env.GAMEPIGEON_BACKEND_URL;
17
+ const fetch = globalThis.fetch;
18
+ const prompt = globalThis.prompt;
19
+
20
+ afterEach(() => {
21
+ process.argv = argv;
22
+ process.env.HOME = home;
23
+ if (authUrl === undefined) delete process.env.GAMEPIGEON_AUTH_URL;
24
+ else process.env.GAMEPIGEON_AUTH_URL = authUrl;
25
+ if (backendUrl === undefined) delete process.env.GAMEPIGEON_BACKEND_URL;
26
+ else process.env.GAMEPIGEON_BACKEND_URL = backendUrl;
27
+ globalThis.fetch = fetch;
28
+ globalThis.prompt = prompt;
29
+ });
30
+
31
+ test("OAuth only accepts Google or GitHub", async () => {
32
+ process.argv = ["bun", "gamepigeon", "auth", "oauth", "microsoft"];
33
+ await expect(runAuthCommand("oauth")).rejects.toThrow("usage: gamepigeon auth oauth <google|github>");
34
+ });
35
+
36
+ test("successful password login returns only pseudonymous analytics context", async () => {
37
+ const testHome = mkdtempSync(join(tmpdir(), "gamepigeon-auth-"));
38
+ process.env.HOME = testHome;
39
+ process.env.GAMEPIGEON_AUTH_URL = "https://auth.example";
40
+ const answers = ["person@example.com", "secret"];
41
+ globalThis.prompt = () => answers.shift() ?? "";
42
+ globalThis.fetch = Object.assign(
43
+ async () => new Response(JSON.stringify({
44
+ user: { id: "usr_test", email: "person@example.com" },
45
+ }), {
46
+ status: 200,
47
+ headers: { "set-auth-token": "session-secret" },
48
+ }),
49
+ { preconnect: fetch.preconnect },
50
+ );
51
+
52
+ try {
53
+ await expect(runAuthCommand("login")).resolves.toEqual({
54
+ kind: "signed_in",
55
+ method: "password",
56
+ userId: "usr_test",
57
+ });
58
+ expect(readFileSync(join(testHome, ".tui-gamepigeon", "backend.conf"), "utf8"))
59
+ .toBe("https://api.runretroarcade.com\nsession-secret\n");
60
+
61
+ await runAuthCommand("logout");
62
+ expect(existsSync(join(testHome, ".tui-gamepigeon", "auth.json"))).toBe(false);
63
+ expect(existsSync(join(testHome, ".tui-gamepigeon", "backend.conf"))).toBe(false);
64
+ } finally {
65
+ rmSync(testHome, { recursive: true, force: true });
66
+ }
67
+ });
68
+
69
+ test("a login-issued bearer authenticates identity, trace, and result requests", async () => {
70
+ const testHome = mkdtempSync(join(tmpdir(), "gamepigeon-auth-sync-"));
71
+ process.env.HOME = testHome;
72
+ process.env.GAMEPIGEON_AUTH_URL = "https://auth.example";
73
+ process.env.GAMEPIGEON_BACKEND_URL = "https://backend.example";
74
+ const answers = ["person@example.com", "secret"];
75
+ const requests: Array<{ url: string; authorization: string }> = [];
76
+ globalThis.prompt = () => answers.shift() ?? "";
77
+ globalThis.fetch = Object.assign(
78
+ async (input: string | URL | Request, init?: RequestInit) => {
79
+ const url = String(input);
80
+ const authorization = new Headers(init?.headers).get("Authorization") ?? "";
81
+ if (url.endsWith("/api/auth/sign-in/email")) {
82
+ return Response.json(
83
+ { user: { id: "usr_test", email: "person@example.com" } },
84
+ { headers: { "set-auth-token": "login-issued-token" } },
85
+ );
86
+ }
87
+ requests.push({ url, authorization });
88
+ if (url.endsWith("/v1/me")) return Response.json({ user_id: "usr_test" });
89
+ if (url.endsWith("/v1/game-event-chunks")) {
90
+ return Response.json({
91
+ accepted: true,
92
+ duplicate: false,
93
+ game_id: "game_test",
94
+ chunk_id: "chunk_test",
95
+ chunk_sequence: 0,
96
+ accepted_event_range: { first: 0, last: 0 },
97
+ queued: true,
98
+ server_received_at_ms: 1,
99
+ }, { status: 202 });
100
+ }
101
+ return Response.json({
102
+ accepted: true,
103
+ personal_best: 1,
104
+ high_score_updated: true,
105
+ });
106
+ },
107
+ { preconnect: fetch.preconnect },
108
+ );
109
+
110
+ try {
111
+ await runAuthCommand("login");
112
+ const config = loadBackendConfig();
113
+ expect(config).not.toBeNull();
114
+ if (!config) throw new Error("Login did not create backend credentials.");
115
+
116
+ await new IdentityClient(config).resolve();
117
+ await new HttpTransport(config.eventChunksUrl, { token: config.token }).send({
118
+ bytes: "{}",
119
+ path: "/pending/chunk.json",
120
+ chunk: {
121
+ game_id: "game_test",
122
+ chunk_id: "chunk_test",
123
+ chunk_sequence: 0,
124
+ first_event_sequence: 0,
125
+ last_event_sequence: 0,
126
+ },
127
+ } as PersistedChunk);
128
+ await new HttpResultTransport(config.gameResultsUrl, config.token).send({
129
+ userId: "usr_test",
130
+ path: "/pending/result.json",
131
+ bytes: "{}",
132
+ request: {
133
+ result_id: "result_test",
134
+ game_id: "game_test",
135
+ game_type: "2048",
136
+ score: 1,
137
+ completed_at: "2026-07-27T00:00:00.000Z",
138
+ },
139
+ } satisfies PersistedGameResult);
140
+
141
+ expect(requests).toEqual([
142
+ { url: "https://backend.example/v1/me", authorization: "Bearer login-issued-token" },
143
+ {
144
+ url: "https://backend.example/v1/game-event-chunks",
145
+ authorization: "Bearer login-issued-token",
146
+ },
147
+ {
148
+ url: "https://backend.example/v1/game-results",
149
+ authorization: "Bearer login-issued-token",
150
+ },
151
+ ]);
152
+ } finally {
153
+ rmSync(testHome, { recursive: true, force: true });
154
+ }
155
+ });
156
+
157
+ test("OAuth callback listener binds before ready resolves and accepts tokens", async () => {
158
+ const callback = startOAuthCallbackServer(0);
159
+ try {
160
+ await callback.ready;
161
+ const base = `http://127.0.0.1:${callback.port}`;
162
+ const ping = await fetch(`${base}/auth/ping`);
163
+ expect(ping.ok).toBe(true);
164
+ expect(await ping.text()).toBe("ok");
165
+
166
+ const tokenPromise = callback.token;
167
+ const response = await fetch(`${base}/auth/callback?token=session-from-handoff`);
168
+ expect(response.ok).toBe(true);
169
+ expect(response.headers.get("cache-control")).toBe("no-store");
170
+ const body = await response.text();
171
+ expect(body).toContain("You&#39;re signed in");
172
+ expect(body).toContain("close this tab");
173
+ await expect(tokenPromise).resolves.toBe("session-from-handoff");
174
+ } finally {
175
+ callback.close();
176
+ }
177
+ });
178
+
179
+ test("OAuth callback listener tolerates a redirect that appends ?token to ?state", async () => {
180
+ const callback = startOAuthCallbackServer(0);
181
+ try {
182
+ await callback.ready;
183
+ const tokenPromise = callback.token;
184
+ // Some hosted redirects glue `?token` onto a callback already carrying
185
+ // `?state`; URLSearchParams alone would fold the token into the state.
186
+ const response = await fetch(
187
+ `http://127.0.0.1:${callback.port}/auth/callback?state=nonce?token=session-from-malformed`,
188
+ );
189
+ expect(response.ok).toBe(true);
190
+ await expect(tokenPromise).resolves.toBe("session-from-malformed");
191
+ } finally {
192
+ callback.close();
193
+ }
194
+ });
195
+
196
+ test("OAuth callback listener reports why a failed handoff stopped", async () => {
197
+ const callback = startOAuthCallbackServer(0);
198
+ try {
199
+ await callback.ready;
200
+ const tokenPromise = callback.token;
201
+ const response = await fetch(
202
+ `http://127.0.0.1:${callback.port}/auth/callback?error=access_denied`,
203
+ );
204
+ expect(response.status).toBe(400);
205
+ const body = await response.text();
206
+ expect(body).toContain("Sign-in was cancelled before it finished.");
207
+ expect(body).toContain("gamepigeon auth login");
208
+ await expect(tokenPromise).rejects.toThrow("Sign-in was cancelled before it finished.");
209
+ } finally {
210
+ callback.close();
211
+ }
212
+ });
213
+
214
+ test("OAuth callback listener escapes a hostile error description", async () => {
215
+ const callback = startOAuthCallbackServer(0);
216
+ try {
217
+ await callback.ready;
218
+ const tokenPromise = callback.token;
219
+ const response = await fetch(
220
+ `http://127.0.0.1:${callback.port}/auth/callback?error=bad&error_description=${
221
+ encodeURIComponent("<script>alert(1)</script>")
222
+ }`,
223
+ );
224
+ const body = await response.text();
225
+ expect(body).not.toContain("<script>alert(1)</script>");
226
+ expect(body).toContain("&lt;script&gt;");
227
+ await expect(tokenPromise).rejects.toThrow("alert(1)");
228
+ } finally {
229
+ callback.close();
230
+ }
231
+ });
232
+
233
+ test("OAuth callback listener falls back to a free port when the preferred one is taken", async () => {
234
+ const busy = startOAuthCallbackServer(0);
235
+ try {
236
+ await busy.ready;
237
+ const busyPort = busy.port;
238
+
239
+ const callback = startOAuthCallbackServer(busyPort);
240
+ try {
241
+ await callback.ready;
242
+ expect(callback.port).not.toBe(busyPort);
243
+
244
+ const base = `http://127.0.0.1:${callback.port}`;
245
+ const tokenPromise = callback.token;
246
+ const response = await fetch(`${base}/auth/callback?token=session-from-fallback`);
247
+ expect(response.ok).toBe(true);
248
+ await expect(tokenPromise).resolves.toBe("session-from-fallback");
249
+ } finally {
250
+ callback.close();
251
+ }
252
+ } finally {
253
+ busy.close();
254
+ }
255
+ });