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.
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/PKG-INFO +1 -1
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/pyproject.toml +1 -1
- minitest_cli-0.16.2/renovate.json +4 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/api/apps_manager_client.py +8 -0
- minitest_cli-0.16.2/src/minitest_cli/commands/env.py +198 -0
- minitest_cli-0.16.2/src/minitest_cli/commands/env_helpers.py +140 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/core/auth.py +27 -7
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/core/credentials.py +1 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/core/oauth.py +28 -20
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/core/token_exchange.py +17 -2
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/main.py +2 -0
- minitest_cli-0.16.2/src/minitest_cli/models/app_env_vars.py +19 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_auth.py +70 -9
- minitest_cli-0.16.2/tests/test_env.py +263 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/uv.lock +1 -1
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/.env.example +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/.github/workflows/ci.yml +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/.github/workflows/install-scripts.yml +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/.github/workflows/release.yml +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/.gitignore +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/.opencode/skill/release/SKILL.md +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/AGENTS.md +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/README.md +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/RELEASE.md +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/install.ps1 +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/install.sh +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/pyrightconfig.json +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/__init__.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/api/__init__.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/api/client.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/assets/__init__.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/assets/callback.html +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/__init__.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/app_knowledge.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/app_knowledge_helpers.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/apps.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/apps_dependencies.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/apps_helpers.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/auth.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/auth_api_key.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/batch.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/batch_helpers.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/build.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/build_helpers.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/flow_types.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/init.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/init_playbook.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/run.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/run_display.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/run_helpers.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/run_targets.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/skill.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/test_file.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/test_file_helpers.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/test_file_list.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/test_profile.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/test_profile_default.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/test_profile_helpers.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/test_profile_list.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/upgrade.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/user_story.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/user_story_bindings.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/user_story_criteria.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/user_story_helpers.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/user_story_modify.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/commands/user_story_profiles.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/core/__init__.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/core/app_context.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/core/config.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/core/tenants.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/models/__init__.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/models/app.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/models/base.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/models/batch.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/models/build.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/models/story_run.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/models/targets.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/models/user_story.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/utils/__init__.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/utils/mermaid.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/utils/output.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/utils/skill_refresh.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/src/minitest_cli/utils/update_check.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/__init__.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_app_knowledge_commands.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_apps_commands.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_apps_dependencies.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_auth_api_key.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_auth_commands.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_batch_commands.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_build_commands.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_code_quality.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_flow_types_commands.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_init_command.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_mermaid.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_run_commands.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_skill_command.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_test_file_commands.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_test_profile_commands.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_upgrade_command.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_user_story_bindings_commands.py +0 -0
- {minitest_cli-0.16.0 → minitest_cli-0.16.2}/tests/test_user_story_commands.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
|
|
@@ -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
|
|
32
|
+
"""Refresh an expired access token, saving new credentials to disk.
|
|
32
33
|
|
|
33
|
-
|
|
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
|
|
42
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
|
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(
|
|
39
|
-
|
|
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
|