typefaster-cli 0.1.0__tar.gz → 0.1.1__tar.gz

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 (137) hide show
  1. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/.env.example +7 -0
  2. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/.gitignore +1 -0
  3. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/PKG-INFO +1 -13
  4. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/README.md +0 -12
  5. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/__init__.py +1 -1
  6. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/cli.py +1 -0
  7. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/net/api.py +9 -0
  8. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/net/commands.py +95 -2
  9. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/ui/screens/online_race.py +103 -27
  10. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/docker-compose.yml +4 -0
  11. typefaster_cli-0.1.1/docs/online-setup.md +106 -0
  12. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/infra/redis/redis.conf +5 -2
  13. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/pyproject.toml +1 -1
  14. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/app/config.py +5 -0
  15. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/app/main.py +2 -1
  16. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/app/redis_keys.py +5 -0
  17. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/app/repositories.py +36 -0
  18. typefaster_cli-0.1.1/server/app/routers/oauth.py +139 -0
  19. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/pyproject.toml +1 -0
  20. typefaster_cli-0.1.1/server/tests/test_oauth.py +38 -0
  21. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/.dockerignore +0 -0
  22. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  23. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  24. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  25. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  26. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/.github/workflows/ci.yml +0 -0
  27. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/.github/workflows/release.yml +0 -0
  28. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/CONTRIBUTING.md +0 -0
  29. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/LICENSE +0 -0
  30. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/Makefile +0 -0
  31. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/Dockerfile +0 -0
  32. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/__main__.py +0 -0
  33. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/assets/__init__.py +0 -0
  34. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/assets/quotes.json +0 -0
  35. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/domain/__init__.py +0 -0
  36. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/domain/anti_cheat.py +0 -0
  37. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/domain/calculators.py +0 -0
  38. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/domain/errors.py +0 -0
  39. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/domain/ghost.py +0 -0
  40. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/domain/models.py +0 -0
  41. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/domain/typing_engine.py +0 -0
  42. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/infra/__init__.py +0 -0
  43. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/infra/clock.py +0 -0
  44. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/infra/config.py +0 -0
  45. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/infra/db.py +0 -0
  46. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/infra/migrations.py +0 -0
  47. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/infra/paths.py +0 -0
  48. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/infra/quote_loader.py +0 -0
  49. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/infra/replay_store.py +0 -0
  50. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/infra/repository.py +0 -0
  51. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/infra/sqlite_repository.py +0 -0
  52. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/net/__init__.py +0 -0
  53. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/net/token_store.py +0 -0
  54. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/services/__init__.py +0 -0
  55. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/services/container.py +0 -0
  56. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/services/daily_service.py +0 -0
  57. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/services/ghost_service.py +0 -0
  58. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/services/profile_service.py +0 -0
  59. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/services/race_service.py +0 -0
  60. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/services/stats_service.py +0 -0
  61. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/ui/__init__.py +0 -0
  62. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/ui/app.py +0 -0
  63. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/ui/online_app.py +0 -0
  64. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/ui/screens/__init__.py +0 -0
  65. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/ui/screens/_base.py +0 -0
  66. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/ui/screens/daily.py +0 -0
  67. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/ui/screens/help.py +0 -0
  68. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/ui/screens/history.py +0 -0
  69. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/ui/screens/leaderboard.py +0 -0
  70. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/ui/screens/main_menu.py +0 -0
  71. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/ui/screens/practice.py +0 -0
  72. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/ui/screens/profile.py +0 -0
  73. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/ui/screens/race.py +0 -0
  74. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/ui/screens/results.py +0 -0
  75. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/ui/screens/settings.py +0 -0
  76. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/ui/screens/stats.py +0 -0
  77. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/ui/theme.py +0 -0
  78. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/ui/widgets/__init__.py +0 -0
  79. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/ui/widgets/bigtext.py +0 -0
  80. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/ui/widgets/live_stats.py +0 -0
  81. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/ui/widgets/progress_bars.py +0 -0
  82. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/client/typefaster/ui/widgets/typing_field.py +0 -0
  83. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/docs/RELEASING.md +0 -0
  84. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/docs/api-spec.md +0 -0
  85. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/docs/architecture.md +0 -0
  86. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/docs/deployment.md +0 -0
  87. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/docs/redis-schema.md +0 -0
  88. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/docs/roadmap.md +0 -0
  89. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/docs/sqlite-schema.md +0 -0
  90. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/docs/ui-design.md +0 -0
  91. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/docs/websocket-protocol.md +0 -0
  92. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/infra/README.md +0 -0
  93. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/infra/nginx/nginx.conf +0 -0
  94. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/packaging/homebrew/typefaster.rb +0 -0
  95. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/scripts/seed_quotes.py +0 -0
  96. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/Dockerfile +0 -0
  97. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/app/__init__.py +0 -0
  98. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/app/deps.py +0 -0
  99. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/app/logging_config.py +0 -0
  100. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/app/quotes.py +0 -0
  101. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/app/routers/__init__.py +0 -0
  102. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/app/routers/auth.py +0 -0
  103. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/app/routers/health.py +0 -0
  104. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/app/routers/leaderboards.py +0 -0
  105. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/app/routers/lobbies.py +0 -0
  106. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/app/security.py +0 -0
  107. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/app/ws/__init__.py +0 -0
  108. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/app/ws/manager.py +0 -0
  109. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/tests/__init__.py +0 -0
  110. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/tests/conftest.py +0 -0
  111. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/tests/test_auth.py +0 -0
  112. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/tests/test_health_leaderboards.py +0 -0
  113. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/tests/test_lobbies.py +0 -0
  114. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/tests/test_scoring_anticheat.py +0 -0
  115. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/server/tests/test_ws_race.py +0 -0
  116. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/shared/pyproject.toml +0 -0
  117. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/shared/typefaster_shared/__init__.py +0 -0
  118. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/shared/typefaster_shared/anti_cheat.py +0 -0
  119. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/shared/typefaster_shared/dto.py +0 -0
  120. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/shared/typefaster_shared/events.py +0 -0
  121. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/shared/typefaster_shared/scoring.py +0 -0
  122. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/tests/conftest.py +0 -0
  123. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/tests/fixtures/.gitkeep +0 -0
  124. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/tests/integration/test_container_and_cli.py +0 -0
  125. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/tests/integration/test_migrations.py +0 -0
  126. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/tests/integration/test_profile_stats.py +0 -0
  127. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/tests/integration/test_race_service.py +0 -0
  128. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/tests/integration/test_race_timing.py +0 -0
  129. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/tests/integration/test_sqlite_repository.py +0 -0
  130. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/tests/integration/test_ui_smoke.py +0 -0
  131. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/tests/unit/test_anti_cheat.py +0 -0
  132. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/tests/unit/test_calculators.py +0 -0
  133. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/tests/unit/test_config.py +0 -0
  134. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/tests/unit/test_ghost.py +0 -0
  135. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/tests/unit/test_net.py +0 -0
  136. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/tests/unit/test_quote_loader.py +0 -0
  137. {typefaster_cli-0.1.0 → typefaster_cli-0.1.1}/tests/unit/test_typing_engine.py +0 -0
