sweatstack-cli 0.11.0__tar.gz → 0.12.0__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 (61) hide show
  1. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/CHANGELOG.md +19 -0
  2. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/PKG-INFO +6 -2
  3. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/README.md +4 -0
  4. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/pyproject.toml +1 -1
  5. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/commands/auth.py +13 -3
  6. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/commands/page.py +89 -36
  7. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/services/applications.py +2 -1
  8. sweatstack_cli-0.12.0/tests/test_commands/test_auth.py +49 -0
  9. sweatstack_cli-0.12.0/tests/test_commands/test_page_deploy.py +155 -0
  10. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_commands/test_page_init.py +1 -1
  11. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_services/test_applications.py +1 -1
  12. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/uv.lock +1 -1
  13. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/.claude/settings.local.json +0 -0
  14. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/.claude/skills/sweatstack/SKILL.md +0 -0
  15. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/.claude/skills/sweatstack/api.md +0 -0
  16. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/.claude/skills/sweatstack/auth.md +0 -0
  17. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/.claude/skills/sweatstack/cli.md +0 -0
  18. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/.claude/skills/sweatstack/pyscript.md +0 -0
  19. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/.claude/skills/sweatstack/webapp.md +0 -0
  20. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/.gitignore +0 -0
  21. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/.python-version +0 -0
  22. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/CREATE_PRIVATE_APP.md +0 -0
  23. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/DEVELOPMENT.md +0 -0
  24. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/Makefile +0 -0
  25. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/PLAN.md +0 -0
  26. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/plans/001-page-init-command.md +0 -0
  27. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/__init__.py +0 -0
  28. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/api/__init__.py +0 -0
  29. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/api/client.py +0 -0
  30. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/auth/__init__.py +0 -0
  31. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/auth/callback_server.py +0 -0
  32. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/auth/jwt.py +0 -0
  33. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/auth/pkce.py +0 -0
  34. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/auth/tokens.py +0 -0
  35. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/commands/__init__.py +0 -0
  36. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/commands/app.py +0 -0
  37. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/config.py +0 -0
  38. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/console.py +0 -0
  39. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/exceptions.py +0 -0
  40. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/main.py +0 -0
  41. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/project.py +0 -0
  42. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/py.typed +0 -0
  43. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/services/__init__.py +0 -0
  44. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/templates/page/index.html +0 -0
  45. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/version_check.py +0 -0
  46. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/sweatstack.toml +0 -0
  47. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/__init__.py +0 -0
  48. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/conftest.py +0 -0
  49. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_api/__init__.py +0 -0
  50. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_api/test_client.py +0 -0
  51. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_auth/__init__.py +0 -0
  52. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_auth/test_callback_server.py +0 -0
  53. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_auth/test_jwt.py +0 -0
  54. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_auth/test_pkce.py +0 -0
  55. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_auth/test_tokens.py +0 -0
  56. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_commands/__init__.py +0 -0
  57. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_commands/test_app.py +0 -0
  58. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_commands/test_cli.py +0 -0
  59. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_project.py +0 -0
  60. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_services/__init__.py +0 -0
  61. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_version_check.py +0 -0
@@ -4,6 +4,25 @@ All notable changes to this project are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.12.0] - 2026-10-13
8
+
9
+ ### Fixed
10
+
11
+ - `login` no longer crashes after authenticating: it read a `name` claim that
12
+ the userinfo endpoint never returns. `login` and `status` now show
13
+ `given_name`/`family_name`, falling back to email.
14
+ - `page init` now sends the slug to `PUT /api/v1/applications/{id}/page` as a
15
+ form field, as the API expects; it was sent as JSON and rejected with a 422.
16
+
17
+ ### Changed
18
+
19
+ - `page deploy` now updates or creates: if the page doesn't exist, it is created
20
+ and the deploy goes ahead. With an application linked in `sweatstack.toml`, its
21
+ page is created at the slug. Without one, `deploy` asks to create a new
22
+ application with that page (the same flow as `page init`) and records it in
23
+ `sweatstack.toml`; non-interactively, it fails and points to `app link` and
24
+ `app create --page`.
25
+
7
26
  ## [0.11.0] - 2026-06-30
8
27
 
9
28
  ### Added
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: sweatstack-cli
3
- Version: 0.11.0
3
+ Version: 0.12.0
4
4
  Summary: Command-line interface for SweatStack — the sports data platform for developers
