sweatstack-cli 0.7.2__tar.gz → 0.9.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 (51) hide show
  1. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/.claude/settings.local.json +2 -1
  2. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/PKG-INFO +4 -4
  3. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/README.md +3 -3
  4. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/pyproject.toml +1 -1
  5. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/src/sweatstack_cli/__init__.py +1 -1
  6. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/src/sweatstack_cli/commands/app.py +49 -3
  7. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/src/sweatstack_cli/commands/page.py +57 -4
  8. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/src/sweatstack_cli/project.py +34 -0
  9. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/tests/test_commands/test_app.py +135 -26
  10. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/tests/test_commands/test_cli.py +2 -1
  11. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/uv.lock +1 -1
  12. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/.claude/skills/sweatstack/SKILL.md +0 -0
  13. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/.claude/skills/sweatstack/api.md +0 -0
  14. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/.claude/skills/sweatstack/auth.md +0 -0
  15. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/.claude/skills/sweatstack/cli.md +0 -0
  16. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/.claude/skills/sweatstack/pyscript.md +0 -0
  17. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/.claude/skills/sweatstack/webapp.md +0 -0
  18. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/.gitignore +0 -0
  19. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/.python-version +0 -0
  20. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/CREATE_PRIVATE_APP.md +0 -0
  21. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/DEVELOPMENT.md +0 -0
  22. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/Makefile +0 -0
  23. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/PLAN.md +0 -0
  24. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/src/sweatstack_cli/api/__init__.py +0 -0
  25. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/src/sweatstack_cli/api/client.py +0 -0
  26. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/src/sweatstack_cli/auth/__init__.py +0 -0
  27. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/src/sweatstack_cli/auth/callback_server.py +0 -0
  28. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/src/sweatstack_cli/auth/jwt.py +0 -0
  29. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/src/sweatstack_cli/auth/pkce.py +0 -0
  30. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/src/sweatstack_cli/auth/tokens.py +0 -0
  31. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/src/sweatstack_cli/commands/__init__.py +0 -0
  32. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/src/sweatstack_cli/commands/auth.py +0 -0
  33. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/src/sweatstack_cli/config.py +0 -0
  34. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/src/sweatstack_cli/console.py +0 -0
  35. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/src/sweatstack_cli/exceptions.py +0 -0
  36. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/src/sweatstack_cli/main.py +0 -0
  37. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/src/sweatstack_cli/py.typed +0 -0
  38. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/src/sweatstack_cli/version_check.py +0 -0
  39. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/sweatstack.toml +0 -0
  40. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/tests/__init__.py +0 -0
  41. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/tests/conftest.py +0 -0
  42. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/tests/test_api/__init__.py +0 -0
  43. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/tests/test_api/test_client.py +0 -0
  44. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/tests/test_auth/__init__.py +0 -0
  45. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/tests/test_auth/test_callback_server.py +0 -0
  46. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/tests/test_auth/test_jwt.py +0 -0
  47. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/tests/test_auth/test_pkce.py +0 -0
  48. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/tests/test_auth/test_tokens.py +0 -0
  49. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/tests/test_commands/__init__.py +0 -0
  50. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/tests/test_project.py +0 -0
  51. {sweatstack_cli-0.7.2 → sweatstack_cli-0.9.0}/tests/test_version_check.py +0 -0
@@ -19,7 +19,8 @@
19
19
  "Bash(tree:*)",
20
20
  "Bash(env:*)",
21
21
  "Bash(uv run python:*)",
22
- "Bash(git:*)"
22
+ "Bash(git:*)",
23
+ "Bash(grep:*)"
23
24
  ]
24
25
  }
25
26
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sweatstack-cli
3
- Version: 0.7.2
3
+ Version: 0.9.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
@@ -88,7 +88,7 @@ sweatstack page deploy
88
88
 
89
89
  | Command | Description |
90
90
  |---------|-------------|
91
- | `sweatstack app create NAME` | Create a new private OAuth2 application |
91
+ | `sweatstack app create [NAME]` | Create a new private OAuth2 application |
92
92
  | `sweatstack app link` | Link current directory to an existing application |
93
93
 
94
94
  #### `app create` options
