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.
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/CHANGELOG.md +19 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/PKG-INFO +6 -2
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/README.md +4 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/pyproject.toml +1 -1
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/commands/auth.py +13 -3
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/commands/page.py +89 -36
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/services/applications.py +2 -1
- sweatstack_cli-0.12.0/tests/test_commands/test_auth.py +49 -0
- sweatstack_cli-0.12.0/tests/test_commands/test_page_deploy.py +155 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_commands/test_page_init.py +1 -1
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_services/test_applications.py +1 -1
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/uv.lock +1 -1
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/.claude/settings.local.json +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/.claude/skills/sweatstack/SKILL.md +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/.claude/skills/sweatstack/api.md +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/.claude/skills/sweatstack/auth.md +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/.claude/skills/sweatstack/cli.md +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/.claude/skills/sweatstack/pyscript.md +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/.claude/skills/sweatstack/webapp.md +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/.gitignore +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/.python-version +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/CREATE_PRIVATE_APP.md +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/DEVELOPMENT.md +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/Makefile +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/PLAN.md +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/plans/001-page-init-command.md +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/__init__.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/api/__init__.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/api/client.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/auth/__init__.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/auth/callback_server.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/auth/jwt.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/auth/pkce.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/auth/tokens.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/commands/__init__.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/commands/app.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/config.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/console.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/exceptions.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/main.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/project.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/py.typed +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/services/__init__.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/templates/page/index.html +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/version_check.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/sweatstack.toml +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/__init__.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/conftest.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_api/__init__.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_api/test_client.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_auth/__init__.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_auth/test_callback_server.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_auth/test_jwt.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_auth/test_pkce.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_auth/test_tokens.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_commands/__init__.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_commands/test_app.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_commands/test_cli.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_project.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/tests/test_services/__init__.py +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: sweatstack-cli
|
|
3
|
-
Version: 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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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
|
|
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(
|
|
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]
|
|
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
|
-
|
|
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",
|
|
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",
|
|
89
|
+
client.put.assert_called_once_with("/api/v1/applications/abc/page", data={"slug": "my-app"})
|
|
90
90
|
|
|
91
91
|
|
|
92
92
|
class TestGetPageSlug:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sweatstack_cli-0.11.0 → sweatstack_cli-0.12.0}/src/sweatstack_cli/templates/page/index.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|