5
5
  Project-URL: Homepage, https://sweatstack.no
6
6
  Project-URL: Documentation, https://docs.sweatstack.no
@@ -134,6 +134,10 @@ sweatstack page deploy
134
134
  sweatstack page deploy my-page --dir ./dist
135
135
  ```
136
136
 
137
+ If the page doesn't exist yet, `deploy` creates it. With an app linked in
138
+ `sweatstack.toml`, it creates that app's page. Without one, it asks to create a
139
+ new app with the page (and fails instead when not run interactively, e.g. in CI).
140
+
137
141
  ## License
138
142
 
139
143
  MIT
@@ -104,6 +104,10 @@ sweatstack page deploy
104
104
  sweatstack page deploy my-page --dir ./dist
105
105
  ```
106
106
 
107
+ If the page doesn't exist yet, `deploy` creates it. With an app linked in
108
+ `sweatstack.toml`, it creates that app's page. Without one, it asks to create a
109
+ new app with the page (and fails instead when not run interactively, e.g. in CI).
110
+
107
111
  ## License
108
112
 
109
113
  MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sweatstack-cli"
3
- version = "0.11.0"
3
+ version = "0.12.0"
4
4
  description = "Command-line interface for SweatStack — the sports data platform for developers"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Aart Goossens", email = "aart@sweatstack.no" }]
@@ -11,6 +11,16 @@ from sweatstack_cli.exceptions import APIError, AuthenticationError
11
11
  from sweatstack_cli.version_check import check_for_update
12
12
 
13
13
 