@@ -130,8 +130,8 @@ Deploy static sites to SweatStack Pages.
130
130
  # Deploy using sweatstack.toml defaults (recommended)
131
131
  sweatstack page deploy
132
132
 
133
- # Or specify slug and directory directly
134
- sweatstack page deploy my-page ./dist
133
+ # Or specify slug and directory explicitly
134
+ sweatstack page deploy my-page --dir ./dist
135
135
  ```
136
136
 
137
137
  ## License
@@ -58,7 +58,7 @@ sweatstack page deploy
58
58
 
59
59
  | Command | Description |
60
60
  |---------|-------------|
61
- | `sweatstack app create NAME` | Create a new private OAuth2 application |
61
+ | `sweatstack app create [NAME]` | Create a new private OAuth2 application |
62
62
  | `sweatstack app link` | Link current directory to an existing application |
63
63
 
64
64
  #### `app create` options
@@ -100,8 +100,8 @@ Deploy static sites to SweatStack Pages.
100
100
  # Deploy using sweatstack.toml defaults (recommended)
101
101
  sweatstack page deploy
102
102
 
103
- # Or specify slug and directory directly
104
- sweatstack page deploy my-page ./dist
103
+ # Or specify slug and directory explicitly
104
+ sweatstack page deploy my-page --dir ./dist
105
105
  ```
106
106
 
107
107
  ## License
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sweatstack-cli"
3
- version = "0.7.2"
3
+ version = "0.9.0"
4
4
  description = "Command-line interface for SweatStack — the sports data platform for developers"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Aart Goossens", email = "aart@sweatstack.no" }]
@@ -1,3 +1,3 @@
1
1
  """SweatStack CLI — Command-line interface for the SweatStack platform."""
2
2
 
3
- __version__ = "0.7.2"
3
+ __version__ = "0.9.0"
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json as json_lib
6
+ import sys
6
7
  from pathlib import Path
7
8
  from typing import Any, cast
8
9
 
@@ -16,9 +17,14 @@ from sweatstack_cli.project import CONFIG_FILENAME, write_config
16
17
  app = typer.Typer(name="app", help="Manage OAuth2 applications.")
17
18
 
18
19
 
20
+ def _is_interactive() -> bool:
21
+ """Check if stdin is a TTY (interactive terminal)."""
22
+ return sys.stdin.isatty()
23
+
24
+
19
25
  @app.command()
