minitest-cli 0.16.0__tar.gz → 0.16.2__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 (103) hide show
  1. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/PKG-INFO +1 -1
  2. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/pyproject.toml +1 -1
  3. minitest_cli-0.16.2/renovate.json +4 -0
  4. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/api/apps_manager_client.py +8 -0
  5. minitest_cli-0.16.2/src/minitest_cli/commands/env.py +198 -0
  6. minitest_cli-0.16.2/src/minitest_cli/commands/env_helpers.py +140 -0
  7. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/core/auth.py +27 -7
  8. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/core/credentials.py +1 -0
  9. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/core/oauth.py +28 -20
  10. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/core/token_exchange.py +17 -2
  11. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/main.py +2 -0
  12. minitest_cli-0.16.2/src/minitest_cli/models/app_env_vars.py +19 -0
  13. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_auth.py +70 -9
  14. minitest_cli-0.16.2/tests/test_env.py +263 -0
  15. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/uv.lock +1 -1
  16. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/.env.example +0 -0
  17. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/.github/workflows/ci.yml +0 -0
  18. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/.github/workflows/install-scripts.yml +0 -0
  19. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/.github/workflows/release.yml +0 -0
  20. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/.gitignore +0 -0
  21. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/.opencode/skill/release/SKILL.md +0 -0
  22. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/AGENTS.md +0 -0
  23. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/README.md +0 -0
  24. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/RELEASE.md +0 -0
  25. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/install.ps1 +0 -0
  26. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/install.sh +0 -0
  27. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/pyrightconfig.json +0 -0
  28. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/__init__.py +0 -0
  29. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/api/__init__.py +0 -0
  30. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/api/client.py +0 -0
  31. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/assets/__init__.py +0 -0
  32. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/assets/callback.html +0 -0
  33. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/__init__.py +0 -0
  34. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/app_knowledge.py +0 -0
  35. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/app_knowledge_helpers.py +0 -0
  36. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/apps.py +0 -0
  37. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/apps_dependencies.py +0 -0
  38. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/apps_helpers.py +0 -0
  39. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/auth.py +0 -0
  40. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/auth_api_key.py +0 -0
  41. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/batch.py +0 -0
  42. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/batch_helpers.py +0 -0
  43. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/build.py +0 -0
  44. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/build_helpers.py +0 -0
  45. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/flow_types.py +0 -0
  46. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/init.py +0 -0
  47. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/init_playbook.py +0 -0
  48. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/run.py +0 -0
  49. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/run_display.py +0 -0
  50. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/run_helpers.py +0 -0
  51. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/run_targets.py +0 -0
  52. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/skill.py +0 -0
  53. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/test_file.py +0 -0
  54. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/test_file_helpers.py +0 -0
  55. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/test_file_list.py +0 -0
  56. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/test_profile.py +0 -0
  57. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/test_profile_default.py +0 -0
  58. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/test_profile_helpers.py +0 -0
  59. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/test_profile_list.py +0 -0
  60. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/upgrade.py +0 -0
  61. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/user_story.py +0 -0
  62. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/user_story_bindings.py +0 -0
  63. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/user_story_criteria.py +0 -0
  64. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/user_story_helpers.py +0 -0
  65. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/user_story_modify.py +0 -0
  66. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/user_story_profiles.py +0 -0
  67. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/core/__init__.py +0 -0
  68. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/core/app_context.py +0 -0
  69. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/core/config.py +0 -0
  70. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/core/tenants.py +0 -0
  71. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/models/__init__.py +0 -0
  72. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/models/app.py +0 -0
  73. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/models/base.py +0 -0
  74. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/models/batch.py +0 -0
  75. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/models/build.py +0 -0
  76. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/models/story_run.py +0 -0
  77. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/models/targets.py +0 -0
  78. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/models/user_story.py +0 -0
  79. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/utils/__init__.py +0 -0
  80. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/utils/mermaid.py +0 -0
  81. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/utils/output.py +0 -0
  82. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/utils/skill_refresh.py +0 -0
  83. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/utils/update_check.py +0 -0
  84. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/__init__.py +0 -0
  85. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_app_knowledge_commands.py +0 -0
  86. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_apps_commands.py +0 -0
  87. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_apps_dependencies.py +0 -0
  88. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_auth_api_key.py +0 -0
  89. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_auth_commands.py +0 -0
  90. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_batch_commands.py +0 -0
  91. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_build_commands.py +0 -0
  92. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_code_quality.py +0 -0
  93. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_flow_types_commands.py +0 -0
  94. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_init_command.py +0 -0
  95. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_mermaid.py +0 -0
  96. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_run_commands.py +0 -0
  97. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_skill_command.py +0 -0
  98. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_test_file_commands.py +0 -0
  99. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_test_profile_commands.py +0 -0
  100. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_upgrade_command.py +0 -0
  101. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_user_story_bindings_commands.py +0 -0
  102. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_user_story_commands.py +0 -0
  103. {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: minitest-cli
3
- Version: 0.16.0
3
+ Version: 0.16.2
4
4
  Summary: Minitest CLI – command-line interface for the Minitest testing platform
5
5
  Project-URL: Homepage, https://minitap.ai/
6
6
  Project-URL: Source, https://github.com/minitap-ai/minitest-cli
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "minitest-cli"
3
- version = "0.16.0"
3
+ version = "0.16.2"
4
4
  description = "Minitest CLI – command-line interface for the Minitest testing platform"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,4 @@
1
+ {
2
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3
+ "extends": ["github>minitap-ai/renovate-config"]
4
+ }
@@ -70,6 +70,14 @@ class AppsManagerClient:
70
70
  """Send a POST request."""
71
71
  return await self._ensure_client().post(path, **kwargs)
72
72
 
73
+ async def put(self, path: str, **kwargs: Any) -> httpx.Response:
74
+ """Send a PUT request."""
75
+ return await self._ensure_client().put(path, **kwargs)
76
+
77
+ async def delete(self, path: str, **kwargs: Any) -> httpx.Response:
78
+ """Send a DELETE request."""
79
+ return await self._ensure_client().delete(path, **kwargs)
80
+
73
81
  async def upload_form(
74
82
  self,
75
83
  path: str,
@@ -0,0 +1,198 @@
1
+ """App environment-variable commands: list, get, set, unset, clear.
2
+
3
+ Values are secrets: ``list`` masks them by default (reveal with ``--show``),
4
+ and ``get`` prints a single value verbatim to stdout on purpose. Mutations
5
+ (``set``/``unset``/``clear``) require ``--yes`` so they never run unconfirmed.
6
+ """
7
+
8
+ import asyncio
9
+ from collections.abc import Coroutine
10
+ from typing import Annotated, Any
11
+
12
+ import httpx
13
+ import typer
14
+ from rich.markup import escape
15
+
16
+ from minitest_cli.commands.env_helpers import (
17
+ MASK,
18
+ confirm_or_exit,
19
+ delete_env_vars,
20
+ diff_keys,
21
+ fetch_env_vars,
22
+ print_diff,
23
+ put_env_vars,
24
+ resolve_app_and_tenant,
25
+ )
26
+ from minitest_cli.core.auth import require_auth
27
+ from minitest_cli.core.config import Settings
28
+ from minitest_cli.utils.output import (
29
+ print_error,
30
+ print_json,
31
+ print_success,
32
+ print_table,
33
+ )
34
+
35
+ EXIT_NETWORK_ERROR = 3
36
+ EXIT_NOT_FOUND = 4
37
+
38
+ app = typer.Typer(name="env", help="Manage an app's environment variables.")
39
+
40
+
41
+ @app.callback()
42
+ def _callback() -> None:
43
+ """Manage an app's environment variables."""
44
+
45
+
46
+ def _get_settings() -> Settings:
47
+ return typer.Context.settings # type: ignore[attr-defined]
48
+
49
+
50
+ def _is_json_mode() -> bool:
51
+ return typer.Context.json_mode # type: ignore[attr-defined]
52
+
53
+
54
+ def _get_app_flag() -> str | None:
55
+ return typer.Context.app_flag # type: ignore[attr-defined]
56
+
57
+
58
+ def _run[T](coro: Coroutine[Any, Any, T]) -> T:
59
+ try:
60
+ return asyncio.run(coro)
61
+ except httpx.HTTPError as exc:
62
+ print_error(f"Network error: {exc}")
63
+ raise typer.Exit(code=EXIT_NETWORK_ERROR) from exc
64
+
65
+
66
+ def _context() -> tuple[Settings, str, str, bool]:
67
+ settings = _get_settings()
68
+ require_auth(settings)
69
+ app_id, tenant_id = _run(resolve_app_and_tenant(settings, _get_app_flag()))
70
+ return settings, app_id, tenant_id, _is_json_mode()
71
+
72
+
73
+ @app.command(name="list")
74
+ def list_env(
75
+ show: Annotated[
76
+ bool, typer.Option("--show", help="Reveal values instead of masking them.")
77
+ ] = False,
78
+ ) -> None:
79
+ """List the app's environment variables (values masked unless --show)."""
80
+ settings, app_id, tenant_id, json_mode = _context()
81
+ env_vars = _run(fetch_env_vars(settings, tenant_id, app_id))
82
+
83
+ rendered = env_vars if show else {k: MASK for k in env_vars}
84
+ if json_mode:
85
+ print_json(rendered)
86
+ return
87
+ if not env_vars:
88
+ print_success("No environment variables configured for this app.")
89
+ return
90
+ rows = [[escape(k), escape(rendered[k])] for k in sorted(rendered)]
91
+ print_table(["Key", "Value"], rows, title="Environment variables")
92
+
93
+
94
+ @app.command(name="get")
95
+ def get_env(key: Annotated[str, typer.Argument(help="Environment variable name.")]) -> None:
96
+ """Print a single environment variable's value verbatim to stdout."""
97
+ settings, app_id, tenant_id, json_mode = _context()
98
+ env_vars = _run(fetch_env_vars(settings, tenant_id, app_id))
99
+
100
+ if key not in env_vars:
101
+ print_error(f"Environment variable not found: '{key}'.")
102
+ raise typer.Exit(code=EXIT_NOT_FOUND)
103
+
104
+ if json_mode:
105
+ print_json({key: env_vars[key]})
106
+ return
107
+ print(env_vars[key]) # noqa: T201
108
+
109
+
110
+ @app.command(name="set")
111
+ def set_env(
112
+ key: Annotated[str, typer.Argument(help="Environment variable name.")],
113
+ value: Annotated[str, typer.Argument(help="Value to set.")],
114
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Confirm the change.")] = False,
115
+ dry_run: Annotated[
116
+ bool, typer.Option("--dry-run", help="Show the change without applying it.")
117
+ ] = False,
118
+ ) -> None:
119
+ """Set an environment variable (creates or updates), preserving the others."""
120
+ settings, app_id, tenant_id, json_mode = _context()
121
+ current = _run(fetch_env_vars(settings, tenant_id, app_id))
122
+ updated = {**current, key: value}
123
+
124
+ _apply(settings, tenant_id, app_id, current, updated, yes, dry_run, json_mode, f"Set '{key}'")
125
+
126
+
127
+ @app.command(name="unset")
128
+ def unset_env(
129
+ key: Annotated[str, typer.Argument(help="Environment variable name.")],
130
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Confirm the change.")] = False,
131
+ dry_run: Annotated[
132
+ bool, typer.Option("--dry-run", help="Show the change without applying it.")
133
+ ] = False,
134
+ ) -> None:
135
+ """Remove a single environment variable, preserving the others."""
136
+ settings, app_id, tenant_id, json_mode = _context()
137
+ current = _run(fetch_env_vars(settings, tenant_id, app_id))
138
+ if key not in current:
139
+ print_error(f"Environment variable not found: '{key}'.")
140
+ raise typer.Exit(code=EXIT_NOT_FOUND)
141
+ updated = {k: v for k, v in current.items() if k != key}
142
+
143
+ _apply(settings, tenant_id, app_id, current, updated, yes, dry_run, json_mode, f"Unset '{key}'")
144
+
145
+
146
+ @app.command(name="clear")
147
+ def clear_env(
148
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Confirm the deletion.")] = False,
149
+ dry_run: Annotated[
150
+ bool, typer.Option("--dry-run", help="Show the change without applying it.")
151
+ ] = False,
152
+ ) -> None:
153
+ """Delete ALL environment variables for the app."""
154
+ settings, app_id, tenant_id, json_mode = _context()
155
+ current = _run(fetch_env_vars(settings, tenant_id, app_id))
156
+ if not current:
157
+ print_error("No environment variables to delete.")
158
+ raise typer.Exit(code=EXIT_NOT_FOUND)
159
+
160
+ _, _, removed = diff_keys(current, {})
161
+ if dry_run:
162
+ print_diff([], [], removed)
163
+ if json_mode:
164
+ print_json({"added": [], "changed": [], "removed": removed, "dryRun": True})
165
+ return
166
+
167
+ confirm_or_exit(yes, f"Deleting all {len(current)} environment variables")
168
+ _run(delete_env_vars(settings, tenant_id, app_id))
169
+ if json_mode:
170
+ print_json({"added": [], "changed": [], "removed": removed})
171
+ return
172
+ print_success(f"Deleted all {len(removed)} environment variables.")
173
+
174
+
175
+ def _apply(
176
+ settings: Settings,
177
+ tenant_id: str,
178
+ app_id: str,
179
+ current: dict[str, str],
180
+ updated: dict[str, str],
181
+ yes: bool,
182
+ dry_run: bool,
183
+ json_mode: bool,
184
+ action: str,
185
+ ) -> None:
186
+ added, changed, removed = diff_keys(current, updated)
187
+ if dry_run:
188
+ print_diff(added, changed, removed)
189
+ if json_mode:
190
+ print_json({"added": added, "changed": changed, "removed": removed, "dryRun": True})
191
+ return
192
+
193
+ confirm_or_exit(yes, action)
194
+ _run(put_env_vars(settings, tenant_id, app_id, updated))
195
+ if json_mode:
196
+ print_json({"added": added, "changed": changed, "removed": removed})
197
+ return
198
+ print_success(f"{action} — {len(updated)} environment variables now set.")
@@ -0,0 +1,140 @@
1
+ """Helpers for env-var commands: app/tenant resolution, HTTP, confirmation."""
2
+
3
+ import sys
4
+
5
+ import httpx
6
+ import typer
7
+
8
+ from minitest_cli.api.apps_manager_client import AppsManagerClient
9
+ from minitest_cli.api.client import ApiClient
10
+ from minitest_cli.core.config import Settings
11
+ from minitest_cli.models.app import AppListResponse
12
+ from minitest_cli.models.app_env_vars import AppEnvVarsResponse
13
+ from minitest_cli.utils.output import print_error
14
+
15
+ EXIT_GENERAL_ERROR = 1
16
+ EXIT_NETWORK_ERROR = 3
17
+ EXIT_NOT_FOUND = 4
18
+
19
+ MASK = "********"
20
+
21
+
22
+ def env_vars_path(tenant_id: str, app_id: str) -> str:
23
+ return f"/api/v1/tenants/{tenant_id}/apps/{app_id}/env-vars"
24
+
25
+
26
+ async def resolve_app_and_tenant(settings: Settings, app_flag: str | None) -> tuple[str, str]:
27
+ """Resolve ``--app`` (id or name) to a concrete ``(app_id, tenant_id)`` pair.
28
+
29
+ The env-vars endpoint verifies the app belongs to the tenant, so both ids
30
+ must come from the same app record.
31
+ """
32
+ target = app_flag or settings.app_id
33
+ if not target:
34
+ print_error("No app specified. Use --app <id-or-name> or set MINITEST_APP_ID.")
35
+ raise typer.Exit(code=EXIT_GENERAL_ERROR)
36
+
37
+ async with ApiClient(settings) as client:
38
+ resp = await client.get("/api/v1/apps")
39
+ if resp.status_code >= 400:
40
+ print_error(f"API error ({resp.status_code}): failed to list apps.")
41
+ raise typer.Exit(code=EXIT_NETWORK_ERROR)
42
+
43
+ apps = AppListResponse.model_validate(resp.json()).apps
44
+ lowered = target.lower()
45
+ matches = [a for a in apps if a.id == target or a.name.lower() == lowered]
46
+ if not matches:
47
+ print_error(f"App not found: '{target}'. Use a valid app id or name.")
48
+ raise typer.Exit(code=EXIT_NOT_FOUND)
49
+ if len(matches) > 1:
50
+ print_error(f"Ambiguous app name '{target}' matches {len(matches)} apps. Use the app id.")
51
+ raise typer.Exit(code=EXIT_GENERAL_ERROR)
52
+
53
+ app = matches[0]
54
+ return app.id, app.tenant_id
55
+
56
+
57
+ async def fetch_env_vars(settings: Settings, tenant_id: str, app_id: str) -> dict[str, str]:
58
+ """Return the app's env vars, or an empty dict when none are configured (404)."""
59
+ async with AppsManagerClient(settings) as client:
60
+ resp = await client.get(env_vars_path(tenant_id, app_id))
61
+ if resp.status_code == 404:
62
+ return {}
63
+ _raise_for_status(resp, resource="Environment variables")
64
+ return AppEnvVarsResponse.model_validate(resp.json()).env_vars
65
+
66
+
67
+ async def put_env_vars(
68
+ settings: Settings, tenant_id: str, app_id: str, env_vars: dict[str, str]
69
+ ) -> AppEnvVarsResponse:
70
+ """Replace the app's full env-var set."""
71
+ async with AppsManagerClient(settings) as client:
72
+ resp = await client.put(env_vars_path(tenant_id, app_id), json={"envVars": env_vars})
73
+ _raise_for_status(resp, resource="Environment variables")
74
+ return AppEnvVarsResponse.model_validate(resp.json())
75
+
76
+
77
+ async def delete_env_vars(settings: Settings, tenant_id: str, app_id: str) -> None:
78
+ """Delete all env vars for the app."""
79
+ async with AppsManagerClient(settings) as client:
80
+ resp = await client.delete(env_vars_path(tenant_id, app_id))
81
+ if resp.status_code == 404:
82
+ print_error("No environment variables to delete.")
83
+ raise typer.Exit(code=EXIT_NOT_FOUND)
84
+ _raise_for_status(resp, resource="Environment variables")
85
+
86
+
87
+ def _raise_for_status(resp: httpx.Response, *, resource: str) -> None:
88
+ if resp.status_code < 400:
89
+ return
90
+ detail = _extract_detail(resp)
91
+ if resp.status_code == 404:
92
+ print_error(detail or f"{resource} not found.")
93
+ raise typer.Exit(code=EXIT_NOT_FOUND)
94
+ if resp.status_code >= 500:
95
+ print_error(detail or f"API error: {resp.status_code}")
96
+ raise typer.Exit(code=EXIT_NETWORK_ERROR)
97
+ print_error(detail or f"API error: {resp.status_code}")
98
+ raise typer.Exit(code=EXIT_GENERAL_ERROR)
99
+
100
+
101
+ def _extract_detail(resp: httpx.Response) -> str | None:
102
+ try:
103
+ body = resp.json()
104
+ except Exception: # noqa: BLE001
105
+ return None
106
+ if isinstance(body, dict):
107
+ return body.get("detail") or body.get("message")
108
+ return None
109
+
110
+
111
+ def confirm_or_exit(yes: bool, action: str) -> None:
112
+ """Gate a mutating action behind explicit confirmation.
113
+
114
+ Passing ``--yes`` proceeds. Without it we refuse rather than prompt, so the
115
+ command stays safe to run non-interactively (agents/CI) — exit 1 naming the
116
+ flag that unblocks it.
117
+ """
118
+ if yes:
119
+ return
120
+ print_error(f"{action} requires confirmation. Re-run with --yes to proceed.")
121
+ raise typer.Exit(code=EXIT_GENERAL_ERROR)
122
+
123
+
124
+ def diff_keys(
125
+ current: dict[str, str], updated: dict[str, str]
126
+ ) -> tuple[list[str], list[str], list[str]]:
127
+ """Return (added, changed, removed) keys between two env-var maps."""
128
+ added = sorted(k for k in updated if k not in current)
129
+ removed = sorted(k for k in current if k not in updated)
130
+ changed = sorted(k for k in updated if k in current and updated[k] != current[k])
131
+ return added, changed, removed
132
+
133
+
134
+ def print_diff(added: list[str], changed: list[str], removed: list[str]) -> None:
135
+ for key in added:
136
+ print(f"+ {key}", file=sys.stderr) # noqa: T201
137
+ for key in changed:
138
+ print(f"~ {key}", file=sys.stderr) # noqa: T201
139
+ for key in removed:
140
+ print(f"- {key}", file=sys.stderr) # noqa: T201
@@ -20,7 +20,11 @@ from minitest_cli.core.credentials import (
20
20
  save_credentials,
21
21
  )
22
22
  from minitest_cli.core.oauth import oauth_pkce_login, refresh_token
23
- from minitest_cli.core.token_exchange import EXIT_CODE_AUTH_ERROR, auth_error
23
+ from minitest_cli.core.token_exchange import (
24
+ EXIT_CODE_AUTH_ERROR,
25
+ SessionRevokedError,
26
+ auth_error,
27
+ )
24
28
 
25
29
  __all__ = [
26
30
  "AuthStatus",
@@ -38,6 +42,7 @@ __all__ = [
38
42
  "require_auth",
39
43
  "refresh_token",
40
44
  "save_credentials",
45
+ "SessionRevokedError",
41
46
  ]
42
47
 
43
48
  AuthMethod = Literal["api_key", "env_token", "oauth", "none"]
@@ -71,14 +76,20 @@ class AuthStatus(TypedDict):
71
76
  def load_or_refresh_credentials(settings: Settings) -> Credentials | None:
72
77
  """Load stored credentials, auto-refreshing if near expiry.
73
78
 
74
- Returns refreshed credentials, original credentials, or None.
79
+ Returns refreshed credentials, original credentials, or None. When the auth
80
+ server rejects the stored token outright, the dead credentials are cleared
81
+ and SessionRevokedError propagates so callers can prompt a fresh login.
75
82
  """
76
83
  creds = load_credentials(settings)
77
84
  if creds is None:
78
85
  return None
79
- if creds.is_expired:
86
+ if not creds.is_expired:
87
+ return creds
88
+ try:
80
89
  return refresh_token(settings, creds)
81
- return creds
90
+ except SessionRevokedError:
91
+ clear_credentials(settings)
92
+ raise
82
93
 
83
94
 
84
95
  def load_token(settings: Settings) -> str:
@@ -91,7 +102,13 @@ def load_token(settings: Settings) -> str:
91
102
  if settings.api_key:
92
103
  return settings.api_key.get_secret_value()
93
104
 
94
- creds = load_or_refresh_credentials(settings)
105
+ try:
106
+ creds = load_or_refresh_credentials(settings)
107
+ except SessionRevokedError:
108
+ auth_error(
109
+ "Your saved session is no longer valid (the auth server rejected it). "
110
+ "Run `minitest auth login` to sign in again."
111
+ )
95
112
  if creds is not None:
96
113
  return creds.access_token
97
114
 
@@ -120,8 +137,11 @@ def get_auth_method(settings: Settings) -> AuthMethod:
120
137
  return "env_token"
121
138
  if settings.api_key:
122
139
  return "api_key"
123
- if load_or_refresh_credentials(settings) is not None:
124
- return "oauth"
140
+ try:
141
+ if load_or_refresh_credentials(settings) is not None:
142
+ return "oauth"
143
+ except SessionRevokedError:
144
+ return "none"
125
145
  return "none"
126
146
 
127
147
 
@@ -23,6 +23,7 @@ class Credentials(BaseModel):
23
23
  expires_at: float
24
24
  user_id: str
25
25
  email: str
26
+ client_id: str | None = None
26
27
 
27
28
  @property
28
29
  def is_expired(self) -> bool:
@@ -19,6 +19,7 @@ import httpx
19
19
  from minitest_cli.core.config import Settings
20
20
  from minitest_cli.core.credentials import Credentials
21
21
  from minitest_cli.core.token_exchange import (
22
+ SessionRevokedError,
22
23
  auth_error,
23
24
  parse_and_save_token_response,
24
25
  register_oauth_client,
@@ -28,47 +29,54 @@ _ASSETS = importlib.resources.files("minitest_cli.assets")
28
29
 
29
30
 
30
31
  def refresh_token(settings: Settings, creds: Credentials) -> Credentials | None:
31
- """Refresh an expired access token using the refresh token.
32
+ """Refresh an expired access token, saving new credentials to disk.
32
33
 
33
- Returns updated credentials (also saved to disk), or None on failure.
34
+ OAuth sessions must refresh against the same client that created them, so a
35
+ persisted client_id is required. Returns None on a transient failure. Raises
36
+ SessionRevokedError when the token can never succeed (missing client_id, or a
37
+ 4xx rejection) so callers can clear it and re-login.
34
38
  """
35
39
  if not settings.supabase_url or not settings.supabase_publishable_key:
36
40
  return None
41
+ if not creds.client_id:
42
+ raise SessionRevokedError
37
43
 
38
44
  supabase_url = settings.supabase_url.rstrip("/")
39
45
  try:
40
46
  response = httpx.post(
41
- f"{supabase_url}/auth/v1/token?grant_type=refresh_token",
42
- json={"refresh_token": creds.refresh_token},
47
+ f"{supabase_url}/auth/v1/oauth/token",
48
+ data={
49
+ "grant_type": "refresh_token",
50
+ "refresh_token": creds.refresh_token,
51
+ "client_id": creds.client_id,
52
+ },
43
53
  headers={
44
- "Content-Type": "application/json",
54
+ "Content-Type": "application/x-www-form-urlencoded",
45
55
  "apikey": settings.supabase_publishable_key,
46
56
  },
47
57
  timeout=15.0,
48
58
  )
49
- response.raise_for_status()
59
+ except httpx.HTTPError:
60
+ return None
61
+
62
+ if 400 <= response.status_code < 500:
63
+ raise SessionRevokedError
64
+ if response.status_code >= 500:
65
+ return None
66
+
67
+ try:
50
68
  data = response.json()
51
- except (httpx.HTTPError, ValueError):
69
+ except ValueError:
52
70
  return None
53
71
 
54
72
  if not isinstance(data, dict):
55
73
  return None
56
74
 
57
- return parse_and_save_token_response(settings, data)
75
+ return parse_and_save_token_response(settings, data, creds.client_id)
58
76
 
59
77
 
60
78
  def oauth_pkce_login(settings: Settings) -> Credentials:
61
- """Run the full OAuth PKCE login flow via Supabase's OAuth2 server.
62
-
63
- Steps:
64
- 1. Start local callback server
65
- 2. Dynamically register an OAuth2 client with Supabase
66
- 3. Generate PKCE code verifier + challenge
67
- 4. Open browser to Supabase authorize endpoint (shows hosted sign-in page)
68
- 5. Wait for callback with authorization code
69
- 6. Exchange code + verifier for tokens at Supabase token endpoint
70
- 7. Save and return credentials
71
- """
79
+ """Run the full OAuth PKCE login flow via Supabase's OAuth2 server."""
72
80
  supabase_url = settings.supabase_url.rstrip("/")
73
81
 
74
82
  # PKCE challenge: base64url(sha256(verifier)) without padding
@@ -181,7 +189,7 @@ def oauth_pkce_login(settings: Settings) -> Credentials:
181
189
  if not isinstance(response_data, dict):
182
190
  auth_error("Token exchange returned unexpected response format.")
183
191
 
184
- creds = parse_and_save_token_response(settings, response_data)
192
+ creds = parse_and_save_token_response(settings, response_data, client_id)
185
193
  if creds is None:
186
194
  auth_error("Failed to parse token response.")
187
195
 
@@ -16,6 +16,14 @@ from minitest_cli.core.credentials import Credentials, save_credentials
16
16
  EXIT_CODE_AUTH_ERROR = 2
17
17
 
18
18
 
19
+ class SessionRevokedError(Exception):
20
+ """The auth server rejected the stored refresh token outright (4xx).
21
+
22
+ Distinct from a transient network/server error: the token can never
23
+ succeed (e.g. issued by a project decommissioned after a migration).
24
+ """
25
+
26
+
19
27
  def require_supabase_url(settings: Settings) -> str:
20
28
  """Return the supabase URL or exit with code 2."""
21
29
  if settings.supabase_url:
@@ -35,8 +43,14 @@ def get_apikey_header(settings: Settings) -> str:
35
43
  )
36
44
 
37
45
 
38
- def parse_and_save_token_response(settings: Settings, data: dict[str, Any]) -> Credentials | None:
39
- """Parse a Supabase token response and persist credentials."""
46
+ def parse_and_save_token_response(
47
+ settings: Settings, data: dict[str, Any], client_id: str | None = None
48
+ ) -> Credentials | None:
49
+ """Parse a Supabase token response and persist credentials.
50
+
51
+ client_id is the OAuth client that owns the session; it must be persisted
52
+ so refresh_token can re-authenticate against the same client.
53
+ """
40
54
  try:
41
55
  user = data.get("user", {})
42
56
  if not isinstance(user, dict):
@@ -57,6 +71,7 @@ def parse_and_save_token_response(settings: Settings, data: dict[str, Any]) -> C
57
71
  expires_at=time.time() + int(expires_in),
58
72
  user_id=user_id,
59
73
  email=email,
74
+ client_id=client_id,
60
75
  )
61
76
  save_credentials(settings, creds)
62
77
  return creds
@@ -11,6 +11,7 @@ from minitest_cli.commands import (
11
11
  auth,
12
12
  batch,
13
13
  build,
14
+ env,
14
15
  flow_types,
15
16
  init,
16
17
  run,
@@ -42,6 +43,7 @@ app.add_typer(test_file.app)
42
43
  app.add_typer(flow_types.app)
43
44
  app.add_typer(app_knowledge.app)
44
45
  app.add_typer(build.app)
46
+ app.add_typer(env.app)
45
47
  app.add_typer(run.app)
46
48
  app.add_typer(batch.app)
47
49
  app.add_typer(skill.app)
@@ -0,0 +1,19 @@
1
+ """Pydantic models for the app env-vars API (apps-manager)."""
2
+
3
+ from datetime import datetime
4
+
5
+ from minitest_cli.models.base import CamelModel
6
+
7
+
8
+ class AppEnvVarsResponse(CamelModel):
9
+ """Response from the apps-manager env-vars endpoints.
10
+
11
+ ``env_vars`` values are decrypted plaintext, so treat the whole payload
12
+ as secret material.
13
+ """
14
+
15
+ id: str
16
+ app_id: str
17
+ tenant_id: str
18
+ env_vars: dict[str, str]
19
+ updated_at: datetime | None = None