14
+ def _display_name(user: dict[str, object]) -> str:
15
+ """Best human-readable name from an OIDC userinfo response.
16
+
17
+ Userinfo carries ``given_name``/``family_name`` (both optional), not ``name``.
18
+ """
19
+ parts = [user.get("given_name"), user.get("family_name")]
20
+ full_name = " ".join(p for p in parts if isinstance(p, str) and p)
21
+ return full_name or str(user.get("email") or user.get("sub") or "unknown")
22
+
23
+
14
24
  def login(
15
25
  force: bool = typer.Option(
16
26
  False,
@@ -30,7 +40,7 @@ def login(
30
40
  client = APIClient(authenticator=auth)
31
41
  user = client.get("/api/v1/oauth/userinfo")
32
42
  console.print(
33
- f"Already logged in as [bold]{user['name']}[/bold]. "
43
+ f"Already logged in as [bold]{_display_name(user)}[/bold]. "
34
44
  f"Use [cyan]--force[/cyan] to re-authenticate."
35
45
  )
36
46
  return
@@ -48,7 +58,7 @@ def login(
48
58
  try:
49
59
  client = APIClient(authenticator=auth)
50
60
  user = client.get("/api/v1/oauth/userinfo")
51
- console.print(f"[green]✓[/green] Logged in as [bold]{user['name']}[/bold]")
61
+ console.print(f"[green]✓[/green] Logged in as [bold]{_display_name(user)}[/bold]")
52
62
  except AuthenticationError as e:
53
63
  console.print(f"[red]Login succeeded but verification failed:[/red] {e}")
54
64
  raise typer.Exit(2)
@@ -76,7 +86,7 @@ def status() -> None:
76
86
  try:
77
87
  client = APIClient(authenticator=auth)
78
88
  user = client.get("/api/v1/oauth/userinfo")
79
- console.print(f" Name: {user.get('name', 'unknown')}")
89
+ console.print(f" Name: {_display_name(user)}")
80
90
  console.print(f" Email: {user.get('email', 'unknown')}")
81
91
  console.print(f" ID: {user.get('sub', 'unknown')}")
82
92
  except (AuthenticationError, APIError):
@@ -33,9 +33,6 @@ from sweatstack_cli.services.applications import (
33
33
  PAGES_DOMAIN = "pages.sweatstack.dev"
34
34
  DEFAULT_PAGE_TITLE = "SweatStack App"
35
35
 
36
- # URL where users can create pages for their applications
37
- SETTINGS_URL = "https://app.sweatstack.no/settings/api"
38
-
39
36
  app = typer.Typer(
40
37
  name="page",
41
38
  help="Deploy static sites to SweatStack Pages.",
@@ -162,45 +159,29 @@ def deploy(
162
159
  "Your site may not load correctly."
163
160
  )
164
161
 
165
- try:
166
- client = APIClient()
167
- except AuthenticationError as e:
168
- console.print(f"[red]Error:[/red] {e}")
169
- raise typer.Exit(2)
162
+ client = _require_client()
170
163
 
171
- with Progress(
172
- SpinnerColumn(),
173
- TextColumn("[progress.description]{task.description}"),
174
- console=console,
175
- transient=True,
176
- ) as progress:
177
- progress.add_task(
178
- f"Deploying [bold]{page_slug}[/bold] ({file_count} files)...",
179
- total=None,
164
+ # Update or create: a missing page is created, then the deploy is retried.
165
+ config_written = False
166
+ try:
167
+ result = _upload(client, page_slug, files)
168
+ except APIError as e:
169
+ if e.status_code != 404:
170
+ console.print(f"[red]Error:[/red] {e}")
171
+ raise typer.Exit(3)
172
+ page_slug, config_written = _create_missing_page(
173
+ client, config=config, slug=page_slug, directory=Path(rel_dir)
180
174
  )
181
-
182
175
  try:
183
- result = client.put(f"/api/v1/pages/{page_slug}/deploy", files=files)
176
+ result = _upload(client, page_slug, files)
184
177
  except APIError as e:
185
- error_msg = str(e)
186
-
187
- # Handle 404 - page doesn't exist
188
- if "(404)" in error_msg:
189
- console.print(f"[red]Error:[/red] Page '{page_slug}' not found.")
190
- console.print()
191
- console.print(
192
- f"Create this page first at: [link={SETTINGS_URL}]{SETTINGS_URL}[/link]"
193
- )
194
- console.print()
195
- console.print("[dim]Pages must be created in the SweatStack dashboard[/dim]")
196
- console.print("[dim]before you can deploy to them.[/dim]")
197
- raise typer.Exit(1)
198
-
199
178
  console.print(f"[red]Error:[/red] {e}")
200
179
  raise typer.Exit(3)
201
180
 
202
181
  # Save to config after successful deploy
203
- if config:
182
+ if config_written:
183
+ pass # Creating the application wrote sweatstack.toml in full.
184
+ elif config:
204
185
  new_slug = page_slug if not config.page.slug else None
205
186
  new_dir = rel_dir if (not config.page.directory and directory_explicit) else None
206
187
  if new_slug or new_dir:
@@ -227,6 +208,76 @@ def deploy(
227
208
  console.print(f" {publish_url}")
228
209
 
229
210
 
211
+ def _upload(
212
+ client: APIClient, slug: str, files: list[tuple[str, tuple[str, bytes, str]]]
213
+ ) -> dict[str, Any]:
214
+ """Replace the page's files, showing a spinner while uploading."""
215
+ with Progress(
216
+ SpinnerColumn(),
217
+ TextColumn("[progress.description]{task.description}"),
218
+ console=console,
219
+ transient=True,
220
+ ) as progress:
221
+ progress.add_task(f"Deploying [bold]{slug}[/bold] ({len(files)} files)...", total=None)
222
+ return client.put(f"/api/v1/pages/{slug}/deploy", files=files)
223
+
224
+
225
+ def _create_missing_page(
226
+ client: APIClient,
227
+ *,
228
+ config: ProjectConfig | None,
229
+ slug: str,
230
+ directory: Path,
231
+ ) -> tuple[str, bool]:
232
+ """Create the page ``deploy`` couldn't find. Returns ``(slug, config_written)``.
233
+
234
+ With an application linked in sweatstack.toml, its page is created at ``slug``.
235
+ Without one, a new application with that page is created after asking (the same
236
+ flow as ``page init``); non-interactively, that's an error instead.
237
+ """
238
+ client_id = config.app.client_id if config else None
239
+
240
+ if client_id:
241
+ try:
242
+ ensure_page(client, application_id=client_id, slug=slug)
243
+ except APIError as error:
244
+ if error.code == APPLICATION_HAS_PAGE_CODE:
245
+ # One page per app, and a live URL is never renamed.
246
+ existing = get_page_slug(client, client_id)
247
+ console.print(
248
+ f"[red]Error:[/red] This application's page is "
249
+ f"[bold]{existing}[/bold], not [bold]{slug}[/bold]."
250
+ )
251
+ console.print(f"[dim]Deploy it with: sweatstack page deploy {existing}[/dim]")
252
+ raise typer.Exit(1)
253
+ _exit_slug_error(error, hint="Deploy to a different page name.")
254
+ console.print(f"[green]✓[/green] Created page [bold]{slug}[/bold]")
255
+ return slug, False
256
+
257
+ if not _is_interactive():
258
+ console.print(
259
+ f"[red]Error:[/red] Page '{slug}' not found, and no application is linked "
260
+ f"in {CONFIG_FILENAME}."
261
+ )
262
+ console.print()
263
+ console.print("Link an existing app (its page is created on the next deploy):")
264
+ console.print(" [cyan]sweatstack app link[/cyan]")
265
+ console.print()
266
+ console.print("Or create a new app with this page:")
267
+ console.print(f' [cyan]sweatstack app create "<name>" --page {slug}[/cyan]')
268
+ raise typer.Exit(1)
269
+
270
+ console.print(
271
+ f"Page [bold]{slug}[/bold] doesn't exist yet, and no application is linked "
272
+ f"in {CONFIG_FILENAME}."
273
+ )
274
+ name = typer.prompt("Application name", default=slug)
275
+ _client_id, accepted, _url, _name = _init_bootstrap(
276
+ name=name, slug=slug, directory=directory, interactive=True
277
+ )
278
+ return accepted, True
279
+
280
+
230
281
  def _find_available_port(start: int = 8000, max_attempts: int = 100) -> int:
231
282
  """Find the first available port starting from `start`."""
232
283
  for port in range(start, start + max_attempts):
@@ -350,11 +401,13 @@ def _slug_rejection_message(error: APIError) -> str:
350
401
  return messages.get(error.code or "", error.detail or str(error))
351
402
 
352
403
 
353
- def _exit_slug_error(error: APIError) -> NoReturn:
404
+ def _exit_slug_error(
405
+ error: APIError, *, hint: str = "Choose another name or pass --slug."
406
+ ) -> NoReturn:
354
407
  """Report a slug-related (or other) API error and exit."""
355
408
  if error.code in SLUG_REJECTION_CODES:
356
409
  console.print(f"[red]Error:[/red] {_slug_rejection_message(error)}")
357
- console.print("[dim]Choose another name or pass --slug.[/dim]")
410
+ console.print(f"[dim]{hint}[/dim]")
358
411
  else:
359
412
  console.print(f"[red]Error:[/red] {error}")
360
413
  raise typer.Exit(3)
@@ -90,9 +90,10 @@ def ensure_page(client: APIClient, *, application_id: str, slug: str) -> dict[st
90
90
  ``code == APPLICATION_HAS_PAGE_CODE`` if this application already has a Page
91
91
  at a different slug.
92
92
  """
93
+ # The endpoint reads ``slug`` as a form field, not JSON.
93
94
  return client.put(
94
95
  f"/api/v1/applications/{application_id}/page",
95
- json={"slug": slug},
96
+ data={"slug": slug},
96
97
  )
97
98
 
98
99
 
@@ -0,0 +1,49 @@
1
+ """Tests for authentication commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ from typer.testing import CliRunner
8
+
9
+ from sweatstack_cli.commands.auth import _display_name
10
+ from sweatstack_cli.main import app
11
+
12
+ runner = CliRunner()
13
+
14
+ # Shape of GET /api/v1/oauth/userinfo: OIDC claims, no `name`.
15
+ USERINFO = {
16
+ "sub": "user_123",
17
+ "given_name": "Ada",
18
+ "family_name": "Lovelace",
19
+ "email": "ada@example.com",
20
+ "registered_at": "2026-01-01T00:00:00Z",
21
+ "issue": None,
22
+ }
23
+
24
+
25
+ class TestDisplayName:
26
+ def test_joins_given_and_family_name(self) -> None:
27
+ assert _display_name(USERINFO) == "Ada Lovelace"
28
+
29
+ def test_given_name_only(self) -> None:
30
+ assert _display_name({"sub": "u", "given_name": "Ada", "family_name": None}) == "Ada"
31
+
32
+ def test_falls_back_to_email_then_sub(self) -> None:
33
+ assert _display_name({"sub": "u", "email": "ada@example.com"}) == "ada@example.com"
34
+ assert _display_name({"sub": "u"}) == "u"
35
+
36
+
37
+ class TestLogin:
38
+ def test_already_logged_in_uses_userinfo_claims(self) -> None:
39
+ with (
40
+ patch("sweatstack_cli.commands.auth.Authenticator") as mock_auth,
41
+ patch("sweatstack_cli.commands.auth.APIClient") as mock_client,
42
+ ):
43
+ mock_auth.return_value.get_valid_tokens.return_value = MagicMock()
44
+ mock_client.return_value.get.return_value = USERINFO
45
+
46
+ result = runner.invoke(app, ["login"])
47
+
48
+ assert result.exit_code == 0, result.output
49
+ assert "Already logged in as Ada Lovelace" in result.stdout
@@ -0,0 +1,155 @@
1
+ """Tests for `page deploy`: update the page, or create it when it doesn't exist."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from pathlib import Path
7
+ from unittest.mock import MagicMock, patch
8
+
9
+ import pytest
10
+ from typer.testing import CliRunner
11
+
12
+ from sweatstack_cli.exceptions import APIError
13
+ from sweatstack_cli.main import app
14
+ from sweatstack_cli.project import ProjectConfig, write_config
15
+
16
+ runner = CliRunner()
17
+
18
+ DEPLOYED = {
19
+ "slug": "my-page",
20
+ "published": True,
21
+ "url": "https://my-page.pages.sweatstack.dev",
22
+ "application": {"id": "app_123", "name": "My App", "is_private": False},
23
+ }
24
+ NOT_FOUND = APIError("API error (404): Page not found", status_code=404)
25
+
26
+
27
+ @pytest.fixture
28
+ def mock_api() -> Iterator[MagicMock]:
29
+ with patch("sweatstack_cli.commands.page.APIClient") as mock_client:
30
+ instance = MagicMock()
31
+ mock_client.return_value = instance
32
+ yield instance
33
+
34
+
35
+ @pytest.fixture
36
+ def site(tmp_path: Path) -> Iterator[Path]:
37
+ """An isolated working directory with a deployable `public/`."""
38
+ with runner.isolated_filesystem(temp_dir=tmp_path) as cwd:
39
+ Path("public").mkdir()
40
+ Path("public/index.html").write_text("<h1>hi</h1>")
41
+ yield Path(cwd)
42
+
43
+
44
+ def _interactive(value: bool):
45
+ return patch("sweatstack_cli.commands.page._is_interactive", return_value=value)
46
+
47
+
48
+ def _deploy_calls(mock_api: MagicMock) -> list[str]:
49
+ return [c.args[0] for c in mock_api.put.call_args_list if c.args[0].endswith("/deploy")]
50
+
51
+
52
+ class TestDeployExistingPage:
53
+ def test_deploys_without_creating(self, mock_api: MagicMock, site: Path) -> None:
54
+ mock_api.put.return_value = DEPLOYED
55
+
56
+ result = runner.invoke(app, ["page", "deploy", "my-page", "--dir", "public"])
57
+
58
+ assert result.exit_code == 0, result.output
59
+ mock_api.put.assert_called_once()
60
+ mock_api.post.assert_not_called()
61
+
62
+ def test_other_errors_do_not_create(self, mock_api: MagicMock, site: Path) -> None:
63
+ mock_api.put.side_effect = APIError("API error (403): Not authorized", status_code=403)
64
+
65
+ result = runner.invoke(app, ["page", "deploy", "my-page", "--dir", "public"])
66
+
67
+ assert result.exit_code == 3
68
+ mock_api.put.assert_called_once()
69
+ mock_api.post.assert_not_called()
70
+
71
+
72
+ class TestDeployCreatesPageForLinkedApp:
73
+ def _link(self) -> None:
74
+ write_config(
75
+ app_name="My App", client_id="app_123", page_slug="my-page", page_directory="public"
76
+ )
77
+
78
+ def test_creates_page_then_deploys(self, mock_api: MagicMock, site: Path) -> None:
79
+ self._link()
80
+ mock_api.put.side_effect = [NOT_FOUND, {"slug": "my-page"}, DEPLOYED]
81
+
82
+ with _interactive(False):
83
+ result = runner.invoke(app, ["page", "deploy"])
84
+
85
+ assert result.exit_code == 0, result.output
86
+ mock_api.put.assert_any_call("/api/v1/applications/app_123/page", data={"slug": "my-page"})
87
+ assert _deploy_calls(mock_api) == ["/api/v1/pages/my-page/deploy"] * 2
88
+ assert "Created page my-page" in result.stdout
89
+ config = ProjectConfig.load()
90
+ assert config is not None
91
+ assert config.app.client_id == "app_123"
92
+
93
+ def test_app_with_another_page_is_an_error(self, mock_api: MagicMock, site: Path) -> None:
94
+ self._link()
95
+ mock_api.put.side_effect = [
96
+ NOT_FOUND,
97
+ APIError("has page", status_code=409, code="application_has_page"),
98
+ ]
99
+ mock_api.get.return_value = [{"id": "app_123", "name": "My App", "page_slug": "other"}]
100
+
101
+ with _interactive(False):
102
+ result = runner.invoke(app, ["page", "deploy"])
103
+
104
+ assert result.exit_code == 1
105
+ assert "page is other, not my-page" in result.stdout
106
+ assert len(_deploy_calls(mock_api)) == 1
107
+
108
+
109
+ class TestDeployCreatesAppWhenUnlinked:
110
+ def test_non_interactive_fails_with_hints(self, mock_api: MagicMock, site: Path) -> None:
111
+ mock_api.put.side_effect = NOT_FOUND
112
+
113
+ with _interactive(False):
114
+ result = runner.invoke(app, ["page", "deploy", "my-page", "--dir", "public"])
115
+
116
+ assert result.exit_code == 1
117
+ assert "sweatstack app link" in result.stdout
118
+ assert 'sweatstack app create "<name>" --page my-page' in result.stdout
119
+ mock_api.post.assert_not_called()
120
+
121
+ def test_interactive_creates_app_after_asking(self, mock_api: MagicMock, site: Path) -> None:
122
+ mock_api.put.side_effect = [NOT_FOUND, DEPLOYED]
123
+ mock_api.post.return_value = {
124
+ "id": "app_123",
125
+ "name": "My App",
126
+ "page": {"slug": "my-page", "url": "https://my-page.pages.sweatstack.dev"},
127
+ }
128
+
129
+ with _interactive(True):
130
+ result = runner.invoke(
131
+ app, ["page", "deploy", "my-page", "--dir", "public"], input="My App\ny\n"
132
+ )
133
+
134
+ assert result.exit_code == 0, result.output
135
+ mock_api.post.assert_called_once_with(
136
+ "/api/v1/applications", json={"name": "My App", "page": "my-page"}
137
+ )
138
+ assert _deploy_calls(mock_api) == ["/api/v1/pages/my-page/deploy"] * 2
139
+ config = ProjectConfig.load()
140
+ assert config is not None
141
+ assert config.app.client_id == "app_123"
142
+ assert config.page.slug == "my-page"
143
+ assert config.page.directory == "public"
144
+
145
+ def test_interactive_decline_creates_nothing(self, mock_api: MagicMock, site: Path) -> None:
146
+ mock_api.put.side_effect = NOT_FOUND
147
+
148
+ with _interactive(True):
149
+ result = runner.invoke(
150
+ app, ["page", "deploy", "my-page", "--dir", "public"], input="My App\nn\n"
151
+ )
152
+
153
+ assert result.exit_code == 1
154
+ mock_api.post.assert_not_called()
155
+ assert not Path("sweatstack.toml").exists()
@@ -195,7 +195,7 @@ class TestReuse:
195
195
 
196
196
  assert result.exit_code == 0, result.stdout
197
197
  mock_api.put.assert_called_once_with(
198
- "/api/v1/applications/app_123/page", json={"slug": "my-app"}
198
+ "/api/v1/applications/app_123/page", data={"slug": "my-app"}
199
199
  )
200
200
  assert "Created page" in result.stdout
201
201
  assert ProjectConfig.load().page.slug == "my-app" # type: ignore[union-attr]
@@ -86,7 +86,7 @@ class TestEnsurePage:
86
86
  result = ensure_page(client, application_id="abc", slug="my-app")
87
87
 
88
88
  assert result["slug"] == "my-app"
89
- client.put.assert_called_once_with("/api/v1/applications/abc/page", json={"slug": "my-app"})
89
+ client.put.assert_called_once_with("/api/v1/applications/abc/page", data={"slug": "my-app"})
90
90
 
91
91
 
92
92
  class TestGetPageSlug:
@@ -463,7 +463,7 @@ wheels = [
463
463
 
464
464
  [[package]]
465
465
  name = "sweatstack-cli"
466
- version = "0.11.0"
466
+ version = "0.12.0"
467
467
  source = { editable = "." }
468
468
  dependencies = [
469
469
  { name = "httpx" },
File without changes