20
26
  def create(
21
- name: str = typer.Argument(..., help="Application name."),
27
+ name: str | None = typer.Argument(None, help="Application name."),
22
28
  description: str | None = typer.Option(
23
29
  None,
24
30
  "--description",
@@ -56,7 +62,33 @@ def create(
56
62
  """Create a new private OAuth2 application."""
57
63
  if env and env_file:
58
64
  console.print("[red]Error:[/red] --env and --env-file are mutually exclusive.")
59
- raise typer.Exit(4)
65
+ raise typer.Exit(1)
66
+
67
+ interactive = _is_interactive()
68
+
69
+ # Resolve name
70
+ if name is None:
71
+ if not interactive:
72
+ console.print("[red]Error:[/red] App name is required.")
73
+ raise typer.Exit(1)
74
+ name = typer.prompt("App name")
75
+
76
+ # Prompt for optional fields in interactive mode
77
+ if interactive:
78
+ if description is None:
79
+ description = typer.prompt(
80
+ "Description (optional)", default="", show_default=False
81
+ )
82
+ if not description:
83
+ description = None
84
+ if page is None:
85
+ page = typer.prompt(
86
+ "Page slug (optional)", default="", show_default=False
87
+ )
88
+ if not page:
89
+ page = None
90
+ if not secret:
91
+ secret = typer.confirm("Generate client secret?", default=False)
60
92
 
61
93
  body: dict[str, str | bool] = {"name": name}
62
94
 
@@ -86,7 +118,18 @@ def create(
86
118
  if env_path:
87
119
  _write_env_file(env_path, result)
88
120
 
89
- # Write sweatstack.toml
121
+ # Write sweatstack.toml (protect existing file)
122
+ config_path = Path(CONFIG_FILENAME)
123
+ if config_path.exists():
124
+ if not interactive:
125
+ console.print(
126
+ f"[red]Error:[/red] {CONFIG_FILENAME} already exists. "
127
+ "Remove it first or run interactively."
128
+ )
129
+ raise typer.Exit(1)
130
+ console.print(f"[yellow]Warning:[/yellow] {CONFIG_FILENAME} already exists.")
131
+ typer.confirm("Overwrite?", abort=True)
132
+
90
133
  config_path = write_config(
91
134
  app_name=result["name"],
92
135
  client_id=result["id"],
@@ -206,6 +249,9 @@ def link(
206
249
  console.print(f" [cyan][{i}][/cyan] {app_info['name']}{page_info}")
207
250
 
208
251
  console.print()
252
+ if not sys.stdin.isatty():
253
+ console.print("[red]Error:[/red] Cannot select application in non-interactive mode.")
254
+ raise typer.Exit(1)
209
255
  selection = typer.prompt("Select application", type=int)
210
256
 
211
257
  if selection < 1 or selection > len(apps):
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import mimetypes
6
+ import sys
6
7
  from pathlib import Path
7
8
 
8
9
  import typer
@@ -12,7 +13,7 @@ from sweatstack_cli.api import APIClient
12
13
  from sweatstack_cli.config import get_settings
13
14
  from sweatstack_cli.console import console
14
15
  from sweatstack_cli.exceptions import APIError, AuthenticationError
15
- from sweatstack_cli.project import CONFIG_FILENAME, ProjectConfig
16
+ from sweatstack_cli.project import CONFIG_FILENAME, ProjectConfig, update_config, write_config
16
17
 
17
18
  # URL where users can create pages for their applications
18
19
  SETTINGS_URL = "https://app.sweatstack.no/settings/api"
@@ -59,12 +60,14 @@ def deploy(
59
60
  None,
60
61
  help="Page slug. Reads from sweatstack.toml if not provided.",
61
62
  ),
62
- directory: Path | None = typer.Argument(
63
+ directory: Path | None = typer.Option(
63
64
  None,
64
- help="Directory to deploy. Reads from sweatstack.toml or defaults to current dir.",
65
+ "--dir",
66
+ help="Directory to deploy. Reads from sweatstack.toml if not provided.",
65
67
  ),
66
68
  ) -> None:
67
69
  """Deploy a static site to SweatStack Pages."""
70
+
68
71
  # Load config for defaults
69
72
  config = ProjectConfig.load()
70
73
 
@@ -76,9 +79,19 @@ def deploy(
76
79
  raise typer.Exit(1)
77
80
 
78
81
  # Resolve directory
82
+ directory_explicit = directory is not None
79
83
  if directory is None:
80
84
  dir_from_config = config.page.directory if config else None
81
- directory = Path(dir_from_config) if dir_from_config else Path(".")
85
+ if dir_from_config:
86
+ directory = Path(dir_from_config)
87
+ else:
88
+ if not sys.stdin.isatty():
89
+ console.print("[red]Error:[/red] No deploy directory specified.")
90
+ console.print("Use --dir or set it in sweatstack.toml.")
91
+ raise typer.Exit(1)
92
+ dir_input = typer.prompt("Deploy directory", default="public/")
93
+ directory = Path(dir_input)
94
+ directory_explicit = True
82
95
 
83
96
  if not directory.exists():
84
97
  console.print(f"[red]Error:[/red] Directory '{directory}' does not exist.")
@@ -90,6 +103,32 @@ def deploy(
90
103
 
91
104
  directory = directory.resolve()
92
105
 
106
+ # Compute relative directory for config and warnings
107
+ try:
108
+ rel_dir = str(directory.relative_to(Path.cwd()))
109
+ except ValueError:
110
+ rel_dir = str(directory)
111
+
112
+ # Warn and confirm if deploying with different values than toml
113
+ if config:
114
+ mismatches: list[str] = []
115
+ if config.page.slug and slug and slug != config.page.slug:
116
+ mismatches.append(
117
+ f"Deploying to '{slug}' but "
118
+ f"{CONFIG_FILENAME} has slug '{config.page.slug}'"
119
+ )
120
+ if config.page.directory and rel_dir != config.page.directory:
121
+ mismatches.append(
122
+ f"Deploying '{rel_dir}' but "
123
+ f"{CONFIG_FILENAME} has directory '{config.page.directory}'"
124
+ )
125
+ if mismatches:
126
+ for msg in mismatches:
127
+ console.print(f"[yellow]Warning:[/yellow] {msg}")
128
+ if not sys.stdin.isatty():
129
+ raise typer.Exit(1)
130
+ typer.confirm("Continue?", abort=True)
131
+
93
132
  # Collect files
94
133
  files = _collect_files(directory)
95
134
  file_count = len(files)
@@ -143,6 +182,20 @@ def deploy(
143
182
  console.print(f"[red]Error:[/red] {e}")
144
183
  raise typer.Exit(3)
145
184
 
185
+ # Save to config after successful deploy
186
+ if config:
187
+ update_kwargs: dict[str, str] = {}
188
+ if not config.page.slug:
189
+ update_kwargs["page_slug"] = page_slug
190
+ if not config.page.directory and directory_explicit:
191
+ update_kwargs["page_directory"] = rel_dir
192
+ if update_kwargs:
193
+ update_config(**update_kwargs)
194
+ console.print(f"[green]✓[/green] Saved to [bold]{CONFIG_FILENAME}[/bold]")
195
+ elif directory_explicit:
196
+ write_config(page_slug=page_slug, page_directory=rel_dir)
197
+ console.print(f"[green]✓[/green] Created [bold]{CONFIG_FILENAME}[/bold]")
198
+
146
199
  console.print()
147
200
  console.print(f"[green]✓[/green] Deployed [bold]{page_slug}[/bold] successfully!")
148
201
  console.print(f" Files: {file_count}")
@@ -109,3 +109,37 @@ def write_config(
109
109
  config_path.write_text("\n".join(lines), encoding="utf-8")
110
110
 
111
111
  return config_path
112
+
113
+
114
+ def update_config(
115
+ path: Path | None = None,
116
+ *,
117
+ page_slug: str | None = None,
118
+ page_directory: str | None = None,
119
+ ) -> Path | None:
120
+ """Update specific fields in an existing sweatstack.toml without overwriting other values."""
121
+ config_path = path or Path(CONFIG_FILENAME)
122
+
123
+ if not config_path.exists():
124
+ return None
125
+
126
+ try:
127
+ data = tomllib.loads(config_path.read_text(encoding="utf-8"))
128
+ except tomllib.TOMLDecodeError:
129
+ return None
130
+
131
+ app_data = data.get("app", {})
132
+ page_data = data.get("page", {})
133
+
134
+ if page_slug is not None:
135
+ page_data["slug"] = page_slug
136
+ if page_directory is not None:
137
+ page_data["directory"] = page_directory
138
+
139
+ return write_config(
140
+ path=config_path,
141
+ app_name=app_data.get("name"),
142
+ client_id=app_data.get("client_id"),
143
+ page_slug=page_data.get("slug"),
144
+ page_directory=page_data.get("directory"),
145
+ )
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from pathlib import Path
6
- from unittest.mock import MagicMock, patch
6
+ from unittest.mock import MagicMock, PropertyMock, patch
7
7
 
8
8
  import pytest
9
9
  from typer.testing import CliRunner
@@ -26,14 +26,15 @@ def mock_api():
26
26
  class TestAppCreate:
27
27
  """Tests for app create command."""
28
28
 
29
- def test_create_minimal(self, mock_api: MagicMock) -> None:
29
+ def test_create_minimal(self, mock_api: MagicMock, tmp_path: Path) -> None:
30
30
  """Should create an app with just a name."""
31
31
  mock_api.post.return_value = {
32
32
  "name": "Test App",
33
- "client_id": "abc123",
33
+ "id": "abc123",
34
34
  }
35
35
 
36
- result = runner.invoke(app, ["app", "create", "Test App"])
36
+ with runner.isolated_filesystem(temp_dir=tmp_path):
37
+ result = runner.invoke(app, ["app", "create", "Test App"])
37
38
 
38
39
  assert result.exit_code == 0
39
40
  assert "Test App" in result.stdout
@@ -43,15 +44,16 @@ class TestAppCreate:
43
44
  json={"name": "Test App"},
44
45
  )
45
46
 
46
- def test_create_with_page(self, mock_api: MagicMock) -> None:
47
+ def test_create_with_page(self, mock_api: MagicMock, tmp_path: Path) -> None:
47
48
  """Should create an app with page association."""
48
49
  mock_api.post.return_value = {
49
50
  "name": "Test App",
50
- "client_id": "abc123",
51
+ "id": "abc123",
51
52
  "redirect_uris": ["https://myapp.sweatstack.pages.dev/callback"],
52
53
  }
53
54
 
54
- result = runner.invoke(app, ["app", "create", "Test App", "--page", "myapp"])
55
+ with runner.isolated_filesystem(temp_dir=tmp_path):
56
+ result = runner.invoke(app, ["app", "create", "Test App", "--page", "myapp"])
55
57
 
56
58
  assert result.exit_code == 0
57
59
  assert "myapp.sweatstack.pages.dev" in result.stdout
@@ -60,15 +62,16 @@ class TestAppCreate:
60
62
  json={"name": "Test App", "page": "myapp"},
61
63
  )
62
64
 
63
- def test_create_with_secret(self, mock_api: MagicMock) -> None:
65
+ def test_create_with_secret(self, mock_api: MagicMock, tmp_path: Path) -> None:
64
66
  """Should create an app with client secret and show warning."""
65
67
  mock_api.post.return_value = {
66
68
  "name": "Test App",
67
- "client_id": "abc123",
69
+ "id": "abc123",
68
70
  "client_secret": "secret456",
69
71
  }
70
72
 
71
- result = runner.invoke(app, ["app", "create", "Test App", "--secret"])
73
+ with runner.isolated_filesystem(temp_dir=tmp_path):
74
+ result = runner.invoke(app, ["app", "create", "Test App", "--secret"])
72
75
 
73
76
  assert result.exit_code == 0
74
77
  assert "secret456" in result.stdout
@@ -78,15 +81,16 @@ class TestAppCreate:
78
81
  json={"name": "Test App", "generate_secret": True},
79
82
  )
80
83
 
81
- def test_create_with_short_flags(self, mock_api: MagicMock) -> None:
84
+ def test_create_with_short_flags(self, mock_api: MagicMock, tmp_path: Path) -> None:
82
85
  """Should support short flags -p and -s."""
83
86
  mock_api.post.return_value = {
84
87
  "name": "Test App",
85
- "client_id": "abc123",
88
+ "id": "abc123",
86
89
  "client_secret": "secret456",
87
90
  }
88
91
 
89
- result = runner.invoke(app, ["app", "create", "Test App", "-p", "myapp", "-s"])
92
+ with runner.isolated_filesystem(temp_dir=tmp_path):
93
+ result = runner.invoke(app, ["app", "create", "Test App", "-p", "myapp", "-s"])
90
94
 
91
95
  assert result.exit_code == 0
92
96
  mock_api.post.assert_called_once_with(
@@ -94,24 +98,25 @@ class TestAppCreate:
94
98
  json={"name": "Test App", "page": "myapp", "generate_secret": True},
95
99
  )
96
100
 
97
- def test_create_json_output(self, mock_api: MagicMock) -> None:
101
+ def test_create_json_output(self, mock_api: MagicMock, tmp_path: Path) -> None:
98
102
  """Should output JSON when --json flag is used."""
99
103
  mock_api.post.return_value = {
100
104
  "name": "Test App",
101
- "client_id": "abc123",
105
+ "id": "abc123",
102
106
  }
103
107
 
104
- result = runner.invoke(app, ["app", "create", "Test App", "--json"])
108
+ with runner.isolated_filesystem(temp_dir=tmp_path):
109
+ result = runner.invoke(app, ["app", "create", "Test App", "--json"])
105
110
 
106
111
  assert result.exit_code == 0
107
- assert '"client_id": "abc123"' in result.stdout
112
+ assert '"id": "abc123"' in result.stdout
108
113
  assert '"name": "Test App"' in result.stdout
109
114
 
110
115
  def test_create_with_env(self, mock_api: MagicMock, tmp_path: Path) -> None:
111
116
  """Should write credentials to .env file."""
112
117
  mock_api.post.return_value = {
113
118
  "name": "Test App",
114
- "client_id": "abc123",
119
+ "id": "abc123",
115
120
  "client_secret": "secret456",
116
121
  }
117
122
 
@@ -129,7 +134,7 @@ class TestAppCreate:
129
134
  """Should write credentials to custom env file."""
130
135
  mock_api.post.return_value = {
131
136
  "name": "Test App",
132
- "client_id": "abc123",
137
+ "id": "abc123",
133
138
  }
134
139
 
135
140
  with runner.isolated_filesystem(temp_dir=tmp_path):
@@ -147,7 +152,7 @@ class TestAppCreate:
147
152
  """Should append to existing .env file."""
148
153
  mock_api.post.return_value = {
149
154
  "name": "Test App",
150
- "client_id": "abc123",
155
+ "id": "abc123",
151
156
  }
152
157
 
153
158
  with runner.isolated_filesystem(temp_dir=tmp_path):
@@ -166,7 +171,7 @@ class TestAppCreate:
166
171
  """Should not overwrite existing env vars."""
167
172
  mock_api.post.return_value = {
168
173
  "name": "Test App",
169
- "client_id": "abc123",
174
+ "id": "abc123",
170
175
  }
171
176
 
172
177
  with runner.isolated_filesystem(temp_dir=tmp_path):
@@ -187,7 +192,7 @@ class TestAppCreate:
187
192
  app, ["app", "create", "Test App", "--env", "--env-file", ".env.local"]
188
193
  )
189
194
 
190
- assert result.exit_code == 4 # ValidationError
195
+ assert result.exit_code == 1
191
196
  assert "mutually exclusive" in result.stdout
192
197
 
193
198
  def test_create_not_authenticated(self) -> None:
@@ -211,16 +216,17 @@ class TestAppCreate:
211
216
  assert "App name already exists" in result.stdout
212
217
 
213
218
  def test_create_env_with_secret_no_warning_without_env(
214
- self, mock_api: MagicMock
219
+ self, mock_api: MagicMock, tmp_path: Path
215
220
  ) -> None:
216
221
  """Should show secret warning when --env is not used."""
217
222
  mock_api.post.return_value = {
218
223
  "name": "Test App",
219
- "client_id": "abc123",
224
+ "id": "abc123",
220
225
  "client_secret": "secret456",
221
226
  }
222
227
 
223
- result = runner.invoke(app, ["app", "create", "Test App", "--secret"])
228
+ with runner.isolated_filesystem(temp_dir=tmp_path):
229
+ result = runner.invoke(app, ["app", "create", "Test App", "--secret"])
224
230
 
225
231
  assert result.exit_code == 0
226
232
  assert "won't be shown again" in result.stdout
@@ -231,7 +237,7 @@ class TestAppCreate:
231
237
  """Should not show secret warning when --env is used."""
232
238
  mock_api.post.return_value = {
233
239
  "name": "Test App",
234
- "client_id": "abc123",
240
+ "id": "abc123",
235
241
  "client_secret": "secret456",
236
242
  }
237
243
 
@@ -241,3 +247,106 @@ class TestAppCreate:
241
247
  assert result.exit_code == 0
242
248
  assert "won't be shown again" not in result.stdout
243
249
  assert "written to" in result.stdout
250
+
251
+ def test_create_noninteractive_requires_name(self) -> None:
252
+ """Should error when name is missing in non-interactive mode."""
253
+ result = runner.invoke(app, ["app", "create"])
254
+
255
+ assert result.exit_code == 1
256
+ assert "App name is required" in result.stdout
257
+
258
+ def test_create_interactive_full(self, mock_api: MagicMock, tmp_path: Path) -> None:
259
+ """Should prompt for all fields when no args given interactively."""
260
+ mock_api.post.return_value = {"name": "My App", "id": "abc123"}
261
+
262
+ with patch("sweatstack_cli.commands.app._is_interactive", return_value=True):
263
+ with runner.isolated_filesystem(temp_dir=tmp_path):
264
+ result = runner.invoke(
265
+ app, ["app", "create"], input="My App\n\n\nn\n"
266
+ )
267
+
268
+ assert result.exit_code == 0
269
+ assert "My App" in result.stdout
270
+ mock_api.post.assert_called_once_with(
271
+ "/api/v1/applications",
272
+ json={"name": "My App"},
273
+ )
274
+
275
+ def test_create_interactive_with_values(
276
+ self, mock_api: MagicMock, tmp_path: Path
277
+ ) -> None:
278
+ """Should send all interactively-provided values to the API."""
279
+ mock_api.post.return_value = {
280
+ "name": "My App",
281
+ "id": "abc123",
282
+ "client_secret": "secret456",
283
+ }
284
+
285
+ with patch("sweatstack_cli.commands.app._is_interactive", return_value=True):
286
+ with runner.isolated_filesystem(temp_dir=tmp_path):
287
+ result = runner.invoke(
288
+ app,
289
+ ["app", "create"],
290
+ input="My App\nA cool app\nmy-page\ny\n",
291
+ )
292
+
293
+ assert result.exit_code == 0
294
+ mock_api.post.assert_called_once_with(
295
+ "/api/v1/applications",
296
+ json={
297
+ "name": "My App",
298
+ "description": "A cool app",
299
+ "page": "my-page",
300
+ "generate_secret": True,
301
+ },
302
+ )
303
+
304
+ def test_create_interactive_partial(
305
+ self, mock_api: MagicMock, tmp_path: Path
306
+ ) -> None:
307
+ """Should only prompt for fields not provided via args/flags."""
308
+ mock_api.post.return_value = {"name": "My App", "id": "abc123"}
309
+
310
+ with patch("sweatstack_cli.commands.app._is_interactive", return_value=True):
311
+ with runner.isolated_filesystem(temp_dir=tmp_path):
312
+ result = runner.invoke(
313
+ app, ["app", "create", "My App"], input="\n\nn\n"
314
+ )
315
+
316
+ assert result.exit_code == 0
317
+ assert "App name" not in result.stdout
318
+ mock_api.post.assert_called_once_with(
319
+ "/api/v1/applications",
320
+ json={"name": "My App"},
321
+ )
322
+
323
+ def test_create_interactive_skips_provided_flags(
324
+ self, mock_api: MagicMock, tmp_path: Path
325
+ ) -> None:
326
+ """Should skip prompts for fields already provided via flags."""
327
+ mock_api.post.return_value = {
328
+ "name": "Test App",
329
+ "id": "abc123",
330
+ "client_secret": "secret456",
331
+ }
332
+
333
+ with patch("sweatstack_cli.commands.app._is_interactive", return_value=True):
334
+ with runner.isolated_filesystem(temp_dir=tmp_path):
335
+ # --secret and --description provided; only page is prompted
336
+ result = runner.invoke(
337
+ app,
338
+ ["app", "create", "Test App", "--secret", "-d", "Desc"],
339
+ input="\n",
340
+ )
341
+
342
+ assert result.exit_code == 0
343
+ assert "Generate client secret?" not in result.stdout
344
+ assert "Description" not in result.stdout
345
+ mock_api.post.assert_called_once_with(
346
+ "/api/v1/applications",
347
+ json={
348
+ "name": "Test App",
349
+ "description": "Desc",
350
+ "generate_secret": True,
351
+ },
352
+ )
@@ -62,7 +62,8 @@ class TestPageCommands:
62
62
 
63
63
  assert result.exit_code == 0
64
64
  assert "SLUG" in result.stdout
65
- assert "DIRECTORY" in result.stdout
65
+ assert "--dir" in result.stdout
66
+ assert "-d," not in result.stdout
66
67
 
67
68
 
68
69
  class TestAppCommands:
@@ -463,7 +463,7 @@ wheels = [
463
463
 
464
464
  [[package]]
465
465
  name = "sweatstack-cli"
466
- version = "0.6.0"
466
+ version = "0.8.0"
467
467
  source = { editable = "." }
468
468
  dependencies = [
469
469
  { name = "httpx" },
File without changes
File without changes