@trolleroof/tui 0.2.5 → 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.
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +2 -2
- package/README.md +29 -7
- package/dist/index.js +284 -18
- package/overlay/dist/app.js +74 -0
- package/overlay/dist/index.html +1589 -0
- package/overlay-release.json +2 -2
- package/package.json +27 -4
- package/scripts/install-adapters.sh +6 -6
- package/scripts/install-adapters.test.sh +2 -2
- package/scripts/materialize-agents-plugin.sh +3 -3
- package/scripts/npm-pack.test.ts +51 -0
- package/scripts/overlay.ts +1 -1
- package/src/analytics/events.test.ts +77 -0
- package/src/analytics/events.ts +85 -0
- package/src/analytics/posthog.test.ts +85 -0
- package/src/analytics/posthog.ts +88 -0
- package/src/auth-pages.ts +108 -0
- package/src/auth.test.ts +255 -0
- package/src/auth.ts +511 -0
- package/src/core/game.ts +35 -0
- package/src/core/key.ts +12 -0
- package/src/core/rng.ts +41 -0
- package/src/data/bc-export.test.ts +153 -0
- package/src/data/bc-export.ts +125 -0
- package/src/data/trajectory.ts +103 -0
- package/src/games/connect4/connect4.ts +81 -0
- package/src/games/connect4/engine.test.ts +77 -0
- package/src/games/connect4/engine.ts +89 -0
- package/src/games/g2048/engine.test.ts +138 -0
- package/src/games/g2048/engine.ts +109 -0
- package/src/games/g2048/game2048.ts +104 -0
- package/src/games/minesweeper/engine.test.ts +83 -0
- package/src/games/minesweeper/engine.ts +112 -0
- package/src/games/minesweeper/minesweeper.ts +107 -0
- package/src/games/registry.test.ts +16 -0
- package/src/games/registry.ts +130 -0
- package/src/games/snake/engine.test.ts +89 -0
- package/src/games/snake/engine.ts +95 -0
- package/src/games/snake/snake.ts +77 -0
- package/src/games/sokoban/engine.test.ts +49 -0
- package/src/games/sokoban/engine.ts +152 -0
- package/src/games/sokoban/sokoban.ts +103 -0
- package/src/games/sudoku/engine.test.ts +88 -0
- package/src/games/sudoku/engine.ts +99 -0
- package/src/games/sudoku/sudoku.ts +100 -0
- package/src/games/tetris/engine.test.ts +378 -0
- package/src/games/tetris/engine.ts +236 -0
- package/src/games/tetris/tetris.ts +220 -0
- package/src/index.ts +82 -0
- package/src/recording/chunks.ts +93 -0
- package/src/recording/config.test.ts +31 -0
- package/src/recording/config.ts +37 -0
- package/src/recording/contracts.test.ts +71 -0
- package/src/recording/gameplay.test.ts +70 -0
- package/src/recording/gameplay.ts +18 -0
- package/src/recording/http-transport.test.ts +197 -0
- package/src/recording/identity.test.ts +26 -0
- package/src/recording/identity.ts +38 -0
- package/src/recording/ids.ts +27 -0
- package/src/recording/permissions.ts +10 -0
- package/src/recording/recorder.test.ts +388 -0
- package/src/recording/recorder.ts +464 -0
- package/src/recording/store.test.ts +231 -0
- package/src/recording/store.ts +305 -0
- package/src/recording/terminal-grid.test.ts +88 -0
- package/src/recording/terminal-grid.ts +248 -0
- package/src/recording/trace-recorder.test.ts +287 -0
- package/src/recording/trace-recorder.ts +560 -0
- package/src/recording/transport.ts +170 -0
- package/src/recording/types.test.ts +18 -0
- package/src/recording/types.ts +326 -0
- package/src/recording/uploader.test.ts +193 -0
- package/src/recording/uploader.ts +103 -0
- package/src/results/completion.ts +100 -0
- package/src/results/http-result-transport.test.ts +75 -0
- package/src/results/local-score.test.ts +32 -0
- package/src/results/local-score.ts +39 -0
- package/src/results/results.test.ts +383 -0
- package/src/results/store.ts +149 -0
- package/src/results/transport.ts +79 -0
- package/src/results/types.ts +20 -0
- package/src/results/uploader.ts +84 -0
- package/src/sync/config.test.ts +108 -0
- package/src/sync/config.ts +115 -0
- package/src/sync/coordinator.test.ts +423 -0
- package/src/sync/coordinator.ts +277 -0
- package/src/sync/game-start-queue.test.ts +93 -0
- package/src/sync/game-start-queue.ts +45 -0
- package/src/sync/identity-client.test.ts +34 -0
- package/src/sync/identity-client.ts +43 -0
package/src/auth.test.ts
ADDED
|
@@ -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'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("<script>");
|
|
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
|
+
});
|