sweatstack-cli 0.11.0__tar.gz → 0.12.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 (63) hide show
  1. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/CHANGELOG.md +31 -0
  2. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/DEVELOPMENT.md +8 -6
  3. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/PKG-INFO +40 -7
  4. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/README.md +38 -5
  5. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/pyproject.toml +1 -1
  6. sweatstack_cli-0.12.1/src/sweatstack_cli/__init__.py +10 -0
  7. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/commands/app.py +13 -12
  8. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/commands/auth.py +13 -3
  9. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/commands/page.py +89 -36
  10. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/services/applications.py +2 -1
  11. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_commands/test_app.py +22 -4
  12. sweatstack_cli-0.12.1/tests/test_commands/test_auth.py +49 -0
  13. sweatstack_cli-0.12.1/tests/test_commands/test_page_deploy.py +155 -0
  14. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_commands/test_page_init.py +1 -1
  15. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_services/test_applications.py +1 -1
  16. sweatstack_cli-0.12.1/tests/test_version.py +17 -0
  17. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/uv.lock +1 -1
  18. sweatstack_cli-0.11.0/src/sweatstack_cli/__init__.py +0 -3
  19. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/.claude/settings.local.json +0 -0
  20. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/.claude/skills/sweatstack/SKILL.md +0 -0
  21. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/.claude/skills/sweatstack/api.md +0 -0
  22. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/.claude/skills/sweatstack/auth.md +0 -0
  23. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/.claude/skills/sweatstack/cli.md +0 -0
  24. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/.claude/skills/sweatstack/pyscript.md +0 -0
  25. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/.claude/skills/sweatstack/webapp.md +0 -0
  26. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/.gitignore +0 -0
  27. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/.python-version +0 -0
  28. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/CREATE_PRIVATE_APP.md +0 -0
  29. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/Makefile +0 -0
  30. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/PLAN.md +0 -0
  31. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/plans/001-page-init-command.md +0 -0
  32. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/api/__init__.py +0 -0
  33. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/api/client.py +0 -0
  34. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/auth/__init__.py +0 -0
  35. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/auth/callback_server.py +0 -0
  36. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/auth/jwt.py +0 -0
  37. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/auth/pkce.py +0 -0
  38. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/auth/tokens.py +0 -0
  39. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/commands/__init__.py +0 -0
  40. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/config.py +0 -0
  41. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/console.py +0 -0
  42. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/exceptions.py +0 -0
  43. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/main.py +0 -0
  44. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/project.py +0 -0
  45. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/py.typed +0 -0
  46. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/services/__init__.py +0 -0
  47. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/templates/page/index.html +0 -0
  48. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/version_check.py +0 -0
  49. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/sweatstack.toml +0 -0
  50. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/__init__.py +0 -0
  51. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/conftest.py +0 -0
  52. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_api/__init__.py +0 -0
  53. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_api/test_client.py +0 -0
  54. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_auth/__init__.py +0 -0
  55. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_auth/test_callback_server.py +0 -0
  56. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_auth/test_jwt.py +0 -0
  57. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_auth/test_pkce.py +0 -0
  58. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_auth/test_tokens.py +0 -0
  59. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_commands/__init__.py +0 -0
  60. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_commands/test_cli.py +0 -0
  61. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_project.py +0 -0
  62. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_services/__init__.py +0 -0
  63. {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_version_check.py +0 -0
@@ -4,6 +4,37 @@ 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.1] - 2026-10-13
8
+
9
+ ### Fixed
10
+
11
+ - 0.12.0 reported itself as 0.11.0: in `--version`, in `status` (which then
12
+ always offered an "update" to 0.12.0) and in the `User-Agent` header. The
13
+ version is now read from the installed package, so `pyproject.toml` is the
14
+ only place to bump it.
15
+ - `app create` checks for an existing `sweatstack.toml` before creating the
16
+ application, not after. Non-interactively, or when declining to overwrite, it
17
+ used to create the app and then stop, leaving an application nothing pointed to.
18
+
19
+ ## [0.12.0] - 2026-10-13
20
+
21
+ ### Fixed
22
+
23
+ - `login` no longer crashes after authenticating: it read a `name` claim that
24
+ the userinfo endpoint never returns. `login` and `status` now show
25
+ `given_name`/`family_name`, falling back to email.
26
+ - `page init` now sends the slug to `PUT /api/v1/applications/{id}/page` as a
27
+ form field, as the API expects; it was sent as JSON and rejected with a 422.
28
+
29
+ ### Changed
30
+
31
+ - `page deploy` now updates or creates: if the page doesn't exist, it is created
32
+ and the deploy goes ahead. With an application linked in `sweatstack.toml`, its
33
+ page is created at the slug. Without one, `deploy` asks to create a new
34
+ application with that page (the same flow as `page init`) and records it in
35
+ `sweatstack.toml`; non-interactively, it fails and points to `app link` and
36
+ `app create --page`.
37
+
7
38
  ## [0.11.0] - 2026-06-30