@@ -11,3 +11,10 @@ TYPEFASTER_ACCESS_TOKEN_MINUTES=1440
11
11
 
12
12
  # Host port to expose the server on for local dev (nginx handles prod).
13
13
  SERVER_PORT=8000
14
+
15
+ # ── OAuth device-flow login (optional, all free) ────────────────────────
16
+ # Leave blank to disable a provider. See docs/online-setup.md for how to
17
+ # create the GitHub OAuth App and Google OAuth client.
18
+ TYPEFASTER_GITHUB_CLIENT_ID=
19
+ TYPEFASTER_GOOGLE_CLIENT_ID=
20
+ TYPEFASTER_GOOGLE_CLIENT_SECRET=
@@ -29,3 +29,4 @@ typefaster.db*
29
29
  .idea/
30
30
  .vscode/
31
31
  .DS_Store
32
+ .env
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: typefaster-cli
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: A terminal-first typing game inspired by MonkeyType and TypeRacer.
5
5
  Project-URL: Homepage, https://github.com/Anoshor/typefaster-cli
6
6
  Project-URL: Repository, https://github.com/Anoshor/typefaster-cli
@@ -49,18 +49,6 @@ typefaster
49
49
 
50
50
  ---
51
51
 
52
- ## Status
53
-
54
- | Phase | Scope | State |
55
- |-------|-------|-------|
56
- | **Phase 1** | Offline experience: races, ghosts, profile, stats, history, daily challenge | ✅ **Implemented & tested** |
57
- | **Phase 2** | Online multiplayer: FastAPI + Redis + WebSockets, auth, lobbies, leaderboards, anti-cheat, Docker | ✅ **Implemented & tested** |
58
-
59
- Both phases are implemented. Offline play needs only `pip install`; online play
60
- adds a Dockerized server (see [Online play](#online-play-phase-2)).
61
-
62
- ---
63
-
64
52
  ## What Phase 1 delivers
65
53
 
66
54
  - **Instant offline races** — random quote, live WPM / accuracy / progress / timer.
@@ -17,18 +17,6 @@ typefaster
17
17
 
18
18
  ---
19
19
 
20
- ## Status
21
-
22
- | Phase | Scope | State |
23
- |-------|-------|-------|
24
- | **Phase 1** | Offline experience: races, ghosts, profile, stats, history, daily challenge | ✅ **Implemented & tested** |
25
- | **Phase 2** | Online multiplayer: FastAPI + Redis + WebSockets, auth, lobbies, leaderboards, anti-cheat, Docker | ✅ **Implemented & tested** |
26
-
27
- Both phases are implemented. Offline play needs only `pip install`; online play
28
- adds a Dockerized server (see [Online play](#online-play-phase-2)).
29
-
30
- ---
31
-
32
20
  ## What Phase 1 delivers
33
21
 
34
22
  - **Instant offline races** — random quote, live WPM / accuracy / progress / timer.
@@ -1,3 +1,3 @@
1
1
  """TYPEFASTER-CLI — a terminal-first typing game."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.1"
@@ -190,6 +190,7 @@ app.command("login")(_online.login)
190
190
  app.command("logout")(_online.logout)
191
191
  app.command("leaderboard")(_online.leaderboard)
192
192
  app.add_typer(_online.lobby_app, name="lobby")
193
+ app.add_typer(_online.config_app, name="config")
193
194
 
194
195
 
195
196
  if __name__ == "__main__":
@@ -64,6 +64,15 @@ class ApiClient:
64
64
  def logout(self) -> None:
65
65
  self._request("POST", "/auth/logout", headers=self._auth_headers())
66
66
 
67
+ # ── oauth device flow ──────────────────────────────────────────────
68
+ def oauth_start(self, provider: str) -> Any:
69
+ return self._request("POST", f"/auth/oauth/{provider}/start")
70
+
71
+ def oauth_poll(self, provider: str, device_code: str) -> Any:
72
+ return self._request(
73
+ "POST", f"/auth/oauth/{provider}/poll", json={"device_code": device_code}
74
+ )
75
+
67
76
  def me(self) -> Any:
68
77
  return self._request("GET", "/auth/me", headers=self._auth_headers())
69
78
 
@@ -6,7 +6,10 @@ The online race UI is launched via the Textual app.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import contextlib
9
10
  import getpass
11
+ import time
12
+ import webbrowser
10
13
 
11
14
  import typer
12
15
  from rich.console import Console
@@ -46,8 +49,18 @@ def register(
46
49
  client.close()
47
50
 
48
51
 
49
- def login(username: str = typer.Argument(..., help="Your username.")) -> None:
50
- """Log in to the server."""
52
+ def login(
53
+ username: str | None = typer.Argument(None, help="Your username (password login)."),
54
+ github: bool = typer.Option(False, "--github", help="Log in with GitHub (browser)."),
55
+ google: bool = typer.Option(False, "--google", help="Log in with Google (browser)."),
56
+ ) -> None:
57
+ """Log in to the server (password, or --github / --google)."""
58
+ if github or google:
59
+ _oauth_login("github" if github else "google")
60
+ return
61
+ if not username:
62
+ console.print("[red]Provide a username, or use[/] --github / --google.")
63
+ raise typer.Exit(1)
51
64
  password = getpass.getpass("Password: ")
52
65
  client, session = _client()
53
66
  try:
@@ -63,6 +76,54 @@ def login(username: str = typer.Argument(..., help="Your username.")) -> None:
63
76
  client.close()
64
77
 
65
78
 
79
+ def _oauth_login(provider: str) -> None:
80
+ """Run the OAuth device flow: show a code, open the browser, poll for token."""
81
+ client, session = _client()
82
+ try:
83
+ try:
84
+ start = client.oauth_start(provider)
85
+ except ApiError as exc:
86
+ console.print(f"[red]{provider.title()} login unavailable:[/] {exc.detail}")
87
+ raise typer.Exit(1) from exc
88
+
89
+ uri = start["verification_uri"]
90
+ code = start["user_code"]
91
+ device = start["device_code"]
92
+ interval = int(start.get("interval", 5))
93
+ deadline = time.time() + int(start.get("expires_in", 900))
94
+
95
+ console.print(
96
+ f"\n Open [bold underline]{uri}[/]\n"
97
+ f" Enter code: [bold cyan]{code}[/]\n"
98
+ )
99
+ with contextlib.suppress(Exception):
100
+ webbrowser.open(uri)
101
+ console.print("[grey58]Waiting for authorization…[/] (Ctrl-C to cancel)")
102
+
103
+ while time.time() < deadline:
104
+ time.sleep(interval)
105
+ try:
106
+ r = client.oauth_poll(provider, device)
107
+ except ApiError as exc:
108
+ console.print(f"[red]Login failed:[/] {exc.detail}")
109
+ raise typer.Exit(1) from exc
110
+ status_ = r.get("status")
111
+ if status_ == "pending":
112
+ continue
113
+ if status_ == "slow_down":
114
+ interval += 5
115
+ continue
116
+ session.token = r["access_token"]
117
+ session.username = r["username"]
118
+ session.save()
119
+ console.print(f"[green]Logged in as[/] [bold]{session.username}[/]")
120
+ return
121
+ console.print("[red]Login timed out — please try again.[/]")
122
+ raise typer.Exit(1)
123
+ finally:
124
+ client.close()
125
+
126
+
66
127
  def logout() -> None:
67
128
  """Log out and clear the local token."""
68
129
  client, session = _client()
@@ -105,6 +166,38 @@ def leaderboard(
105
166
  client.close()
106
167
 
107
168
 
169
+ # ── config subcommands ────────────────────────────────────────────────
170
+ config_app = typer.Typer(help="Client configuration (server URL, etc).", no_args_is_help=True)
171
+
172
+
173
+ @config_app.command("set-server")
174
+ def config_set_server(
175
+ url: str = typer.Argument(..., help="Server base URL, e.g. https://abc.trycloudflare.com"),
176
+ ) -> None:
177
+ """Point this client at a server (local, tunnel, or deployed)."""
178
+ session = Session.load()
179
+ new_url = url.rstrip("/")
180
+ if new_url != session.server_url:
181
+ # Tokens are server-specific — clear on change to avoid stale auth.
182
+ session.token = None
183
+ session.username = None
184
+ session.server_url = new_url
185
+ session.save()
186
+ console.print(f"[green]Server set to[/] [bold]{session.server_url}[/]. Now log in or register.")
187
+
188
+
189
+ @config_app.command("show")
190
+ def config_show() -> None:
191
+ """Show the current client configuration."""
192
+ s = Session.load()
193
+ table = Table(show_header=False)
194
+ table.add_column(style="grey58", justify="right")
195
+ table.add_column()
196
+ table.add_row("Server URL", s.server_url)
197
+ table.add_row("Logged in as", s.username or "[grey58]not logged in[/]")
198
+ console.print(table)
199
+
200
+
108
201
  # ── lobby subcommands ─────────────────────────────────────────────────
109
202
  lobby_app = typer.Typer(help="Multiplayer lobbies.", no_args_is_help=True)
110
203
 
@@ -1,8 +1,8 @@
1
- """Online multiplayer race screen.
1
+ """Online multiplayer lobby + race screen.
2
2
 
3
- Connects to the server over WebSocket, auto-readies, and lets the **server**
4
- drive countdown/start/finish. The client only renders state, reports progress,
5
- and submits a final result for server-side validation.
3
+ Flow: connect → wait in the lobby (press R to ready) the **server** starts the
4
+ race when everyone is ready, sends the same quote to all, relays progress, and
5
+ re-scores results server-side. The client only renders state and reports input.
6
6
  """
7
7
 
8
8
  from __future__ import annotations
@@ -23,13 +23,14 @@ from textual.screen import Screen
23
23
  from textual.widgets import Static
24
24
 
25
25
  from ...domain.typing_engine import TypingEngine
26
+ from ..widgets import bigtext
26
27
  from ..widgets.live_stats import LiveStats
27
28
  from ..widgets.typing_field import TypingField
28
29
 
29
30
 
30
- def _bar(pct: float, width: int = 24) -> str:
31
+ def _bar(pct: float, width: int = 28) -> str:
31
32
  filled = max(0, min(width, round(pct / 100.0 * width)))
32
- return "#" * filled + "-" * (width - filled)
33
+ return "" * filled + "" * (width - filled)
33
34
 
34
35
 
35
36
  class OnlineRaceScreen(Screen[None]):
@@ -40,23 +41,28 @@ class OnlineRaceScreen(Screen[None]):
40
41
  self.ws_url = ws_url
41
42
  self.username = username
42
43
  self.mode_seconds = mode_seconds
44
+ # Extract the lobby code from .../ws/lobby/<code>?token=...
45
+ self.code = ws_url.split("/ws/lobby/", 1)[-1].split("?", 1)[0]
43
46
  self.engine: TypingEngine | None = None
44
47
  self._ws: Any = None
45
48
  self._start_ms: int | None = None
49
+ self._phase = "lobby" # lobby | racing | results
46
50
  self._typing = False
47
51
  self._finished = False
52
+ self._ready = False
53
+ self._roster: list[dict[str, Any]] = []
48
54
  self._opponents: dict[str, dict[str, float]] = {}
49
55
  self._status = "Connecting…"
50
56
  self._standings: list[dict[str, Any]] | None = None
51
57
 
52
58
  def compose(self) -> ComposeResult:
53
59
  with Vertical(id="race-wrap"):
54
- yield Static("", id="net-status", classes="dim")
60
+ yield Static("", id="net-status")
55
61
  yield LiveStats()
56
62
  yield TypingField()
57
63
  with VerticalScroll():
58
64
  yield Static("", id="bars")
59
- yield Static("esc to leave", classes="dim")
65
+ yield Static("R ready · esc leave", classes="dim")
60
66
 
61
67
  def on_mount(self) -> None:
62
68
  self.query_one(LiveStats).display = False
@@ -70,13 +76,12 @@ class OnlineRaceScreen(Screen[None]):
70
76
  try:
71
77
  async with websockets.connect(self.ws_url) as ws:
72
78
  self._ws = ws
73
- self._status = "Connected. Waiting for players to ready up…"
79
+ self._status = "In lobby press [bold]R[/] when ready."
74
80
  self._render_status()
75
- await ws.send(json.dumps({"type": "SET_READY", "data": {"ready": True}}))
76
81
  async for raw in ws:
77
82
  self._handle(json.loads(raw))
78
83
  except Exception as exc:
79
- self._status = f"Disconnected: {exc}"
84
+ self._status = f"[red]Disconnected:[/] {exc}"
80
85
  self._render_status()
81
86
 
82
87
  async def _send(self, type_: str, **data: Any) -> None:
@@ -87,10 +92,17 @@ class OnlineRaceScreen(Screen[None]):
87
92
  def _handle(self, msg: dict[str, Any]) -> None:
88
93
  etype = msg.get("type")
89
94
  data = msg.get("data", {})
90
- if etype == "RACE_COUNTDOWN":
95
+ if etype == "LOBBY_UPDATE":
96
+ self._roster = data.get("players", [])
97
+ # Only repaint the lobby in the lobby phase — never clobber the
98
+ # results screen with the post-race "reset to waiting" update.
99
+ if self._phase == "lobby":
100
+ self._render_lobby(data)
101
+ elif etype == "RACE_COUNTDOWN":
91
102
  self._status = f"Race starts in {data.get('count')}…"
92
103
  self._render_status()
93
104
  elif etype == "RACE_START":
105
+ self._phase = "racing"
94
106
  self._begin(data["text"])
95
107
  elif etype == "RACE_PROGRESS":
96
108
  user = data.get("username")
@@ -99,9 +111,11 @@ class OnlineRaceScreen(Screen[None]):
99
111
  "progress": float(data.get("progress", 0)),
100
112
  "wpm": float(data.get("wpm", 0)),
101
113
  }
102
- self._render_bars()
114
+ if self._typing:
115
+ self._render_bars()
103
116
  elif etype == "RACE_FINISHED":
104
117
  if data.get("final"):
118
+ self._phase = "results"
105
119
  self._standings = data.get("standings", [])
106
120
  self._show_standings()
107
121
  else:
@@ -109,16 +123,18 @@ class OnlineRaceScreen(Screen[None]):
109
123
  if user and user != self.username:
110
124
  self._opponents.setdefault(user, {})["progress"] = 100.0
111
125
  self._render_bars()
112
- elif etype == "CHAT_MESSAGE":
113
- pass # chat panel could render here
126
+ elif etype == "ERROR":
127
+ self._status = f"[red]{data.get('message', 'error')}[/]"
128
+ self._render_status()
114
129
 
115
130
  # ── race lifecycle ─────────────────────────────────────────────────
116
131
  def _begin(self, text: str) -> None:
117
132
  self.engine = TypingEngine(text)
118
133
  self._start_ms = int(time.time() * 1000)
119
134
  self._typing = True
120
- self._status = "GO!"
121
- self.query_one("#net-status", Static).display = False
135
+ self.query_one("#net-status", Static).update(
136
+ Text(bigtext.render("GO!"), justify="center", style="bold green")
137
+ )
122
138
  self.query_one(LiveStats).display = True
123
139
  self.query_one(TypingField).display = True
124
140
  self._render_field()
@@ -138,7 +154,6 @@ class OnlineRaceScreen(Screen[None]):
138
154
  seconds_left=seconds_left,
139
155
  )
140
156
  self._render_bars()
141
- # Report progress to the server.
142
157
  self.run_worker(
143
158
  self._send(
144
159
  "PROGRESS",
@@ -147,11 +162,25 @@ class OnlineRaceScreen(Screen[None]):
147
162
  ),
148
163
  name="progress",
149
164
  )
150
- if (elapsed >= self.mode_seconds * 1000 or self.engine.finished) and not self._finished:
165
+ if self._start_ms is not None and elapsed >= self.mode_seconds * 1000 and not self._finished:
151
166
  self._submit_finish()
152
167
 
153
168
  def on_key(self, event: events.Key) -> None:
154
- if not self._typing or self._finished or self.engine is None:
169
+ # Lobby phase: R toggles ready.
170
+ if self._phase == "lobby":
171
+ if event.key.lower() == "r":
172
+ self._ready = not self._ready
173
+ self.run_worker(self._send("SET_READY", ready=self._ready), name="ready")
174
+ event.stop()
175
+ return
176
+ # Results phase: R re-arms for another round.
177
+ if self._phase == "results":
178
+ if event.key.lower() == "r":
179
+ self._play_again()
180
+ event.stop()
181
+ return
182
+ # Race phase: type.
183
+ if self._finished or self.engine is None:
155
184
  return
156
185
  t = self._elapsed()
157
186
  if event.key == "backspace":
@@ -190,7 +219,34 @@ class OnlineRaceScreen(Screen[None]):
190
219
 
191
220
  # ── rendering ──────────────────────────────────────────────────────
192
221
  def _render_status(self) -> None:
193
- self.query_one("#net-status", Static).update(Text(self._status, justify="center"))
222
+ self.query_one("#net-status", Static).update(Text.from_markup(self._status, justify="center"))
223
+
224
+ def _render_lobby(self, data: dict[str, Any]) -> None:
225
+ name = data.get("name", "Lobby")
226
+ host = data.get("host", "")
227
+ table = Table(title=f"{name} ({self.mode_seconds}s)", title_style="bold", expand=True)
228
+ table.add_column("Player")
229
+ table.add_column("Ready", justify="center")
230
+ for p in self._roster:
231
+ who = p["username"] + (" 👑" if p["username"] == host else "")
232
+ who += " (you)" if p["username"] == self.username else ""
233
+ ready = "[green]✓[/]" if p.get("ready") else "[grey58]…[/]"
234
+ table.add_row(who, ready)
235
+ ready_n = sum(1 for p in self._roster if p.get("ready"))
236
+ hint = Text.from_markup(
237
+ f"\n{ready_n}/{len(self._roster)} ready · "
238
+ f"press [bold]R[/] to {'unready' if self._ready else 'ready'} · "
239
+ "race starts when everyone is ready",
240
+ justify="center",
241
+ )
242
+ self.query_one("#bars", Static).update(Group(table, hint))
243
+ self.query_one("#net-status", Static).update(
244
+ Text.from_markup(
245
+ f"WAITING ROOM · join code: [bold cyan]{self.code}[/]\n"
246
+ f"[grey58]share:[/] typefaster lobby join {self.code}",
247
+ justify="center",
248
+ )
249
+ )
194
250
 
195
251
  def _render_field(self) -> None:
196
252
  if self.engine is not None:
@@ -201,17 +257,17 @@ class OnlineRaceScreen(Screen[None]):
201
257
  def _render_bars(self) -> None:
202
258
  text = Text()
203
259
  me_pct = self.engine.progress * 100.0 if self.engine else 0.0
204
- text.append("You ", style="bold")
205
- text.append(f"[{_bar(me_pct)}] {me_pct:3.0f}%\n", style="cyan")
260
+ text.append(f"{self.username[:9]:<9} ", style="bold cyan")
261
+ text.append(f"{_bar(me_pct)} {me_pct:3.0f}%\n", style="cyan")
206
262
  for name, st in sorted(self._opponents.items()):
207
263
  pct = st.get("progress", 0.0)
208
- text.append(f"{name[:7]:<7}", style="bold")
209
- text.append(f"[{_bar(pct)}] {pct:3.0f}%\n", style="magenta")
264
+ text.append(f"{name[:9]:<9} ", style="bold magenta")
265
+ text.append(f"{_bar(pct)} {pct:3.0f}%\n", style="magenta")
210
266
  self.query_one("#bars", Static).update(text)
211
267
 
212
268
  def _show_standings(self) -> None:
213
269
  self._typing = False
214
- table = Table(title="Final Standings", title_style="bold")
270
+ table = Table(title="Final Standings", title_style="bold", expand=True)
215
271
  table.add_column("#", justify="right")
216
272
  table.add_column("Player")
217
273
  table.add_column("WPM", justify="right")
@@ -224,10 +280,30 @@ class OnlineRaceScreen(Screen[None]):
224
280
  f"{row.get('wpm', 0):.0f}",
225
281
  f"{row.get('accuracy', 0) * 100:.0f}%",
226
282
  )
283
+ self.query_one("#net-status", Static).update(
284
+ Text("RACE COMPLETE", justify="center", style="bold green")
285
+ )
227
286
  self.query_one("#net-status", Static).display = True
228
287
  self.query_one(TypingField).display = False
229
288
  self.query_one(LiveStats).display = False
230
- self.query_one("#bars", Static).update(Group(table, Text("\nesc to leave", style="grey58")))
289
+ self.query_one("#bars", Static).update(
290
+ Group(table, Text("\nR play again · esc leave", style="grey58"))
291
+ )
292
+
293
+ def _play_again(self) -> None:
294
+ """Re-arm for another round from the results screen."""
295
+ self._phase = "lobby"
296
+ self._typing = False
297
+ self._finished = False
298
+ self.engine = None
299
+ self._start_ms = None
300
+ self._opponents = {}
301
+ self._ready = True
302
+ self.query_one(LiveStats).display = False
303
+ self.query_one(TypingField).display = False
304
+ self._status = "Ready for next race — waiting for other players…"
305
+ self._render_status()
306
+ self.run_worker(self._send("SET_READY", ready=True), name="ready")
231
307
 
232
308
  # ── exit ───────────────────────────────────────────────────────────
233
309
  def action_leave(self) -> None:
@@ -33,6 +33,10 @@ services:
33
33
  TYPEFASTER_JWT_SECRET: ${TYPEFASTER_JWT_SECRET:?set a strong secret in .env}
34
34
  TYPEFASTER_CORS_ORIGINS: ${TYPEFASTER_CORS_ORIGINS:-*}
35
35
  TYPEFASTER_ACCESS_TOKEN_MINUTES: ${TYPEFASTER_ACCESS_TOKEN_MINUTES:-1440}
36
+ # OAuth device-flow login (optional; leave blank to disable a provider)
37
+ TYPEFASTER_GITHUB_CLIENT_ID: ${TYPEFASTER_GITHUB_CLIENT_ID:-}
38
+ TYPEFASTER_GOOGLE_CLIENT_ID: ${TYPEFASTER_GOOGLE_CLIENT_ID:-}
39
+ TYPEFASTER_GOOGLE_CLIENT_SECRET: ${TYPEFASTER_GOOGLE_CLIENT_SECRET:-}
36
40
  expose:
37
41
  - "8000"
38
42
  ports:
@@ -0,0 +1,106 @@
1
+ # Going online — Cloudflare tunnel + OAuth login
2
+
3
+ How to let a friend on another machine join your races, and (optionally) enable
4
+ GitHub / Google login. Everything here is **free**.
5
+
6
+ The model: your server runs locally (Colima → `localhost:8000`). A **tunnel**
7
+ gives it a public `https://…` address. Friends point their client at that URL.
8
+
9
+ ---
10
+
11
+ ## 1. Start the server
12
+ ```bash
13
+ cd typefaster-cli
14
+ make up # redis + server on localhost:8000 (Colima)
15
+ curl -s localhost:8000/healthz # {"status":"ok"}
16
+ ```
17
+
18
+ ## 2. Expose it with a Cloudflare tunnel (free, no account)
19
+ ```bash
20
+ brew install cloudflared
21
+ cloudflared tunnel --url http://localhost:8000
22
+ ```
23
+ It prints a public URL, e.g.:
24
+ ```
25
+ https://brave-otter-1234.trycloudflare.com
26
+ ```
27
+ Keep this terminal open — the tunnel lives as long as it runs. WebSockets work
28
+ over it automatically (`https://` → `wss://`).
29
+
30
+ > The free `trycloudflare.com` URL changes each run. For a stable address, use a
31
+ > named tunnel (Cloudflare account, still free) or deploy to a VM (`deployment.md`).
32
+
33
+ ## 3. Point clients at the tunnel
34
+ On **each** machine (yours and your friend's):
35
+ ```bash
36
+ typefaster config set-server https://brave-otter-1234.trycloudflare.com
37
+ typefaster config show
38
+ ```
39
+
40
+ ## 4. Play
41
+ **You (host):**
42
+ ```bash
43
+ typefaster register alice # or: typefaster login --github
44
+ typefaster lobby create --name "Friday" --time 60
45
+ # waiting room shows the join code, e.g. 6AJ97X
46
+ ```
47
+ **Friend (other machine):**
48
+ ```bash
49
+ typefaster config set-server https://brave-otter-1234.trycloudflare.com
50
+ typefaster register bob # or login --github / --google
51
+ typefaster lobby join 6AJ97X
52
+ ```
53
+ Both: once you see each other in the room, press **R**. Server runs the
54
+ countdown and the race. After, **R** plays again, **Esc** leaves.
55
+
56
+ ---
57
+
58
+ ## 5. (Optional) GitHub / Google login
59
+
60
+ Username/password works out of the box. To add social login (device flow, the
61
+ `gh auth login` experience), create the apps below and put the IDs in `.env`,
62
+ then `make up` to restart.
63
+
64
+ ### GitHub (client id only — free)
65
+ 1. https://github.com/settings/developers → **New OAuth App**.
66
+ 2. Application name: `TYPEFASTER`; Homepage URL: your repo or tunnel URL;
67
+ Authorization callback URL: any valid URL (device flow doesn't use it, e.g.
68
+ the homepage).
69
+ 3. **Enable Device Flow** (checkbox on the app page) — required.
70
+ 4. Copy the **Client ID** into `.env`:
71
+ ```
72
+ TYPEFASTER_GITHUB_CLIENT_ID=Iv1.xxxxxxxx
73
+ ```
74
+ (No client secret needed for GitHub device flow.)
75
+
76
+ ### Google (client id + secret — free)
77
+ 1. https://console.cloud.google.com → create/select a project.
78
+ 2. **APIs & Services → OAuth consent screen** → External → fill basics → add
79
+ your email as a test user.
80
+ 3. **Credentials → Create credentials → OAuth client ID → "TVs and Limited
81
+ Input devices"**.
82
+ 4. Copy **Client ID** and **Client secret** into `.env`:
83
+ ```
84
+ TYPEFASTER_GOOGLE_CLIENT_ID=xxxx.apps.googleusercontent.com
85
+ TYPEFASTER_GOOGLE_CLIENT_SECRET=xxxx
86
+ ```
87
+
88
+ ### Apply + use
89
+ ```bash
90
+ make up # restart server with the new env
91
+ typefaster login --github # shows a code + opens github.com/login/device
92
+ typefaster login --google # shows a code + opens google.com/device
93
+ ```
94
+ The CLI prints a short code and opens your browser; approve there and you're in.
95
+ First social login auto-creates a TYPEFASTER account (username derived from your
96
+ GitHub login / Google email).
97
+
98
+ > Device flow needs **no public callback URL**, so it works behind the tunnel
99
+ > and on `localhost` alike. Provider login is free; the only possible cost is a
100
+ > server host if you later move off the tunnel (see `deployment.md`).
101
+
102
+ ## Stop everything
103
+ ```bash
104
+ # Ctrl-C the cloudflared terminal
105
+ make down # stop redis + server
106
+ ```
@@ -13,9 +13,12 @@ save 60 10000
13
13
  maxmemory 256mb
14
14
  maxmemory-policy volatile-lru
15
15
 
16
- # Only listen on the container network; never expose Redis publicly.
16
+ # Listen on the internal Docker network so the server container can connect.
17
+ # Redis is NOT published to the host or internet (no `ports:` mapping in
18
+ # docker-compose.yml), so protected-mode is safely disabled here. Do not expose
19
+ # 6379 publicly without also setting `requirepass`.
17
20
  bind 0.0.0.0
18
- protected-mode yes
21
+ protected-mode no
19
22
 
20
23
  # Reduce log noise.
21
24
  loglevel notice
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "typefaster-cli"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "A terminal-first typing game inspired by MonkeyType and TypeRacer."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -16,6 +16,11 @@ class Settings(BaseSettings):
16
16
  countdown_seconds: int = 3
17
17
  max_players_per_lobby: int = 8
18
18
 
19
+ # OAuth device-flow login (all free). Empty => that provider is disabled.
20
+ github_client_id: str = ""
21
+ google_client_id: str = ""
22
+ google_client_secret: str = "" # Google device flow requires the secret
23
+
19
24
 
20
25
  def get_settings() -> Settings:
21
26
  return Settings()
@@ -14,7 +14,7 @@ from .config import get_settings
14
14
  from .deps import resolve_token
15
15
  from .logging_config import configure_logging
16
16
  from .repositories import RedisRepository
17
- from .routers import auth, health, leaderboards, lobbies
17
+ from .routers import auth, health, leaderboards, lobbies, oauth
18
18
  from .ws.manager import Hub
19
19
 
20
20
  log = logging.getLogger("typefaster.server")
@@ -56,6 +56,7 @@ def create_app() -> FastAPI:
56
56
  )
57
57
  app.include_router(health.router)
58
58
  app.include_router(auth.router)
59
+ app.include_router(oauth.router)
59
60
  app.include_router(lobbies.router)
60
61
  app.include_router(leaderboards.router)
61
62
 
@@ -17,6 +17,11 @@ def session(jti: str) -> str:
17
17
  return f"session:{jti}"
18
18
 
19
19
 
20
+ def oauth(provider: str, provider_id: str) -> str:
21
+ """STRING: maps an external identity (github/google + id) -> our username."""
22
+ return f"oauth:{provider}:{provider_id}"
23
+
24
+
20
25
  def lobby(code: str) -> str:
21
26
  """HASH: name, host, is_public, mode_seconds, status, created_at."""
22
27
  return f"lobby:{code}"