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.
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/CHANGELOG.md +31 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/DEVELOPMENT.md +8 -6
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/PKG-INFO +40 -7
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/README.md +38 -5
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/pyproject.toml +1 -1
- sweatstack_cli-0.12.1/src/sweatstack_cli/__init__.py +10 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/commands/app.py +13 -12
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/commands/auth.py +13 -3
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/commands/page.py +89 -36
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/services/applications.py +2 -1
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_commands/test_app.py +22 -4
- sweatstack_cli-0.12.1/tests/test_commands/test_auth.py +49 -0
- sweatstack_cli-0.12.1/tests/test_commands/test_page_deploy.py +155 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_commands/test_page_init.py +1 -1
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_services/test_applications.py +1 -1
- sweatstack_cli-0.12.1/tests/test_version.py +17 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/uv.lock +1 -1
- sweatstack_cli-0.11.0/src/sweatstack_cli/__init__.py +0 -3
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/.claude/settings.local.json +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/.claude/skills/sweatstack/SKILL.md +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/.claude/skills/sweatstack/api.md +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/.claude/skills/sweatstack/auth.md +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/.claude/skills/sweatstack/cli.md +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/.claude/skills/sweatstack/pyscript.md +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/.claude/skills/sweatstack/webapp.md +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/.gitignore +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/.python-version +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/CREATE_PRIVATE_APP.md +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/Makefile +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/PLAN.md +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/plans/001-page-init-command.md +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/api/__init__.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/api/client.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/auth/__init__.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/auth/callback_server.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/auth/jwt.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/auth/pkce.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/auth/tokens.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/commands/__init__.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/config.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/console.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/exceptions.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/main.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/project.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/py.typed +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/services/__init__.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/templates/page/index.html +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/src/sweatstack_cli/version_check.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/sweatstack.toml +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/__init__.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/conftest.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_api/__init__.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_api/test_client.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_auth/__init__.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_auth/test_callback_server.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_auth/test_jwt.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_auth/test_pkce.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_auth/test_tokens.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_commands/__init__.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_commands/test_cli.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_project.py +0 -0
- {sweatstack_cli-0.11.0 → sweatstack_cli-0.12.1}/tests/test_services/__init__.py +0 -0
- {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.
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: sweatstack-cli
|
|
3
|
-
Version: 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
|
|
71
|
-
sweatstack
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
|
41
|
-
sweatstack
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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
|
|
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
|
|
|
@@ -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
|
|
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
|
-
|
|
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",
|
|
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:
|
|
@@ -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
|
|
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.1}/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
|