8
39
 
9
40
  ### Added
@@ -25,7 +25,7 @@ pytest
25
25
 
26
26
  ```
27
27
  src/sweatstack_cli/
28
- ├── __init__.py # Package version
28
+ ├── __init__.py # Package version (read from metadata)
29
29
  ├── main.py # CLI entry point (Typer app)
30
30
  ├── config.py # Environment-based configuration
31
31
  ├── console.py # Rich console singleton
@@ -338,11 +338,13 @@ def my_command():
338
338
 
339
339
  ## Release Process
340
340
 
341
- 1. Update version in `src/sweatstack_cli/__init__.py`
342
- 2. Update `pyproject.toml` version if needed
343
- 3. Run full test suite: `pytest`
344
- 4. Build: `uv build` or `python -m build`
345
- 5. Publish: `twine upload dist/*`
341
+ 1. Bump `version` in `pyproject.toml`, then run `uv lock` to update `uv.lock`.
342
+ This is the only place the version lives: `__version__` reads it from the
343
+ installed package.
344
+ 2. Rename `## [Unreleased]` in `CHANGELOG.md` to `## [X.Y.Z] - YYYY-MM-DD`.
345
+ 3. Run all checks: `make check`
346
+ 4. Commit as `Release X.Y.Z`
347
+ 5. Build and publish: `make publish`
346
348
 
347
349
  ## Debugging
348
350
 
@@ -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.1
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
@@ -67,8 +67,11 @@ uv tool install --reinstall sweatstack-cli
67
67
  # Authenticate with SweatStack
68
68
  sweatstack login
69
69
 
70
- # Create an app and deploy
71
- sweatstack app create "My App" --page my-page --env
70
+ # Create an app with a page, and scaffold public/index.html
71
+ sweatstack page init "My App"
72
+
73
+ # Preview locally, then publish to https://my-app.pages.sweatstack.dev
74
+ sweatstack page serve
72
75
  sweatstack page deploy
73
76
  ```
74
77
 
@@ -110,7 +113,7 @@ sweatstack page deploy
110
113
 
111
114
  ### Project Configuration
112
115
 
113
- Both `app create` and `app link` generate a `sweatstack.toml` in the current directory. Other commands read defaults from this file.
116
+ `app create`, `app link` and `page init` generate a `sweatstack.toml` in the current directory. Other commands read defaults from this file.
114
117
 
115
118
  ```toml
116
119
  [app]
@@ -122,9 +125,33 @@ slug = "my-page"
122
125
  directory = "dist"
123
126
  ```
124
127
 
125
- ### Page Deployment
128
+ ### Pages
129
+
130
+ Static sites hosted at `https://<slug>.pages.sweatstack.dev`. Each application has at most one page.
131
+
132
+ | Command | Description |
133
+ |---------|-------------|
134
+ | `sweatstack page init [NAME]` | Create an app and its page, and scaffold `public/index.html` |
135
+ | `sweatstack page serve` | Preview the site locally |
136
+ | `sweatstack page deploy [SLUG]` | Publish the site, creating the page if needed |
137
+
138
+ #### `page init`
126
139
 
127
- Deploy static sites to SweatStack Pages.
140
+ Creates a private application and its page, records both in `sweatstack.toml`, and writes a starter app to `public/index.html` that signs in with SweatStack and plots your latest activity. The slug is derived from the name. With an app already linked in `sweatstack.toml`, it reuses that app instead of creating one.
141
+
142
+ | Option | Description |
143
+ |--------|-------------|
144
+ | `--slug TEXT` | Page slug (default: derived from the name) |
145
+ | `--dir PATH` | Directory to scaffold (default: `public`) |
146
+ | `--title TEXT` | Title shown on the page (default: the name) |
147
+ | `--client-id TEXT` | Reuse an existing application |
148
+ | `--force, -f` | Overwrite an existing `index.html` |
149
+
150
+ #### `page serve`
151
+
152
+ Serves the `[page] directory` from `sweatstack.toml` on the first free port from 8000. Extra arguments are passed to `python -m http.server`. Apps created by the CLI accept sign-in redirects to localhost on any port.
153
+
154
+ #### `page deploy`
128
155
 
129
156
  ```bash
130
157
  # Deploy using sweatstack.toml defaults (recommended)
@@ -134,6 +161,12 @@ sweatstack page deploy
134
161
  sweatstack page deploy my-page --dir ./dist
135
162
  ```
136
163
 
164
+ Every deploy replaces all files on the page. If the page doesn't exist yet, `deploy` creates it. With an app linked in `sweatstack.toml`, it creates that app's page. Without one, it asks to create a new app with the page, and fails instead when not run interactively (e.g. in CI).
165
+
166
+ New apps are private: only you can sign in. After deploying, `deploy` prints a link to make the app public.
167
+
168
+ **Limits:** 10 MB per file; 100 MB and about 1,000 files per deploy.
169
+
137
170
  ## License
138
171
 
139
172
  MIT
@@ -37,8 +37,11 @@ uv tool install --reinstall sweatstack-cli
37
37
  # Authenticate with SweatStack
38
38
  sweatstack login
39
39
 
40
- # Create an app and deploy
41
- sweatstack app create "My App" --page my-page --env
40
+ # Create an app with a page, and scaffold public/index.html
41
+ sweatstack page init "My App"
42
+
43
+ # Preview locally, then publish to https://my-app.pages.sweatstack.dev
44
+ sweatstack page serve
42
45
  sweatstack page deploy
43
46
  ```
44
47
 
@@ -80,7 +83,7 @@ sweatstack page deploy
80
83
 
81
84
  ### Project Configuration
82
85
 
83
- Both `app create` and `app link` generate a `sweatstack.toml` in the current directory. Other commands read defaults from this file.
86
+ `app create`, `app link` and `page init` generate a `sweatstack.toml` in the current directory. Other commands read defaults from this file.
84
87
 
85
88
  ```toml
86
89
  [app]
@@ -92,9 +95,33 @@ slug = "my-page"
92
95
  directory = "dist"
93
96
  ```
94
97
 
95
- ### Page Deployment
98
+ ### Pages
99
+
100
+ Static sites hosted at `https://<slug>.pages.sweatstack.dev`. Each application has at most one page.
101
+
102
+ | Command | Description |
103
+ |---------|-------------|
104
+ | `sweatstack page init [NAME]` | Create an app and its page, and scaffold `public/index.html` |
105
+ | `sweatstack page serve` | Preview the site locally |
106
+ | `sweatstack page deploy [SLUG]` | Publish the site, creating the page if needed |
107
+
108
+ #### `page init`
96
109
 
97
- Deploy static sites to SweatStack Pages.
110
+ Creates a private application and its page, records both in `sweatstack.toml`, and writes a starter app to `public/index.html` that signs in with SweatStack and plots your latest activity. The slug is derived from the name. With an app already linked in `sweatstack.toml`, it reuses that app instead of creating one.
111
+
112
+ | Option | Description |
113
+ |--------|-------------|
114
+ | `--slug TEXT` | Page slug (default: derived from the name) |
115
+ | `--dir PATH` | Directory to scaffold (default: `public`) |
116
+ | `--title TEXT` | Title shown on the page (default: the name) |
117
+ | `--client-id TEXT` | Reuse an existing application |
118
+ | `--force, -f` | Overwrite an existing `index.html` |
119
+
120
+ #### `page serve`
121
+
122
+ Serves the `[page] directory` from `sweatstack.toml` on the first free port from 8000. Extra arguments are passed to `python -m http.server`. Apps created by the CLI accept sign-in redirects to localhost on any port.
123
+
124
+ #### `page deploy`
98
125
 
99
126
  ```bash
100
127
  # Deploy using sweatstack.toml defaults (recommended)
@@ -104,6 +131,12 @@ sweatstack page deploy
104
131
  sweatstack page deploy my-page --dir ./dist
105
132
  ```
106
133
 
134
+ Every deploy replaces all files on the page. If the page doesn't exist yet, `deploy` creates it. With an app linked in `sweatstack.toml`, it creates that app's page. Without one, it asks to create a new app with the page, and fails instead when not run interactively (e.g. in CI).
135
+
136
+ New apps are private: only you can sign in. After deploying, `deploy` prints a link to make the app public.
137
+
138
+ **Limits:** 10 MB per file; 100 MB and about 1,000 files per deploy.
139
+
107
140
  ## License
108
141
 
109
142
  MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sweatstack-cli"
3
- version = "0.11.0"
3
+ version = "0.12.1"
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" }]
@@ -0,0 +1,10 @@
1
+ """SweatStack CLI — Command-line interface for the SweatStack platform."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ # Single source of truth: the version in pyproject.toml, read from the installed
6
+ # package's metadata. A hardcoded copy here drifted once (0.12.0 shipped as 0.11.0).
7
+ try:
8
+ __version__ = version("sweatstack-cli")
9
+ except PackageNotFoundError: # Imported from a source tree that isn't installed.
10
+ __version__ = "unknown"
@@ -74,6 +74,18 @@ def create(
74
74
  raise typer.Exit(1)
75
75
  name = typer.prompt("App name")
76
76
 
77
+ # Protect an existing sweatstack.toml before creating anything, so declining
78
+ # (or a non-interactive refusal) never leaves an orphaned application behind.
79
+ if Path(CONFIG_FILENAME).exists():
80
+ if not interactive:
81
+ console.print(
82
+ f"[red]Error:[/red] {CONFIG_FILENAME} already exists. "
83
+ "Remove it first or run interactively."
84
+ )
85
+ raise typer.Exit(1)
86
+ console.print(f"[yellow]Warning:[/yellow] {CONFIG_FILENAME} already exists.")
87
+ typer.confirm("Overwrite?", abort=True)
88
+
77
89
  # Prompt for optional fields in interactive mode
78
90
  if interactive:
79
91
  if description is None:
@@ -110,18 +122,7 @@ def create(
110
122
  if env_path:
111
123
  _write_env_file(env_path, result)
112
124
 
113
- # Write sweatstack.toml (protect existing file)
114
- config_path = Path(CONFIG_FILENAME)
115
- if config_path.exists():
116
- if not interactive:
117
- console.print(
118
- f"[red]Error:[/red] {CONFIG_FILENAME} already exists. "
119
- "Remove it first or run interactively."
120
- )
121
- raise typer.Exit(1)
122
- console.print(f"[yellow]Warning:[/yellow] {CONFIG_FILENAME} already exists.")
123
- typer.confirm("Overwrite?", abort=True)
124
-
125
+ # Write sweatstack.toml (checked for overwrite before the app was created)
125
126
  # Default the deploy directory to public/ so the project is deploy-ready and
126
127
  # consistent with `page init` / `page serve` / `page deploy`.
127
128
  config_path = write_config(
@@ -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
 
@@ -189,9 +189,12 @@ class TestAppCreate:
189
189
  assert result.exit_code == 1
190
190
  assert "mutually exclusive" in result.stdout
191
191
 
192
- def test_create_not_authenticated(self) -> None:
192
+ def test_create_not_authenticated(self, tmp_path: Path) -> None:
193
193
  """Should error when not authenticated."""
194
- with patch("sweatstack_cli.commands.app.APIClient") as mock_client:
194
+ with (
195
+ patch("sweatstack_cli.commands.app.APIClient") as mock_client,
196
+ runner.isolated_filesystem(temp_dir=tmp_path),
197
+ ):
195
198
  mock_client.side_effect = AuthenticationError("Not authenticated")
196
199
 
197
200
  result = runner.invoke(app, ["app", "create", "Test App"])
@@ -200,15 +203,30 @@ class TestAppCreate:
200
203
  assert "Not authenticated" in result.stdout
201
204
  assert "sweatstack login" in result.stdout
202
205
 
203
- def test_create_api_error(self, mock_api: MagicMock) -> None:
206
+ def test_create_api_error(self, mock_api: MagicMock, tmp_path: Path) -> None:
204
207
  """Should handle API errors."""
205
208
  mock_api.post.side_effect = APIError("App name already exists")
206
209
 
207
- result = runner.invoke(app, ["app", "create", "Test App"])
210
+ with runner.isolated_filesystem(temp_dir=tmp_path):
211
+ result = runner.invoke(app, ["app", "create", "Test App"])
208
212
 
209
213
  assert result.exit_code == 3
210
214
  assert "App name already exists" in result.stdout
211
215
 
216
+ def test_create_existing_config_creates_nothing(
217
+ self, mock_api: MagicMock, tmp_path: Path
218
+ ) -> None:
219
+ """Should refuse before creating the app, never leaving an orphan behind."""
220
+ with runner.isolated_filesystem(temp_dir=tmp_path):
221
+ Path("sweatstack.toml").write_text('[app]\nclient_id = "existing"\n')
222
+
223
+ result = runner.invoke(app, ["app", "create", "Test App"])
224
+
225
+ assert result.exit_code == 1
226
+ assert "already exists" in result.stdout
227
+ mock_api.post.assert_not_called()
228
+ assert Path("sweatstack.toml").read_text() == '[app]\nclient_id = "existing"\n'
229
+
212
230
  def test_create_env_with_secret_no_warning_without_env(
213
231
  self, mock_api: MagicMock, tmp_path: Path
214
232
  ) -> None:
@@ -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:
@@ -0,0 +1,17 @@
1
+ """The reported version is the one in pyproject.toml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from pathlib import Path
7
+
8
+ from sweatstack_cli import __version__
9
+
10
+ PYPROJECT = Path(__file__).parent.parent / "pyproject.toml"
11
+
12
+
13
+ def test_version_matches_pyproject() -> None:
14
+ with PYPROJECT.open("rb") as f:
15
+ expected = tomllib.load(f)["project"]["version"]
16
+
17
+ assert __version__ == expected
@@ -463,7 +463,7 @@ wheels = [
463
463
 
464
464
  [[package]]
465
465
  name = "sweatstack-cli"
466
- version = "0.11.0"
466
+ version = "0.12.1"
467
467
  source = { editable = "." }
468
468
  dependencies = [
469
469
  { name = "httpx" },
@@ -1,3 +0,0 @@
1
- """SweatStack CLI — Command-line interface for the SweatStack platform."""
2
-
3
- __version__ = "0.11.0"
File without changes