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.
Files changed (40) hide show
  1. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/.claude/settings.local.json +2 -1
  2. sweatstack_cli-0.2.0/CREATE_PRIVATE_APP.md +524 -0
  3. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/PKG-INFO +1 -1
  4. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/pyproject.toml +1 -1
  5. sweatstack_cli-0.2.0/src/sweatstack_cli/commands/app.py +139 -0
  6. sweatstack_cli-0.1.2/src/sweatstack_cli/commands/pages.py → sweatstack_cli-0.2.0/src/sweatstack_cli/commands/page.py +2 -2
  7. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/console.py +5 -0
  8. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/main.py +4 -2
  9. sweatstack_cli-0.2.0/tests/test_commands/test_app.py +243 -0
  10. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/tests/test_commands/test_cli.py +30 -8
  11. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/uv.lock +1 -1
  12. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/.gitignore +0 -0
  13. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/.python-version +0 -0
  14. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/DEVELOPMENT.md +0 -0
  15. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/Makefile +0 -0
  16. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/PLAN.md +0 -0
  17. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/README.md +0 -0
  18. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/__init__.py +0 -0
  19. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/api/__init__.py +0 -0
  20. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/api/client.py +0 -0
  21. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/auth/__init__.py +0 -0
  22. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/auth/callback_server.py +0 -0
  23. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/auth/jwt.py +0 -0
  24. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/auth/pkce.py +0 -0
  25. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/auth/tokens.py +0 -0
  26. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/commands/__init__.py +0 -0
  27. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/commands/auth.py +0 -0
  28. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/config.py +0 -0
  29. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/exceptions.py +0 -0
  30. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/src/sweatstack_cli/py.typed +0 -0
  31. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/tests/__init__.py +0 -0
  32. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/tests/conftest.py +0 -0
  33. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/tests/test_api/__init__.py +0 -0
  34. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/tests/test_api/test_client.py +0 -0
  35. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/tests/test_auth/__init__.py +0 -0
  36. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/tests/test_auth/test_callback_server.py +0 -0
  37. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/tests/test_auth/test_jwt.py +0 -0
  38. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/tests/test_auth/test_pkce.py +0 -0
  39. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/tests/test_auth/test_tokens.py +0 -0
  40. {sweatstack_cli-0.1.2 → sweatstack_cli-0.2.0}/tests/test_commands/__init__.py +0 -0
@@ -15,7 +15,8 @@
15
15
  "WebFetch(domain:localhost)",
16
16
  "Bash(curl:*)",
17
17
  "Bash(python3:*)",
18
- "Bash(make help:*)"
18
+ "Bash(make help:*)",
19
+ "Bash(tree:*)"
19
20
  ]
20
21
  }
21
22
  }
@@ -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.1.2
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sweatstack-cli"
3
- version = "0.1.2"
3
+ version = "0.2.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@goossens.me" }]
@@ -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
- """Pages commands: deploy."""
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="pages",
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 auth, pages
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(pages.app, name="pages")
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 "pages" in result.stdout
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 TestPagesCommands:
50
- """Tests for pages subcommands."""
49
+ class TestPageCommands:
50
+ """Tests for page subcommands."""
51
51
 
52
- def test_pages_help(self) -> None:
53
- """Should show pages help."""
54
- result = runner.invoke(app, ["pages", "--help"])
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 test_pages_deploy_help(self) -> None:
59
+ def test_page_deploy_help(self) -> None:
60
60
  """Should show deploy help."""
61
- result = runner.invoke(app, ["pages", "deploy", "--help"])
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
@@ -509,7 +509,7 @@ wheels = [
509
509
 
510
510
  [[package]]
511
511
  name = "sweatstack-cli"
512
- version = "0.1.1"
512
+ version = "0.1.2"
513
513
  source = { editable = "." }
514
514
  dependencies = [
515
515
  { name = "httpx" },
File without changes
File without changes
File without changes