sweatstack-cli 0.1.2__tar.gz → 0.2.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.1.2 → sweatstack_cli-0.2.0}/.claude/settings.local.json +2 -1
- sweatstack_cli-0.2.0/CREATE_PRIVATE_APP.md +524 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/PKG-INFO +1 -1
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/pyproject.toml +1 -1
- sweatstack_cli-0.2.0/src/sweatstack_cli/commands/app.py +139 -0
- sweatstack_cli-0.1.2/src/sweatstack_cli/commands/pages.py → sweatstack_cli-0.2.0/src/sweatstack_cli/commands/page.py +2 -2
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/console.py +5 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/main.py +4 -2
- sweatstack_cli-0.2.0/tests/test_commands/test_app.py +243 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/tests/test_commands/test_cli.py +30 -8
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/uv.lock +1 -1
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/.gitignore +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/.python-version +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/DEVELOPMENT.md +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/Makefile +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/PLAN.md +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/README.md +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/__init__.py +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/api/__init__.py +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/api/client.py +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/auth/__init__.py +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/auth/callback_server.py +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/auth/jwt.py +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/auth/pkce.py +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/auth/tokens.py +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/commands/__init__.py +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/commands/auth.py +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/config.py +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/exceptions.py +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/py.typed +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/tests/__init__.py +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/tests/conftest.py +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/tests/test_api/__init__.py +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/tests/test_api/test_client.py +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/tests/test_auth/__init__.py +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/tests/test_auth/test_callback_server.py +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/tests/test_auth/test_jwt.py +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/tests/test_auth/test_pkce.py +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/tests/test_auth/test_tokens.py +0 -0
- {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/tests/test_commands/__init__.py +0 -0
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
# Plan: Add `app create` Command + Rename `pages` → `page`
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
1. Add functionality to create private OAuth2 applications through the CLI
|
|
6
|
+
2. Rename `pages` command to `page` for consistency (singular command names)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Part 1: Rename `pages` → `page`
|
|
11
|
+
|
|
12
|
+
### Rationale
|
|
13
|
+
|
|
14
|
+
Consistent singular command names across the CLI:
|
|
15
|
+
- `sweatstack app create` (new)
|
|
16
|
+
- `sweatstack page deploy` (renamed from `pages`)
|
|
17
|
+
- `sweatstack login` / `logout` / `whoami` (existing)
|
|
18
|
+
|
|
19
|
+
This matches the convention used by `gh`, `gcloud`, `aws`, `az`, `docker`, and `fly`.
|
|
20
|
+
|
|
21
|
+
### Changes Required
|
|
22
|
+
|
|
23
|
+
| File | Change |
|
|
24
|
+
|------|--------|
|
|
25
|
+
| `src/sweatstack_cli/commands/pages.py` | Rename to `page.py`, update Typer name |
|
|
26
|
+
| `src/sweatstack_cli/main.py` | Update import and registration |
|
|
27
|
+
| `tests/test_commands/test_pages.py` | Rename to `test_page.py`, update tests |
|
|
28
|
+
|
|
29
|
+
### Code Changes
|
|
30
|
+
|
|
31
|
+
**`src/sweatstack_cli/commands/page.py`** (renamed from `pages.py`):
|
|
32
|
+
```python
|
|
33
|
+
app = typer.Typer(name="page", help="Deploy static sites to SweatStack Pages.")
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**`src/sweatstack_cli/main.py`**:
|
|
37
|
+
```python
|
|
38
|
+
from sweatstack_cli.commands import auth, page # was: pages
|
|
39
|
+
|
|
40
|
+
app.add_typer(page.app, name="page") # was: pages
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Part 2: Add `app create` Command
|
|
46
|
+
|
|
47
|
+
### CLI Interface
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
sweatstack app create NAME [--page SLUG] [--secret] [--env | --env-file PATH] [--json]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Examples
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# Minimal
|
|
57
|
+
sweatstack app create "My App"
|
|
58
|
+
|
|
59
|
+
# With page (redirect URI auto-included)
|
|
60
|
+
sweatstack app create "My App" --page myapp
|
|
61
|
+
|
|
62
|
+
# With secret, written to .env
|
|
63
|
+
sweatstack app create "My App" --secret --env
|
|
64
|
+
|
|
65
|
+
# Full example
|
|
66
|
+
sweatstack app create "My App" --page myapp --secret --env-file .env.local
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Design Decisions
|
|
70
|
+
|
|
71
|
+
| Element | Design | Rationale |
|
|
72
|
+
|---------|--------|-----------|
|
|
73
|
+
| `NAME` | Positional (required) | Primary identifier, always needed |
|
|
74
|
+
| `--page SLUG` | Option | Associates a SweatStack Page; auto-includes redirect URI |
|
|
75
|
+
| `--secret` / `-s` | Flag | Generates client secret (explicit opt-in for security) |
|
|
76
|
+
| `--env` | Flag | Write credentials to `.env` in current directory |
|
|
77
|
+
| `--env-file PATH` | Option | Write credentials to custom path (mutually exclusive with `--env`) |
|
|
78
|
+
| `--json` | Flag | Output JSON for scripting/CI |
|
|
79
|
+
|
|
80
|
+
### Notes
|
|
81
|
+
|
|
82
|
+
- When `--page` is specified, the API automatically adds the page URL to redirect URIs (no opt-out needed)
|
|
83
|
+
- Warnings (e.g., "variable already exists") are written to stderr to keep stdout clean for `--json` piping
|
|
84
|
+
|
|
85
|
+
### API Mapping
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
CLI → API Request Body
|
|
89
|
+
────────────────────────────────────────────────────────────
|
|
90
|
+
NAME (positional) → "name": "..."
|
|
91
|
+
--page SLUG → "page": "..."
|
|
92
|
+
--secret → "generate_secret": true
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Implementation
|
|
98
|
+
|
|
99
|
+
### File: `src/sweatstack_cli/commands/app.py`
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
"""Application management commands."""
|
|
103
|
+
|
|
104
|
+
from __future__ import annotations
|
|
105
|
+
|
|
106
|
+
import json as json_lib
|
|
107
|
+
from pathlib import Path
|
|
108
|
+
|
|
109
|
+
import typer
|
|
110
|
+
|
|
111
|
+
from sweatstack_cli.api.client import APIClient
|
|
112
|
+
from sweatstack_cli.console import console
|
|
113
|
+
from sweatstack_cli.exceptions import APIError, AuthenticationError, ValidationError
|
|
114
|
+
|
|
115
|
+
app = typer.Typer(name="app", help="Manage OAuth2 applications.")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.command()
|
|
119
|
+
def create(
|
|
120
|
+
name: str = typer.Argument(..., help="Application name."),
|
|
121
|
+
page: str | None = typer.Option(
|
|
122
|
+
None, "--page", "-p",
|
|
123
|
+
help="Associate with a SweatStack Page (includes redirect URI).",
|
|
124
|
+
),
|
|
125
|
+
secret: bool = typer.Option(
|
|
126
|
+
False, "--secret", "-s",
|
|
127
|
+
help="Generate a client secret.",
|
|
128
|
+
),
|
|
129
|
+
env: bool = typer.Option(
|
|
130
|
+
False, "--env",
|
|
131
|
+
help="Write credentials to .env file.",
|
|
132
|
+
),
|
|
133
|
+
env_file: Path | None = typer.Option(
|
|
134
|
+
None, "--env-file",
|
|
135
|
+
help="Write credentials to specified file.",
|
|
136
|
+
),
|
|
137
|
+
json_output: bool = typer.Option(
|
|
138
|
+
False, "--json",
|
|
139
|
+
help="Output as JSON.",
|
|
140
|
+
),
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Create a new private OAuth2 application."""
|
|
143
|
+
# Validate mutually exclusive options
|
|
144
|
+
if env and env_file:
|
|
145
|
+
raise ValidationError("--env and --env-file are mutually exclusive.")
|
|
146
|
+
|
|
147
|
+
# Build request body
|
|
148
|
+
body: dict[str, str | bool] = {"name": name}
|
|
149
|
+
|
|
150
|
+
if page:
|
|
151
|
+
body["page"] = page
|
|
152
|
+
|
|
153
|
+
if secret:
|
|
154
|
+
body["generate_secret"] = True
|
|
155
|
+
|
|
156
|
+
# Make API call
|
|
157
|
+
try:
|
|
158
|
+
client = APIClient()
|
|
159
|
+
except AuthenticationError as e:
|
|
160
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
161
|
+
console.print("[dim]Run 'sweatstack login' to authenticate.[/dim]")
|
|
162
|
+
raise typer.Exit(2)
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
result = client.post("/api/v1/applications", json=body)
|
|
166
|
+
except APIError as e:
|
|
167
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
168
|
+
raise typer.Exit(3)
|
|
169
|
+
|
|
170
|
+
# Write to env file if requested
|
|
171
|
+
env_path = Path(".env") if env else env_file
|
|
172
|
+
if env_path:
|
|
173
|
+
_write_env_file(env_path, result)
|
|
174
|
+
|
|
175
|
+
# Output result
|
|
176
|
+
if json_output:
|
|
177
|
+
console.print(json_lib.dumps(result, indent=2))
|
|
178
|
+
else:
|
|
179
|
+
_print_app_created(result, env_path)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _write_env_file(path: Path, result: dict) -> None:
|
|
183
|
+
"""Append credentials to an env file."""
|
|
184
|
+
lines_to_add: list[str] = []
|
|
185
|
+
existing_content = ""
|
|
186
|
+
|
|
187
|
+
if path.exists():
|
|
188
|
+
existing_content = path.read_text()
|
|
189
|
+
|
|
190
|
+
# Check for existing variables and prepare new lines
|
|
191
|
+
client_id = result.get("client_id")
|
|
192
|
+
client_secret = result.get("client_secret")
|
|
193
|
+
|
|
194
|
+
if client_id:
|
|
195
|
+
if "SWEATSTACK_CLIENT_ID=" in existing_content:
|
|
196
|
+
# Use stderr so it doesn't pollute JSON output
|
|
197
|
+
console.print(f"[yellow]Warning:[/yellow] SWEATSTACK_CLIENT_ID already exists in {path}, skipping.", stderr=True)
|
|
198
|
+
else:
|
|
199
|
+
lines_to_add.append(f"SWEATSTACK_CLIENT_ID={client_id}")
|
|
200
|
+
|
|
201
|
+
if client_secret:
|
|
202
|
+
if "SWEATSTACK_CLIENT_SECRET=" in existing_content:
|
|
203
|
+
console.print(f"[yellow]Warning:[/yellow] SWEATSTACK_CLIENT_SECRET already exists in {path}, skipping.", stderr=True)
|
|
204
|
+
else:
|
|
205
|
+
lines_to_add.append(f"SWEATSTACK_CLIENT_SECRET={client_secret}")
|
|
206
|
+
|
|
207
|
+
if lines_to_add:
|
|
208
|
+
# Ensure file ends with newline before appending
|
|
209
|
+
if existing_content and not existing_content.endswith("\n"):
|
|
210
|
+
lines_to_add.insert(0, "")
|
|
211
|
+
|
|
212
|
+
with path.open("a") as f:
|
|
213
|
+
f.write("\n".join(lines_to_add) + "\n")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _print_app_created(result: dict, env_path: Path | None) -> None:
|
|
217
|
+
"""Pretty-print the created application details."""
|
|
218
|
+
console.print(f"[green]✓[/green] Created application [bold]{result['name']}[/bold]")
|
|
219
|
+
console.print()
|
|
220
|
+
console.print(f" Client ID: [cyan]{result['client_id']}[/cyan]")
|
|
221
|
+
|
|
222
|
+
if "client_secret" in result:
|
|
223
|
+
console.print(f" Secret: [cyan]{result['client_secret']}[/cyan]")
|
|
224
|
+
|
|
225
|
+
if "redirect_uris" in result and result["redirect_uris"]:
|
|
226
|
+
console.print()
|
|
227
|
+
console.print(" Redirect URIs:")
|
|
228
|
+
for uri in result["redirect_uris"]:
|
|
229
|
+
console.print(f" • {uri}")
|
|
230
|
+
|
|
231
|
+
# Show env file status or secret warning
|
|
232
|
+
if env_path:
|
|
233
|
+
console.print()
|
|
234
|
+
console.print(f"[green]✓[/green] Credentials written to [bold]{env_path}[/bold]")
|
|
235
|
+
elif "client_secret" in result:
|
|
236
|
+
console.print()
|
|
237
|
+
console.print("[yellow]Save the secret now — it won't be shown again.[/yellow]")
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### File: `src/sweatstack_cli/main.py`
|
|
241
|
+
|
|
242
|
+
Add import and registration:
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
from sweatstack_cli.commands import auth, page, app as app_commands
|
|
246
|
+
|
|
247
|
+
# ... existing registrations ...
|
|
248
|
+
|
|
249
|
+
app.add_typer(page.app, name="page") # renamed from pages
|
|
250
|
+
app.add_typer(app_commands.app, name="app") # new
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## Tests
|
|
256
|
+
|
|
257
|
+
### File: `tests/test_commands/test_app.py`
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
"""Tests for app commands."""
|
|
261
|
+
|
|
262
|
+
from pathlib import Path
|
|
263
|
+
from unittest.mock import MagicMock, patch
|
|
264
|
+
|
|
265
|
+
import pytest
|
|
266
|
+
from typer.testing import CliRunner
|
|
267
|
+
|
|
268
|
+
from sweatstack_cli.main import app
|
|
269
|
+
|
|
270
|
+
runner = CliRunner()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@pytest.fixture
|
|
274
|
+
def mock_api():
|
|
275
|
+
"""Mock APIClient for testing."""
|
|
276
|
+
with patch("sweatstack_cli.commands.app.APIClient") as mock_client:
|
|
277
|
+
mock_instance = MagicMock()
|
|
278
|
+
mock_client.return_value = mock_instance
|
|
279
|
+
yield mock_instance
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_app_create_minimal(mock_api):
|
|
283
|
+
"""Test creating an app with just a name."""
|
|
284
|
+
mock_api.post.return_value = {
|
|
285
|
+
"name": "Test App",
|
|
286
|
+
"client_id": "abc123",
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
result = runner.invoke(app, ["app", "create", "Test App"])
|
|
290
|
+
|
|
291
|
+
assert result.exit_code == 0
|
|
292
|
+
assert "Test App" in result.stdout
|
|
293
|
+
assert "abc123" in result.stdout
|
|
294
|
+
mock_api.post.assert_called_once_with(
|
|
295
|
+
"/api/v1/applications",
|
|
296
|
+
json={"name": "Test App"},
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_app_create_with_page(mock_api):
|
|
301
|
+
"""Test creating an app with page association."""
|
|
302
|
+
mock_api.post.return_value = {
|
|
303
|
+
"name": "Test App",
|
|
304
|
+
"client_id": "abc123",
|
|
305
|
+
"redirect_uris": ["https://myapp.sweatstack.pages.dev/callback"],
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
result = runner.invoke(app, ["app", "create", "Test App", "--page", "myapp"])
|
|
309
|
+
|
|
310
|
+
assert result.exit_code == 0
|
|
311
|
+
mock_api.post.assert_called_once_with(
|
|
312
|
+
"/api/v1/applications",
|
|
313
|
+
json={
|
|
314
|
+
"name": "Test App",
|
|
315
|
+
"page": "myapp",
|
|
316
|
+
},
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_app_create_with_secret(mock_api):
|
|
321
|
+
"""Test creating an app with client secret."""
|
|
322
|
+
mock_api.post.return_value = {
|
|
323
|
+
"name": "Test App",
|
|
324
|
+
"client_id": "abc123",
|
|
325
|
+
"client_secret": "secret456",
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
result = runner.invoke(app, ["app", "create", "Test App", "--secret"])
|
|
329
|
+
|
|
330
|
+
assert result.exit_code == 0
|
|
331
|
+
assert "secret456" in result.stdout
|
|
332
|
+
assert "won't be shown again" in result.stdout
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_app_create_with_env(mock_api, tmp_path):
|
|
336
|
+
"""Test writing credentials to .env file."""
|
|
337
|
+
mock_api.post.return_value = {
|
|
338
|
+
"name": "Test App",
|
|
339
|
+
"client_id": "abc123",
|
|
340
|
+
"client_secret": "secret456",
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
with runner.isolated_filesystem(temp_dir=tmp_path):
|
|
344
|
+
result = runner.invoke(app, ["app", "create", "Test App", "--secret", "--env"])
|
|
345
|
+
|
|
346
|
+
assert result.exit_code == 0
|
|
347
|
+
assert "written to" in result.stdout
|
|
348
|
+
|
|
349
|
+
env_content = Path(".env").read_text()
|
|
350
|
+
assert "SWEATSTACK_CLIENT_ID=abc123" in env_content
|
|
351
|
+
assert "SWEATSTACK_CLIENT_SECRET=secret456" in env_content
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def test_app_create_with_env_file(mock_api, tmp_path):
|
|
355
|
+
"""Test writing credentials to custom env file."""
|
|
356
|
+
mock_api.post.return_value = {
|
|
357
|
+
"name": "Test App",
|
|
358
|
+
"client_id": "abc123",
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
with runner.isolated_filesystem(temp_dir=tmp_path):
|
|
362
|
+
result = runner.invoke(app, ["app", "create", "Test App", "--env-file", ".env.local"])
|
|
363
|
+
|
|
364
|
+
assert result.exit_code == 0
|
|
365
|
+
env_content = Path(".env.local").read_text()
|
|
366
|
+
assert "SWEATSTACK_CLIENT_ID=abc123" in env_content
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_app_create_env_no_overwrite(mock_api, tmp_path):
|
|
370
|
+
"""Test that existing env vars are not overwritten."""
|
|
371
|
+
mock_api.post.return_value = {
|
|
372
|
+
"name": "Test App",
|
|
373
|
+
"client_id": "abc123",
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
with runner.isolated_filesystem(temp_dir=tmp_path):
|
|
377
|
+
Path(".env").write_text("SWEATSTACK_CLIENT_ID=existing\n")
|
|
378
|
+
|
|
379
|
+
result = runner.invoke(app, ["app", "create", "Test App", "--env"])
|
|
380
|
+
|
|
381
|
+
assert result.exit_code == 0
|
|
382
|
+
assert "already exists" in result.stdout
|
|
383
|
+
|
|
384
|
+
env_content = Path(".env").read_text()
|
|
385
|
+
assert "SWEATSTACK_CLIENT_ID=existing" in env_content
|
|
386
|
+
assert "abc123" not in env_content
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def test_app_create_env_and_env_file_mutually_exclusive():
|
|
390
|
+
"""Test that --env and --env-file cannot be used together."""
|
|
391
|
+
result = runner.invoke(app, ["app", "create", "Test App", "--env", "--env-file", ".env.local"])
|
|
392
|
+
|
|
393
|
+
assert result.exit_code == 4 # ValidationError
|
|
394
|
+
assert "mutually exclusive" in result.stdout
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def test_app_create_not_authenticated():
|
|
398
|
+
"""Test error when not authenticated."""
|
|
399
|
+
with patch("sweatstack_cli.commands.app.APIClient") as mock_client:
|
|
400
|
+
from sweatstack_cli.exceptions import AuthenticationError
|
|
401
|
+
mock_client.side_effect = AuthenticationError("Not authenticated")
|
|
402
|
+
|
|
403
|
+
result = runner.invoke(app, ["app", "create", "Test App"])
|
|
404
|
+
|
|
405
|
+
assert result.exit_code == 2
|
|
406
|
+
assert "Not authenticated" in result.stdout
|
|
407
|
+
assert "sweatstack login" in result.stdout
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def test_app_create_api_error(mock_api):
|
|
411
|
+
"""Test error when API returns an error."""
|
|
412
|
+
from sweatstack_cli.exceptions import APIError
|
|
413
|
+
mock_api.post.side_effect = APIError("App name already exists")
|
|
414
|
+
|
|
415
|
+
result = runner.invoke(app, ["app", "create", "Test App"])
|
|
416
|
+
|
|
417
|
+
assert result.exit_code == 3
|
|
418
|
+
assert "App name already exists" in result.stdout
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def test_app_create_json_output(mock_api):
|
|
422
|
+
"""Test JSON output format."""
|
|
423
|
+
mock_api.post.return_value = {
|
|
424
|
+
"name": "Test App",
|
|
425
|
+
"client_id": "abc123",
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
result = runner.invoke(app, ["app", "create", "Test App", "--json"])
|
|
429
|
+
|
|
430
|
+
assert result.exit_code == 0
|
|
431
|
+
assert '"client_id": "abc123"' in result.stdout
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
---
|
|
435
|
+
|
|
436
|
+
## File Changes Summary
|
|
437
|
+
|
|
438
|
+
| File | Action |
|
|
439
|
+
|------|--------|
|
|
440
|
+
| `src/sweatstack_cli/commands/pages.py` | Rename → `page.py`, update Typer name |
|
|
441
|
+
| `src/sweatstack_cli/commands/app.py` | Create (new module) |
|
|
442
|
+
| `src/sweatstack_cli/main.py` | Update imports and registrations |
|
|
443
|
+
| `tests/test_commands/test_pages.py` | Rename → `test_page.py`, update command names |
|
|
444
|
+
| `tests/test_commands/test_app.py` | Create (new test module) |
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
448
|
+
## Expected CLI Output
|
|
449
|
+
|
|
450
|
+
### Success (Human-Readable)
|
|
451
|
+
|
|
452
|
+
```
|
|
453
|
+
$ sweatstack app create "My Dev App" --page myapp --secret --env
|
|
454
|
+
|
|
455
|
+
✓ Created application My Dev App
|
|
456
|
+
|
|
457
|
+
Client ID: clnt_a1b2c3d4e5f6
|
|
458
|
+
Secret: sk_live_x9y8z7w6v5u4
|
|
459
|
+
|
|
460
|
+
Redirect URIs:
|
|
461
|
+
• https://myapp.sweatstack.pages.dev/callback
|
|
462
|
+
|
|
463
|
+
✓ Credentials written to .env
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
### Success (JSON)
|
|
467
|
+
|
|
468
|
+
```
|
|
469
|
+
$ sweatstack app create "My Dev App" --json
|
|
470
|
+
|
|
471
|
+
{
|
|
472
|
+
"name": "My Dev App",
|
|
473
|
+
"client_id": "clnt_a1b2c3d4e5f6"
|
|
474
|
+
}
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
### Secret Warning (no --env)
|
|
478
|
+
|
|
479
|
+
```
|
|
480
|
+
$ sweatstack app create "My Dev App" --secret
|
|
481
|
+
|
|
482
|
+
✓ Created application My Dev App
|
|
483
|
+
|
|
484
|
+
Client ID: clnt_a1b2c3d4e5f6
|
|
485
|
+
Secret: sk_live_x9y8z7w6v5u4
|
|
486
|
+
|
|
487
|
+
Save the secret now — it won't be shown again.
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
---
|
|
491
|
+
|
|
492
|
+
## Final CLI Structure
|
|
493
|
+
|
|
494
|
+
```
|
|
495
|
+
sweatstack
|
|
496
|
+
├── login # Authenticate via browser
|
|
497
|
+
├── logout # Remove stored credentials
|
|
498
|
+
├── whoami # Show current user
|
|
499
|
+
├── status # Show auth status
|
|
500
|
+
├── page
|
|
501
|
+
│ └── deploy # Deploy static site
|
|
502
|
+
└── app
|
|
503
|
+
└── create # Create OAuth2 application
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
---
|
|
507
|
+
|
|
508
|
+
## Checklist
|
|
509
|
+
|
|
510
|
+
### Part 1: Rename `pages` → `page`
|
|
511
|
+
- [ ] Rename `commands/pages.py` → `commands/page.py`
|
|
512
|
+
- [ ] Update Typer name in `page.py`
|
|
513
|
+
- [ ] Update imports in `main.py`
|
|
514
|
+
- [ ] Rename `test_pages.py` → `test_page.py`
|
|
515
|
+
- [ ] Update test command invocations
|
|
516
|
+
|
|
517
|
+
### Part 2: Add `app create`
|
|
518
|
+
- [ ] Create `commands/app.py`
|
|
519
|
+
- [ ] Register in `main.py`
|
|
520
|
+
- [ ] Create `test_app.py`
|
|
521
|
+
- [ ] Test manually against staging API
|
|
522
|
+
|
|
523
|
+
### Documentation
|
|
524
|
+
- [ ] Update README if needed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sweatstack-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Application management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as json_lib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from sweatstack_cli.api import APIClient
|
|
11
|
+
from sweatstack_cli.console import console, stderr_console
|
|
12
|
+
from sweatstack_cli.exceptions import APIError, AuthenticationError
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(name="app", help="Manage OAuth2 applications.")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command()
|
|
18
|
+
def create(
|
|
19
|
+
name: str = typer.Argument(..., help="Application name."),
|
|
20
|
+
page: str | None = typer.Option(
|
|
21
|
+
None,
|
|
22
|
+
"--page",
|
|
23
|
+
"-p",
|
|
24
|
+
help="Associate with a SweatStack Page (includes redirect URI).",
|
|
25
|
+
),
|
|
26
|
+
secret: bool = typer.Option(
|
|
27
|
+
False,
|
|
28
|
+
"--secret",
|
|
29
|
+
"-s",
|
|
30
|
+
help="Generate a client secret.",
|
|
31
|
+
),
|
|
32
|
+
env: bool = typer.Option(
|
|
33
|
+
False,
|
|
34
|
+
"--env",
|
|
35
|
+
help="Write credentials to .env file.",
|
|
36
|
+
),
|
|
37
|
+
env_file: Path | None = typer.Option(
|
|
38
|
+
None,
|
|
39
|
+
"--env-file",
|
|
40
|
+
help="Write credentials to specified file.",
|
|
41
|
+
),
|
|
42
|
+
json_output: bool = typer.Option(
|
|
43
|
+
False,
|
|
44
|
+
"--json",
|
|
45
|
+
help="Output as JSON.",
|
|
46
|
+
),
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Create a new private OAuth2 application."""
|
|
49
|
+
if env and env_file:
|
|
50
|
+
console.print("[red]Error:[/red] --env and --env-file are mutually exclusive.")
|
|
51
|
+
raise typer.Exit(4)
|
|
52
|
+
|
|
53
|
+
body: dict[str, str | bool] = {"name": name}
|
|
54
|
+
|
|
55
|
+
if page:
|
|
56
|
+
body["page"] = page
|
|
57
|
+
|
|
58
|
+
if secret:
|
|
59
|
+
body["generate_secret"] = True
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
client = APIClient()
|
|
63
|
+
except AuthenticationError as e:
|
|
64
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
65
|
+
console.print("[dim]Run 'sweatstack login' to authenticate.[/dim]")
|
|
66
|
+
raise typer.Exit(2)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
result = client.post("/api/v1/applications", json=body)
|
|
70
|
+
except APIError as e:
|
|
71
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
72
|
+
raise typer.Exit(3)
|
|
73
|
+
|
|
74
|
+
env_path = Path(".env") if env else env_file
|
|
75
|
+
if env_path:
|
|
76
|
+
_write_env_file(env_path, result)
|
|
77
|
+
|
|
78
|
+
if json_output:
|
|
79
|
+
console.print(json_lib.dumps(result, indent=2))
|
|
80
|
+
else:
|
|
81
|
+
_print_app_created(result, env_path)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _write_env_file(path: Path, result: dict) -> None:
|
|
85
|
+
"""Append credentials to an env file."""
|
|
86
|
+
lines_to_add: list[str] = []
|
|
87
|
+
existing_content = ""
|
|
88
|
+
|
|
89
|
+
if path.exists():
|
|
90
|
+
existing_content = path.read_text()
|
|
91
|
+
|
|
92
|
+
client_id = result.get("client_id")
|
|
93
|
+
client_secret = result.get("client_secret")
|
|
94
|
+
|
|
95
|
+
if client_id:
|
|
96
|
+
if "SWEATSTACK_CLIENT_ID=" in existing_content:
|
|
97
|
+
stderr_console.print(
|
|
98
|
+
f"[yellow]Warning:[/yellow] SWEATSTACK_CLIENT_ID already exists in {path}, skipping."
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
lines_to_add.append(f"SWEATSTACK_CLIENT_ID={client_id}")
|
|
102
|
+
|
|
103
|
+
if client_secret:
|
|
104
|
+
if "SWEATSTACK_CLIENT_SECRET=" in existing_content:
|
|
105
|
+
stderr_console.print(
|
|
106
|
+
f"[yellow]Warning:[/yellow] SWEATSTACK_CLIENT_SECRET already exists in {path}, skipping."
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
lines_to_add.append(f"SWEATSTACK_CLIENT_SECRET={client_secret}")
|
|
110
|
+
|
|
111
|
+
if lines_to_add:
|
|
112
|
+
if existing_content and not existing_content.endswith("\n"):
|
|
113
|
+
lines_to_add.insert(0, "")
|
|
114
|
+
|
|
115
|
+
with path.open("a") as f:
|
|
116
|
+
f.write("\n".join(lines_to_add) + "\n")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _print_app_created(result: dict, env_path: Path | None) -> None:
|
|
120
|
+
"""Pretty-print the created application details."""
|
|
121
|
+
console.print(f"[green]✓[/green] Created application [bold]{result['name']}[/bold]")
|
|
122
|
+
console.print()
|
|
123
|
+
console.print(f" Client ID: [cyan]{result['client_id']}[/cyan]")
|
|
124
|
+
|
|
125
|
+
if "client_secret" in result:
|
|
126
|
+
console.print(f" Secret: [cyan]{result['client_secret']}[/cyan]")
|
|
127
|
+
|
|
128
|
+
if result.get("redirect_uris"):
|
|
129
|
+
console.print()
|
|
130
|
+
console.print(" Redirect URIs:")
|
|
131
|
+
for uri in result["redirect_uris"]:
|
|
132
|
+
console.print(f" - {uri}")
|
|
133
|
+
|
|
134
|
+
if env_path:
|
|
135
|
+
console.print()
|
|
136
|
+
console.print(f"[green]✓[/green] Credentials written to [bold]{env_path}[/bold]")
|
|
137
|
+
elif "client_secret" in result:
|
|
138
|
+
console.print()
|
|
139
|
+
console.print("[yellow]Save the secret now — it won't be shown again.[/yellow]")
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Page commands: deploy static sites."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -16,7 +16,7 @@ from sweatstack_cli.exceptions import APIError, AuthenticationError
|
|
|
16
16
|
SETTINGS_URL = "https://app.sweatstack.no/settings/api"
|
|
17
17
|
|
|
18
18
|
app = typer.Typer(
|
|
19
|
-
name="
|
|
19
|
+
name="page",
|
|
20
20
|
help="Deploy static sites to SweatStack Pages.",
|
|
21
21
|
)
|
|
22
22
|
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
"""Shared console instance for consistent terminal output."""
|
|
2
2
|
|
|
3
|
+
import sys
|
|
4
|
+
|
|
3
5
|
from rich.console import Console
|
|
4
6
|
|
|
5
7
|
# Singleton console for consistent output across the CLI
|
|
6
8
|
console = Console()
|
|
9
|
+
|
|
10
|
+
# Separate console for stderr (warnings that shouldn't pollute stdout)
|
|
11
|
+
stderr_console = Console(file=sys.stderr)
|
|
@@ -5,7 +5,8 @@ from __future__ import annotations
|
|
|
5
5
|
import typer
|
|
6
6
|
|
|
7
7
|
from sweatstack_cli import __version__
|
|
8
|
-
from sweatstack_cli.commands import
|
|
8
|
+
from sweatstack_cli.commands import app as app_commands
|
|
9
|
+
from sweatstack_cli.commands import auth, page
|
|
9
10
|
from sweatstack_cli.console import console
|
|
10
11
|
from sweatstack_cli.exceptions import CLIError
|
|
11
12
|
|
|
@@ -19,7 +20,8 @@ app = typer.Typer(
|
|
|
19
20
|
)
|
|
20
21
|
|
|
21
22
|
# Register command groups
|
|
22
|
-
app.add_typer(
|
|
23
|
+
app.add_typer(page.app, name="page")
|
|
24
|
+
app.add_typer(app_commands.app, name="app")
|
|
23
25
|
|
|
24
26
|
# Register top-level auth commands for convenience
|
|
25
27
|
# These are the most common operations, so we promote them to top level
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Tests for app commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from unittest.mock import MagicMock, patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from typer.testing import CliRunner
|
|
10
|
+
|
|
11
|
+
from sweatstack_cli.exceptions import APIError, AuthenticationError
|
|
12
|
+
from sweatstack_cli.main import app
|
|
13
|
+
|
|
14
|
+
runner = CliRunner()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def mock_api():
|
|
19
|
+
"""Mock APIClient for testing."""
|
|
20
|
+
with patch("sweatstack_cli.commands.app.APIClient") as mock_client:
|
|
21
|
+
mock_instance = MagicMock()
|
|
22
|
+
mock_client.return_value = mock_instance
|
|
23
|
+
yield mock_instance
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestAppCreate:
|
|
27
|
+
"""Tests for app create command."""
|
|
28
|
+
|
|
29
|
+
def test_create_minimal(self, mock_api: MagicMock) -> None:
|
|
30
|
+
"""Should create an app with just a name."""
|
|
31
|
+
mock_api.post.return_value = {
|
|
32
|
+
"name": "Test App",
|
|
33
|
+
"client_id": "abc123",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
result = runner.invoke(app, ["app", "create", "Test App"])
|
|
37
|
+
|
|
38
|
+
assert result.exit_code == 0
|
|
39
|
+
assert "Test App" in result.stdout
|
|
40
|
+
assert "abc123" in result.stdout
|
|
41
|
+
mock_api.post.assert_called_once_with(
|
|
42
|
+
"/api/v1/applications",
|
|
43
|
+
json={"name": "Test App"},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def test_create_with_page(self, mock_api: MagicMock) -> None:
|
|
47
|
+
"""Should create an app with page association."""
|
|
48
|
+
mock_api.post.return_value = {
|
|
49
|
+
"name": "Test App",
|
|
50
|
+
"client_id": "abc123",
|
|
51
|
+
"redirect_uris": ["https://myapp.sweatstack.pages.dev/callback"],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
result = runner.invoke(app, ["app", "create", "Test App", "--page", "myapp"])
|
|
55
|
+
|
|
56
|
+
assert result.exit_code == 0
|
|
57
|
+
assert "myapp.sweatstack.pages.dev" in result.stdout
|
|
58
|
+
mock_api.post.assert_called_once_with(
|
|
59
|
+
"/api/v1/applications",
|
|
60
|
+
json={"name": "Test App", "page": "myapp"},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def test_create_with_secret(self, mock_api: MagicMock) -> None:
|
|
64
|
+
"""Should create an app with client secret and show warning."""
|
|
65
|
+
mock_api.post.return_value = {
|
|
66
|
+
"name": "Test App",
|
|
67
|
+
"client_id": "abc123",
|
|
68
|
+
"client_secret": "secret456",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
result = runner.invoke(app, ["app", "create", "Test App", "--secret"])
|
|
72
|
+
|
|
73
|
+
assert result.exit_code == 0
|
|
74
|
+
assert "secret456" in result.stdout
|
|
75
|
+
assert "won't be shown again" in result.stdout
|
|
76
|
+
mock_api.post.assert_called_once_with(
|
|
77
|
+
"/api/v1/applications",
|
|
78
|
+
json={"name": "Test App", "generate_secret": True},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def test_create_with_short_flags(self, mock_api: MagicMock) -> None:
|
|
82
|
+
"""Should support short flags -p and -s."""
|
|
83
|
+
mock_api.post.return_value = {
|
|
84
|
+
"name": "Test App",
|
|
85
|
+
"client_id": "abc123",
|
|
86
|
+
"client_secret": "secret456",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
result = runner.invoke(app, ["app", "create", "Test App", "-p", "myapp", "-s"])
|
|
90
|
+
|
|
91
|
+
assert result.exit_code == 0
|
|
92
|
+
mock_api.post.assert_called_once_with(
|
|
93
|
+
"/api/v1/applications",
|
|
94
|
+
json={"name": "Test App", "page": "myapp", "generate_secret": True},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def test_create_json_output(self, mock_api: MagicMock) -> None:
|
|
98
|
+
"""Should output JSON when --json flag is used."""
|
|
99
|
+
mock_api.post.return_value = {
|
|
100
|
+
"name": "Test App",
|
|
101
|
+
"client_id": "abc123",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
result = runner.invoke(app, ["app", "create", "Test App", "--json"])
|
|
105
|
+
|
|
106
|
+
assert result.exit_code == 0
|
|
107
|
+
assert '"client_id": "abc123"' in result.stdout
|
|
108
|
+
assert '"name": "Test App"' in result.stdout
|
|
109
|
+
|
|
110
|
+
def test_create_with_env(self, mock_api: MagicMock, tmp_path: Path) -> None:
|
|
111
|
+
"""Should write credentials to .env file."""
|
|
112
|
+
mock_api.post.return_value = {
|
|
113
|
+
"name": "Test App",
|
|
114
|
+
"client_id": "abc123",
|
|
115
|
+
"client_secret": "secret456",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
with runner.isolated_filesystem(temp_dir=tmp_path):
|
|
119
|
+
result = runner.invoke(app, ["app", "create", "Test App", "--secret", "--env"])
|
|
120
|
+
|
|
121
|
+
assert result.exit_code == 0
|
|
122
|
+
assert "written to" in result.stdout
|
|
123
|
+
|
|
124
|
+
env_content = Path(".env").read_text()
|
|
125
|
+
assert "SWEATSTACK_CLIENT_ID=abc123" in env_content
|
|
126
|
+
assert "SWEATSTACK_CLIENT_SECRET=secret456" in env_content
|
|
127
|
+
|
|
128
|
+
def test_create_with_env_file(self, mock_api: MagicMock, tmp_path: Path) -> None:
|
|
129
|
+
"""Should write credentials to custom env file."""
|
|
130
|
+
mock_api.post.return_value = {
|
|
131
|
+
"name": "Test App",
|
|
132
|
+
"client_id": "abc123",
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
with runner.isolated_filesystem(temp_dir=tmp_path):
|
|
136
|
+
result = runner.invoke(
|
|
137
|
+
app, ["app", "create", "Test App", "--env-file", ".env.local"]
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
assert result.exit_code == 0
|
|
141
|
+
env_content = Path(".env.local").read_text()
|
|
142
|
+
assert "SWEATSTACK_CLIENT_ID=abc123" in env_content
|
|
143
|
+
|
|
144
|
+
def test_create_env_appends_to_existing(
|
|
145
|
+
self, mock_api: MagicMock, tmp_path: Path
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Should append to existing .env file."""
|
|
148
|
+
mock_api.post.return_value = {
|
|
149
|
+
"name": "Test App",
|
|
150
|
+
"client_id": "abc123",
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
with runner.isolated_filesystem(temp_dir=tmp_path):
|
|
154
|
+
Path(".env").write_text("EXISTING_VAR=value\n")
|
|
155
|
+
|
|
156
|
+
result = runner.invoke(app, ["app", "create", "Test App", "--env"])
|
|
157
|
+
|
|
158
|
+
assert result.exit_code == 0
|
|
159
|
+
env_content = Path(".env").read_text()
|
|
160
|
+
assert "EXISTING_VAR=value" in env_content
|
|
161
|
+
assert "SWEATSTACK_CLIENT_ID=abc123" in env_content
|
|
162
|
+
|
|
163
|
+
def test_create_env_no_overwrite(
|
|
164
|
+
self, mock_api: MagicMock, tmp_path: Path
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Should not overwrite existing env vars."""
|
|
167
|
+
mock_api.post.return_value = {
|
|
168
|
+
"name": "Test App",
|
|
169
|
+
"client_id": "abc123",
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
with runner.isolated_filesystem(temp_dir=tmp_path):
|
|
173
|
+
Path(".env").write_text("SWEATSTACK_CLIENT_ID=existing\n")
|
|
174
|
+
|
|
175
|
+
result = runner.invoke(app, ["app", "create", "Test App", "--env"])
|
|
176
|
+
|
|
177
|
+
assert result.exit_code == 0
|
|
178
|
+
|
|
179
|
+
# Verify the existing value was preserved
|
|
180
|
+
env_content = Path(".env").read_text()
|
|
181
|
+
assert "SWEATSTACK_CLIENT_ID=existing" in env_content
|
|
182
|
+
assert "abc123" not in env_content
|
|
183
|
+
|
|
184
|
+
def test_create_env_and_env_file_mutually_exclusive(self) -> None:
|
|
185
|
+
"""Should error when both --env and --env-file are used."""
|
|
186
|
+
result = runner.invoke(
|
|
187
|
+
app, ["app", "create", "Test App", "--env", "--env-file", ".env.local"]
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
assert result.exit_code == 4 # ValidationError
|
|
191
|
+
assert "mutually exclusive" in result.stdout
|
|
192
|
+
|
|
193
|
+
def test_create_not_authenticated(self) -> None:
|
|
194
|
+
"""Should error when not authenticated."""
|
|
195
|
+
with patch("sweatstack_cli.commands.app.APIClient") as mock_client:
|
|
196
|
+
mock_client.side_effect = AuthenticationError("Not authenticated")
|
|
197
|
+
|
|
198
|
+
result = runner.invoke(app, ["app", "create", "Test App"])
|
|
199
|
+
|
|
200
|
+
assert result.exit_code == 2
|
|
201
|
+
assert "Not authenticated" in result.stdout
|
|
202
|
+
assert "sweatstack login" in result.stdout
|
|
203
|
+
|
|
204
|
+
def test_create_api_error(self, mock_api: MagicMock) -> None:
|
|
205
|
+
"""Should handle API errors."""
|
|
206
|
+
mock_api.post.side_effect = APIError("App name already exists")
|
|
207
|
+
|
|
208
|
+
result = runner.invoke(app, ["app", "create", "Test App"])
|
|
209
|
+
|
|
210
|
+
assert result.exit_code == 3
|
|
211
|
+
assert "App name already exists" in result.stdout
|
|
212
|
+
|
|
213
|
+
def test_create_env_with_secret_no_warning_without_env(
|
|
214
|
+
self, mock_api: MagicMock
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Should show secret warning when --env is not used."""
|
|
217
|
+
mock_api.post.return_value = {
|
|
218
|
+
"name": "Test App",
|
|
219
|
+
"client_id": "abc123",
|
|
220
|
+
"client_secret": "secret456",
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
result = runner.invoke(app, ["app", "create", "Test App", "--secret"])
|
|
224
|
+
|
|
225
|
+
assert result.exit_code == 0
|
|
226
|
+
assert "won't be shown again" in result.stdout
|
|
227
|
+
|
|
228
|
+
def test_create_env_with_secret_no_warning_with_env(
|
|
229
|
+
self, mock_api: MagicMock, tmp_path: Path
|
|
230
|
+
) -> None:
|
|
231
|
+
"""Should not show secret warning when --env is used."""
|
|
232
|
+
mock_api.post.return_value = {
|
|
233
|
+
"name": "Test App",
|
|
234
|
+
"client_id": "abc123",
|
|
235
|
+
"client_secret": "secret456",
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
with runner.isolated_filesystem(temp_dir=tmp_path):
|
|
239
|
+
result = runner.invoke(app, ["app", "create", "Test App", "--secret", "--env"])
|
|
240
|
+
|
|
241
|
+
assert result.exit_code == 0
|
|
242
|
+
assert "won't be shown again" not in result.stdout
|
|
243
|
+
assert "written to" in result.stdout
|
|
@@ -21,7 +21,7 @@ class TestCLI:
|
|
|
21
21
|
assert "login" in result.stdout
|
|
22
22
|
assert "logout" in result.stdout
|
|
23
23
|
assert "whoami" in result.stdout
|
|
24
|
-
assert "
|
|
24
|
+
assert "page" in result.stdout
|
|
25
25
|
|
|
26
26
|
def test_version(self) -> None:
|
|
27
27
|
"""Should show version."""
|
|
@@ -46,20 +46,42 @@ class TestCLI:
|
|
|
46
46
|
assert "Usage:" in result.stdout
|
|
47
47
|
|
|
48
48
|
|
|
49
|
-
class
|
|
50
|
-
"""Tests for
|
|
49
|
+
class TestPageCommands:
|
|
50
|
+
"""Tests for page subcommands."""
|
|
51
51
|
|
|
52
|
-
def
|
|
53
|
-
"""Should show
|
|
54
|
-
result = runner.invoke(app, ["
|
|
52
|
+
def test_page_help(self) -> None:
|
|
53
|
+
"""Should show page help."""
|
|
54
|
+
result = runner.invoke(app, ["page", "--help"])
|
|
55
55
|
|
|
56
56
|
assert result.exit_code == 0
|
|
57
57
|
assert "deploy" in result.stdout
|
|
58
58
|
|
|
59
|
-
def
|
|
59
|
+
def test_page_deploy_help(self) -> None:
|
|
60
60
|
"""Should show deploy help."""
|
|
61
|
-
result = runner.invoke(app, ["
|
|
61
|
+
result = runner.invoke(app, ["page", "deploy", "--help"])
|
|
62
62
|
|
|
63
63
|
assert result.exit_code == 0
|
|
64
64
|
assert "SLUG" in result.stdout
|
|
65
65
|
assert "DIRECTORY" in result.stdout
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestAppCommands:
|
|
69
|
+
"""Tests for app subcommands."""
|
|
70
|
+
|
|
71
|
+
def test_app_help(self) -> None:
|
|
72
|
+
"""Should show app help."""
|
|
73
|
+
result = runner.invoke(app, ["app", "--help"])
|
|
74
|
+
|
|
75
|
+
assert result.exit_code == 0
|
|
76
|
+
assert "create" in result.stdout
|
|
77
|
+
|
|
78
|
+
def test_app_create_help(self) -> None:
|
|
79
|
+
"""Should show create help."""
|
|
80
|
+
result = runner.invoke(app, ["app", "create", "--help"])
|
|
81
|
+
|
|
82
|
+
assert result.exit_code == 0
|
|
83
|
+
assert "NAME" in result.stdout
|
|
84
|
+
assert "--page" in result.stdout
|
|
85
|
+
assert "--secret" in result.stdout
|
|
86
|
+
assert "--env" in result.stdout
|
|
87
|
+
assert "--json" in result.stdout
|
|
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
|