lightnow-cli 1.0.1__tar.gz → 1.0.3__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.
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/PKG-INFO +1 -1
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/__init__.py +1 -1
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/client.py +2 -4
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/commands/auth.py +5 -0
- lightnow_cli-1.0.3/lightnow_cli/commands/context.py +198 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/commands/integrations.py +4 -3
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/commands/publish.py +3 -1
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/commands/query.py +11 -3
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/commands/runner.py +3 -2
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/config.py +46 -1
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/main.py +2 -1
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/pyproject.toml +1 -1
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/tests/test_cli.py +173 -2
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/tests/test_client.py +30 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/tests/test_config.py +40 -2
- lightnow_cli-1.0.3/tests/test_context.py +190 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/tests/test_integrations.py +160 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/.github/workflows/ci.yml +0 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/.github/workflows/release.yml +0 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/.gitignore +0 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/CHANGELOG.md +0 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/CONTRIBUTING.md +0 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/LICENSE +0 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/Makefile +0 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/README.md +0 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/SECURITY.md +0 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/docs/development.md +0 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/docs/release.md +0 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/examples/README.md +0 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/examples/docs.md +0 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/examples/openapi.json +0 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/examples/server.json +0 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/authenticated_http.py +0 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/commands/__init__.py +0 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/commands/validate.py +0 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/tls.py +0 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/lightnow_cli/validation.py +0 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/tests/__init__.py +0 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/tests/test_auth_flow.py +0 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/tests/test_tls.py +0 -0
- {lightnow_cli-1.0.1 → lightnow_cli-1.0.3}/tests/test_validation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lightnow-cli
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.3
|
|
4
4
|
Summary: Official LightNow CLI for MCP registry discovery, client config sync, and local MCP runner workflows
|
|
5
5
|
Project-URL: Homepage, https://github.com/lightnow-ai/lightnow-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/lightnow-ai/lightnow-cli
|
|
@@ -151,8 +151,6 @@ class MCPRegistryClient:
|
|
|
151
151
|
params["favorites"] = favorites
|
|
152
152
|
if sort:
|
|
153
153
|
params["sort"] = sort
|
|
154
|
-
if tenant:
|
|
155
|
-
params["tenant"] = tenant
|
|
156
154
|
if cursor:
|
|
157
155
|
params["cursor"] = cursor
|
|
158
156
|
|
|
@@ -163,6 +161,7 @@ class MCPRegistryClient:
|
|
|
163
161
|
params=params,
|
|
164
162
|
headers=self._get_headers(),
|
|
165
163
|
token=self.token,
|
|
164
|
+
tenant=tenant,
|
|
166
165
|
allow_refresh=self._refresh_on_unauthorized,
|
|
167
166
|
timeout=30.0,
|
|
168
167
|
)
|
|
@@ -191,8 +190,6 @@ class MCPRegistryClient:
|
|
|
191
190
|
) -> Dict[str, Any]:
|
|
192
191
|
"""Get detailed information about a specific server."""
|
|
193
192
|
params: Dict[str, Any] = {}
|
|
194
|
-
if tenant:
|
|
195
|
-
params["tenant"] = tenant
|
|
196
193
|
|
|
197
194
|
encoded_server_id = quote(server_id, safe="")
|
|
198
195
|
if version:
|
|
@@ -209,6 +206,7 @@ class MCPRegistryClient:
|
|
|
209
206
|
params=params,
|
|
210
207
|
headers=self._get_headers(),
|
|
211
208
|
token=self.token,
|
|
209
|
+
tenant=tenant,
|
|
212
210
|
allow_refresh=self._refresh_on_unauthorized,
|
|
213
211
|
timeout=30.0,
|
|
214
212
|
)
|
|
@@ -17,6 +17,7 @@ from typing_extensions import Annotated
|
|
|
17
17
|
from ..config import (
|
|
18
18
|
DEFAULT_CLIENT_ID,
|
|
19
19
|
DEFAULT_ISSUER,
|
|
20
|
+
LOCAL_ADMIN_API_URL,
|
|
20
21
|
LOCAL_ISSUER,
|
|
21
22
|
LOCAL_REGISTRY_API_URL,
|
|
22
23
|
config_manager,
|
|
@@ -380,12 +381,14 @@ def login(
|
|
|
380
381
|
resolved_issuer = issuer or (LOCAL_ISSUER if local else DEFAULT_ISSUER)
|
|
381
382
|
resolved_client_id = client_id or DEFAULT_CLIENT_ID
|
|
382
383
|
resolved_registry_api_url = LOCAL_REGISTRY_API_URL if local else None
|
|
384
|
+
resolved_admin_api_url = LOCAL_ADMIN_API_URL if local else None
|
|
383
385
|
|
|
384
386
|
# Store auth config
|
|
385
387
|
config_manager.set_auth_config(
|
|
386
388
|
resolved_issuer,
|
|
387
389
|
resolved_client_id,
|
|
388
390
|
resolved_registry_api_url,
|
|
391
|
+
resolved_admin_api_url,
|
|
389
392
|
)
|
|
390
393
|
|
|
391
394
|
# Perform device code flow
|
|
@@ -446,6 +449,8 @@ def status() -> None:
|
|
|
446
449
|
if email:
|
|
447
450
|
console.print(f"Email: [bold]{email}[/bold]")
|
|
448
451
|
|
|
452
|
+
console.print(f"Context: [bold]{config_manager.context_display_name()}[/bold]")
|
|
453
|
+
|
|
449
454
|
exp = user_info.get("exp")
|
|
450
455
|
if exp:
|
|
451
456
|
exp_time = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(exp))
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Context selection commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from typing_extensions import Annotated
|
|
12
|
+
|
|
13
|
+
from ..authenticated_http import (
|
|
14
|
+
authentication_error_from_response,
|
|
15
|
+
request_with_refresh,
|
|
16
|
+
)
|
|
17
|
+
from ..config import config_manager
|
|
18
|
+
from .auth import (
|
|
19
|
+
ACCESS_TOKEN_EXPIRED_MESSAGE,
|
|
20
|
+
AccessTokenExpired,
|
|
21
|
+
AuthError,
|
|
22
|
+
require_access_token,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def context(
|
|
29
|
+
tenant: Annotated[
|
|
30
|
+
Optional[str],
|
|
31
|
+
typer.Option(
|
|
32
|
+
"--tenant",
|
|
33
|
+
help="Select an organization by exact tenant id or subdomain.",
|
|
34
|
+
),
|
|
35
|
+
] = None,
|
|
36
|
+
personal: Annotated[
|
|
37
|
+
bool,
|
|
38
|
+
typer.Option("--personal", help="Use the personal account context."),
|
|
39
|
+
] = False,
|
|
40
|
+
show: Annotated[
|
|
41
|
+
bool,
|
|
42
|
+
typer.Option("--show", help="Show the currently stored context."),
|
|
43
|
+
] = False,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Select the default LightNow context for context-aware commands."""
|
|
46
|
+
if personal and tenant:
|
|
47
|
+
console.print(
|
|
48
|
+
"[bold red]Context failed:[/bold red] choose --personal or --tenant."
|
|
49
|
+
)
|
|
50
|
+
raise typer.Exit(2)
|
|
51
|
+
|
|
52
|
+
if show:
|
|
53
|
+
show_current_context()
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
if personal:
|
|
57
|
+
config_manager.set_personal_context()
|
|
58
|
+
console.print("[green]Context set to Personal.[/green]")
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
tenants = fetch_tenants()
|
|
63
|
+
except AccessTokenExpired:
|
|
64
|
+
console.print(f"[red]{ACCESS_TOKEN_EXPIRED_MESSAGE}[/red]")
|
|
65
|
+
raise typer.Exit(1)
|
|
66
|
+
except (AuthError, ValueError) as exc:
|
|
67
|
+
console.print(f"[bold red]Context failed:[/bold red] {exc}")
|
|
68
|
+
raise typer.Exit(1) from exc
|
|
69
|
+
|
|
70
|
+
if tenant:
|
|
71
|
+
selected = find_tenant(tenants, tenant)
|
|
72
|
+
if selected is None:
|
|
73
|
+
console.print(
|
|
74
|
+
"[bold red]Context failed:[/bold red] "
|
|
75
|
+
f"No organization matches '{tenant}'. Use 'lightnow context' "
|
|
76
|
+
"to choose from available organizations."
|
|
77
|
+
)
|
|
78
|
+
raise typer.Exit(1)
|
|
79
|
+
store_tenant_context(selected)
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
choose_interactively(tenants)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def show_current_context() -> None:
|
|
86
|
+
"""Print the currently stored context."""
|
|
87
|
+
config = config_manager.load_config()
|
|
88
|
+
table = Table(title="LightNow Context")
|
|
89
|
+
table.add_column("Scope", style="cyan")
|
|
90
|
+
table.add_column("Details", style="white")
|
|
91
|
+
if config.context_type == "tenant":
|
|
92
|
+
table.add_row(
|
|
93
|
+
"Organization", config.context_label or config.context_tenant or ""
|
|
94
|
+
)
|
|
95
|
+
if config.context_tenant:
|
|
96
|
+
table.add_row("Tenant ID", config.context_tenant)
|
|
97
|
+
else:
|
|
98
|
+
table.add_row("Personal", "Personal LightNow account")
|
|
99
|
+
console.print(table)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def fetch_tenants() -> list[dict[str, Any]]:
|
|
103
|
+
"""Fetch organizations available to the authenticated account."""
|
|
104
|
+
token = require_access_token()
|
|
105
|
+
config = config_manager.load_config()
|
|
106
|
+
admin_api_url = config.admin_api_url
|
|
107
|
+
if not admin_api_url:
|
|
108
|
+
raise ValueError("Admin API URL required. Run 'lightnow login' again.")
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
response = request_with_refresh(
|
|
112
|
+
"GET",
|
|
113
|
+
f"{admin_api_url.rstrip('/')}/tenants",
|
|
114
|
+
token=token,
|
|
115
|
+
headers={"Accept": "application/json"},
|
|
116
|
+
timeout=30.0,
|
|
117
|
+
)
|
|
118
|
+
except httpx.RequestError as exc:
|
|
119
|
+
raise ValueError(f"Network error: {exc}") from exc
|
|
120
|
+
|
|
121
|
+
if response.status_code == 401:
|
|
122
|
+
raise authentication_error_from_response(response)
|
|
123
|
+
if response.status_code >= 400:
|
|
124
|
+
raise ValueError(f"HTTP {response.status_code}: {response.text}")
|
|
125
|
+
|
|
126
|
+
payload = response.json()
|
|
127
|
+
if not isinstance(payload, list):
|
|
128
|
+
raise ValueError("Admin API response did not include an organization list.")
|
|
129
|
+
return [item for item in payload if isinstance(item, dict)]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def choose_interactively(tenants: list[dict[str, Any]]) -> None:
|
|
133
|
+
"""Render choices and store the selected context."""
|
|
134
|
+
choices: list[tuple[str, Optional[dict[str, Any]]]] = [("Personal", None)]
|
|
135
|
+
choices.extend((tenant_label(item), item) for item in tenants)
|
|
136
|
+
|
|
137
|
+
console.print("[bold blue]Select LightNow context[/bold blue]")
|
|
138
|
+
for index, (label, item) in enumerate(choices, start=1):
|
|
139
|
+
suffix = ""
|
|
140
|
+
if item is not None:
|
|
141
|
+
role = text_value(item.get("role"))
|
|
142
|
+
plan = text_value(item.get("plan"))
|
|
143
|
+
details = " · ".join(value for value in [role, plan] if value)
|
|
144
|
+
suffix = f" ({details})" if details else ""
|
|
145
|
+
console.print(f" {index}. {label}{suffix}")
|
|
146
|
+
|
|
147
|
+
selected = typer.prompt("Context", default=1)
|
|
148
|
+
try:
|
|
149
|
+
selected_index = int(str(selected).strip())
|
|
150
|
+
except ValueError as exc:
|
|
151
|
+
raise typer.BadParameter("Context must be a number from the list.") from exc
|
|
152
|
+
|
|
153
|
+
if selected_index < 1 or selected_index > len(choices):
|
|
154
|
+
raise typer.BadParameter("Context selection is out of range.")
|
|
155
|
+
|
|
156
|
+
_, item = choices[selected_index - 1]
|
|
157
|
+
if item is None:
|
|
158
|
+
config_manager.set_personal_context()
|
|
159
|
+
console.print("[green]Context set to Personal.[/green]")
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
store_tenant_context(item)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def find_tenant(tenants: list[dict[str, Any]], value: str) -> Optional[dict[str, Any]]:
|
|
166
|
+
"""Find a tenant by exact id or subdomain."""
|
|
167
|
+
for item in tenants:
|
|
168
|
+
tenant_id = text_value(item.get("id"))
|
|
169
|
+
subdomain = text_value(item.get("subdomain"))
|
|
170
|
+
if value in {tenant_id, subdomain}:
|
|
171
|
+
return item
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def store_tenant_context(item: dict[str, Any]) -> None:
|
|
176
|
+
"""Persist a selected tenant context."""
|
|
177
|
+
tenant_id = text_value(item.get("id"))
|
|
178
|
+
if not tenant_id:
|
|
179
|
+
raise ValueError("Selected organization has no tenant id.")
|
|
180
|
+
label = tenant_label(item)
|
|
181
|
+
config_manager.set_tenant_context(tenant_id, label)
|
|
182
|
+
console.print(f"[green]Context set to organization {label}.[/green]")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def tenant_label(item: dict[str, Any]) -> str:
|
|
186
|
+
"""Return a display label for one tenant."""
|
|
187
|
+
name = text_value(item.get("name"))
|
|
188
|
+
subdomain = text_value(item.get("subdomain"))
|
|
189
|
+
if name and subdomain:
|
|
190
|
+
return f"{name} ({subdomain})"
|
|
191
|
+
return name or subdomain or text_value(item.get("id")) or "Organization"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def text_value(value: Any) -> str:
|
|
195
|
+
"""Return a safe string value."""
|
|
196
|
+
if isinstance(value, str):
|
|
197
|
+
return value
|
|
198
|
+
return ""
|
|
@@ -121,6 +121,7 @@ def sync(
|
|
|
121
121
|
console.print(f"[yellow]{exc}[/yellow]")
|
|
122
122
|
raise typer.Exit(1)
|
|
123
123
|
|
|
124
|
+
effective_tenant = config_manager.effective_tenant(tenant)
|
|
124
125
|
registry_api_url = api_url or config_manager.load_config().registry_api_url
|
|
125
126
|
if not registry_api_url:
|
|
126
127
|
raise_bad_argument(
|
|
@@ -133,7 +134,7 @@ def sync(
|
|
|
133
134
|
profile_payload = fetch_profile_servers(
|
|
134
135
|
api_url=registry_api_url,
|
|
135
136
|
token=bearer_token,
|
|
136
|
-
tenant=
|
|
137
|
+
tenant=effective_tenant,
|
|
137
138
|
profile=profile,
|
|
138
139
|
)
|
|
139
140
|
generated = build_runner_export(
|
|
@@ -141,13 +142,13 @@ def sync(
|
|
|
141
142
|
client=client,
|
|
142
143
|
export_format=export_format,
|
|
143
144
|
profile=profile,
|
|
144
|
-
tenant=
|
|
145
|
+
tenant=effective_tenant,
|
|
145
146
|
)
|
|
146
147
|
else:
|
|
147
148
|
generated = fetch_export(
|
|
148
149
|
api_url=registry_api_url,
|
|
149
150
|
token=bearer_token,
|
|
150
|
-
tenant=
|
|
151
|
+
tenant=effective_tenant,
|
|
151
152
|
profile=profile,
|
|
152
153
|
client=client,
|
|
153
154
|
export_format=export_format,
|
|
@@ -9,6 +9,7 @@ from rich.console import Console
|
|
|
9
9
|
from typing_extensions import Annotated
|
|
10
10
|
|
|
11
11
|
from ..client import get_client
|
|
12
|
+
from ..config import config_manager
|
|
12
13
|
from ..validation import validate_artifacts
|
|
13
14
|
|
|
14
15
|
console = Console()
|
|
@@ -74,6 +75,7 @@ def publish(
|
|
|
74
75
|
try:
|
|
75
76
|
# Get client and publish
|
|
76
77
|
client = get_client()
|
|
78
|
+
effective_tenant = config_manager.effective_tenant(tenant)
|
|
77
79
|
console.print("Publishing to registry...")
|
|
78
80
|
|
|
79
81
|
result = asyncio.run(
|
|
@@ -81,7 +83,7 @@ def publish(
|
|
|
81
83
|
server_json=server_data,
|
|
82
84
|
docs_content=docs_content,
|
|
83
85
|
spec_content=spec_data,
|
|
84
|
-
tenant=
|
|
86
|
+
tenant=effective_tenant,
|
|
85
87
|
)
|
|
86
88
|
)
|
|
87
89
|
|
|
@@ -9,6 +9,7 @@ from rich.table import Table
|
|
|
9
9
|
from typing_extensions import Annotated
|
|
10
10
|
|
|
11
11
|
from ..client import get_client
|
|
12
|
+
from ..config import config_manager
|
|
12
13
|
|
|
13
14
|
console = Console()
|
|
14
15
|
|
|
@@ -43,11 +44,12 @@ def search_servers(
|
|
|
43
44
|
|
|
44
45
|
try:
|
|
45
46
|
client = get_client()
|
|
47
|
+
effective_tenant = config_manager.effective_tenant(tenant)
|
|
46
48
|
result = asyncio.run(
|
|
47
49
|
client.list_servers(
|
|
48
50
|
search=query,
|
|
49
51
|
sort=sort,
|
|
50
|
-
tenant=
|
|
52
|
+
tenant=effective_tenant,
|
|
51
53
|
limit=limit,
|
|
52
54
|
cursor=cursor,
|
|
53
55
|
)
|
|
@@ -109,11 +111,12 @@ def favorite_servers(
|
|
|
109
111
|
|
|
110
112
|
try:
|
|
111
113
|
client = get_client()
|
|
114
|
+
effective_tenant = config_manager.effective_tenant(tenant)
|
|
112
115
|
result = asyncio.run(
|
|
113
116
|
client.list_servers(
|
|
114
117
|
favorites=normalized_scope,
|
|
115
118
|
sort=sort,
|
|
116
|
-
tenant=
|
|
119
|
+
tenant=effective_tenant,
|
|
117
120
|
limit=limit,
|
|
118
121
|
cursor=cursor,
|
|
119
122
|
)
|
|
@@ -208,8 +211,13 @@ def server_info(
|
|
|
208
211
|
|
|
209
212
|
try:
|
|
210
213
|
client = get_client()
|
|
214
|
+
effective_tenant = config_manager.effective_tenant(tenant)
|
|
211
215
|
result = asyncio.run(
|
|
212
|
-
client.get_server_info(
|
|
216
|
+
client.get_server_info(
|
|
217
|
+
server_id=server_id,
|
|
218
|
+
version=version,
|
|
219
|
+
tenant=effective_tenant,
|
|
220
|
+
)
|
|
213
221
|
)
|
|
214
222
|
payload = server_payload(result)
|
|
215
223
|
|
|
@@ -62,6 +62,7 @@ def run(
|
|
|
62
62
|
try:
|
|
63
63
|
token = require_access_token()
|
|
64
64
|
registry_api_url = api_url or config_manager.load_config().registry_api_url
|
|
65
|
+
effective_tenant = config_manager.effective_tenant(tenant)
|
|
65
66
|
if not registry_api_url:
|
|
66
67
|
raise ValueError(
|
|
67
68
|
"Registry API URL required. Configure the CLI or pass --api-url."
|
|
@@ -69,14 +70,14 @@ def run(
|
|
|
69
70
|
selected = resolve_profile_server(
|
|
70
71
|
api_url=registry_api_url,
|
|
71
72
|
token=token,
|
|
72
|
-
tenant=
|
|
73
|
+
tenant=effective_tenant,
|
|
73
74
|
profile=profile,
|
|
74
75
|
server=server,
|
|
75
76
|
)
|
|
76
77
|
context = fetch_runtime_context(
|
|
77
78
|
api_url=registry_api_url,
|
|
78
79
|
token=token,
|
|
79
|
-
tenant=
|
|
80
|
+
tenant=effective_tenant,
|
|
80
81
|
profile=profile,
|
|
81
82
|
server_name=str(selected["server_name"]),
|
|
82
83
|
version=str(selected["version"]),
|
|
@@ -12,8 +12,10 @@ from pydantic import BaseModel, Field
|
|
|
12
12
|
DEFAULT_ISSUER = "https://auth.lightnow.ai/realms/lightnow"
|
|
13
13
|
DEFAULT_CLIENT_ID = "lightnow-cli"
|
|
14
14
|
DEFAULT_REGISTRY_API_URL = "https://registry-api.lightnow.ai/v0.1"
|
|
15
|
+
DEFAULT_ADMIN_API_URL = "https://admin-api.lightnow.ai/v0/portal"
|
|
15
16
|
LOCAL_ISSUER = "https://auth.lightnow.local/realms/lightnow-local"
|
|
16
17
|
LOCAL_REGISTRY_API_URL = "https://registry-api.lightnow.local/v0.1"
|
|
18
|
+
LOCAL_ADMIN_API_URL = "https://admin-api.lightnow.local/v0/portal"
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
class Config(BaseModel):
|
|
@@ -24,7 +26,11 @@ class Config(BaseModel):
|
|
|
24
26
|
issuer: Optional[str] = Field(default=DEFAULT_ISSUER)
|
|
25
27
|
client_id: Optional[str] = Field(default=DEFAULT_CLIENT_ID)
|
|
26
28
|
registry_api_url: Optional[str] = Field(default=DEFAULT_REGISTRY_API_URL)
|
|
29
|
+
admin_api_url: Optional[str] = Field(default=DEFAULT_ADMIN_API_URL)
|
|
27
30
|
user_info: Optional[Dict[str, Any]] = None
|
|
31
|
+
context_type: str = Field(default="personal")
|
|
32
|
+
context_tenant: Optional[str] = None
|
|
33
|
+
context_label: Optional[str] = None
|
|
28
34
|
|
|
29
35
|
|
|
30
36
|
class ConfigManager:
|
|
@@ -127,11 +133,14 @@ class ConfigManager:
|
|
|
127
133
|
self.save_config(config)
|
|
128
134
|
|
|
129
135
|
def clear_token(self) -> None:
|
|
130
|
-
"""Clear stored access token."""
|
|
136
|
+
"""Clear stored access token and account-scoped context."""
|
|
131
137
|
config = self.load_config()
|
|
132
138
|
config.access_token = None
|
|
133
139
|
config.refresh_token = None
|
|
134
140
|
config.user_info = None
|
|
141
|
+
config.context_type = "personal"
|
|
142
|
+
config.context_tenant = None
|
|
143
|
+
config.context_label = None
|
|
135
144
|
self.save_config(config)
|
|
136
145
|
|
|
137
146
|
def set_auth_config(
|
|
@@ -139,14 +148,50 @@ class ConfigManager:
|
|
|
139
148
|
issuer: str,
|
|
140
149
|
client_id: str,
|
|
141
150
|
registry_api_url: Optional[str] = None,
|
|
151
|
+
admin_api_url: Optional[str] = None,
|
|
142
152
|
) -> None:
|
|
143
153
|
"""Set authentication configuration."""
|
|
144
154
|
config = self.load_config()
|
|
145
155
|
config.issuer = issuer
|
|
146
156
|
config.client_id = client_id
|
|
147
157
|
config.registry_api_url = registry_api_url or DEFAULT_REGISTRY_API_URL
|
|
158
|
+
config.admin_api_url = admin_api_url or DEFAULT_ADMIN_API_URL
|
|
148
159
|
self.save_config(config)
|
|
149
160
|
|
|
161
|
+
def set_personal_context(self) -> None:
|
|
162
|
+
"""Use the personal LightNow context by default."""
|
|
163
|
+
config = self.load_config()
|
|
164
|
+
config.context_type = "personal"
|
|
165
|
+
config.context_tenant = None
|
|
166
|
+
config.context_label = None
|
|
167
|
+
self.save_config(config)
|
|
168
|
+
|
|
169
|
+
def set_tenant_context(self, tenant_id: str, label: str) -> None:
|
|
170
|
+
"""Use a tenant context by default."""
|
|
171
|
+
if not tenant_id:
|
|
172
|
+
raise ValueError("Tenant context requires a tenant id.")
|
|
173
|
+
config = self.load_config()
|
|
174
|
+
config.context_type = "tenant"
|
|
175
|
+
config.context_tenant = tenant_id
|
|
176
|
+
config.context_label = label
|
|
177
|
+
self.save_config(config)
|
|
178
|
+
|
|
179
|
+
def effective_tenant(self, explicit_tenant: Optional[str] = None) -> Optional[str]:
|
|
180
|
+
"""Return explicit tenant or the stored default tenant context."""
|
|
181
|
+
if explicit_tenant:
|
|
182
|
+
return explicit_tenant
|
|
183
|
+
config = self.load_config()
|
|
184
|
+
if config.context_type == "tenant":
|
|
185
|
+
return config.context_tenant
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
def context_display_name(self) -> str:
|
|
189
|
+
"""Return a human-readable current context label."""
|
|
190
|
+
config = self.load_config()
|
|
191
|
+
if config.context_type == "tenant":
|
|
192
|
+
return config.context_label or config.context_tenant or "Organization"
|
|
193
|
+
return "Personal"
|
|
194
|
+
|
|
150
195
|
|
|
151
196
|
# Global config manager instance
|
|
152
197
|
config_manager = ConfigManager()
|
|
@@ -4,7 +4,7 @@ import typer
|
|
|
4
4
|
from typing_extensions import Annotated
|
|
5
5
|
|
|
6
6
|
from . import __version__
|
|
7
|
-
from .commands import auth, integrations, publish, query, runner, validate
|
|
7
|
+
from .commands import auth, context, integrations, publish, query, runner, validate
|
|
8
8
|
from .tls import configure_tls_trust_store
|
|
9
9
|
|
|
10
10
|
configure_tls_trust_store()
|
|
@@ -19,6 +19,7 @@ app.command("login")(auth.login)
|
|
|
19
19
|
app.command("logout")(auth.logout)
|
|
20
20
|
app.command("status")(auth.status)
|
|
21
21
|
app.command("whoami")(auth.whoami)
|
|
22
|
+
app.command("context")(context.context)
|
|
22
23
|
app.command("publish")(publish.publish)
|
|
23
24
|
app.command("search")(query.search_servers)
|
|
24
25
|
app.command("favorites")(query.favorite_servers)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lightnow-cli"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.3"
|
|
8
8
|
description = "Official LightNow CLI for MCP registry discovery, client config sync, and local MCP runner workflows"
|
|
9
9
|
authors = [{name = "LightNow Team"}]
|
|
10
10
|
license = {text = "Apache-2.0"}
|
|
@@ -11,6 +11,7 @@ from typer.testing import CliRunner
|
|
|
11
11
|
from lightnow_cli.config import (
|
|
12
12
|
DEFAULT_CLIENT_ID,
|
|
13
13
|
DEFAULT_ISSUER,
|
|
14
|
+
LOCAL_ADMIN_API_URL,
|
|
14
15
|
LOCAL_ISSUER,
|
|
15
16
|
LOCAL_REGISTRY_API_URL,
|
|
16
17
|
)
|
|
@@ -51,7 +52,7 @@ def test_cli_version(runner):
|
|
|
51
52
|
"""Test CLI version command."""
|
|
52
53
|
result = runner.invoke(app, ["--version"])
|
|
53
54
|
assert result.exit_code == 0
|
|
54
|
-
assert "LightNow CLI 1.0.
|
|
55
|
+
assert "LightNow CLI 1.0.3" in result.stdout
|
|
55
56
|
|
|
56
57
|
|
|
57
58
|
def test_cli_help(runner):
|
|
@@ -69,6 +70,7 @@ def test_cli_help(runner):
|
|
|
69
70
|
assert "status" in result.stdout
|
|
70
71
|
assert "logout" in result.stdout
|
|
71
72
|
assert "whoami" in result.stdout
|
|
73
|
+
assert "context" in result.stdout
|
|
72
74
|
assert "list" not in result.stdout
|
|
73
75
|
assert "│ auth " not in result.stdout
|
|
74
76
|
assert "│ integrations " not in result.stdout
|
|
@@ -91,6 +93,7 @@ def test_login_help_is_customer_friendly(runner):
|
|
|
91
93
|
"logout",
|
|
92
94
|
"status",
|
|
93
95
|
"whoami",
|
|
96
|
+
"context",
|
|
94
97
|
"publish",
|
|
95
98
|
"search",
|
|
96
99
|
"favorites",
|
|
@@ -175,6 +178,173 @@ def test_run_command_starts_profile_server(runner):
|
|
|
175
178
|
assert run_process.call_args.kwargs["env"]["SONARQUBE_TOKEN"] == "secret"
|
|
176
179
|
|
|
177
180
|
|
|
181
|
+
def test_run_command_passes_tenant_to_profile_and_context_requests(runner):
|
|
182
|
+
"""The local runner keeps organization context for lookup and secret context."""
|
|
183
|
+
with (
|
|
184
|
+
patch(
|
|
185
|
+
"lightnow_cli.commands.runner.require_access_token",
|
|
186
|
+
return_value="token",
|
|
187
|
+
),
|
|
188
|
+
patch(
|
|
189
|
+
"lightnow_cli.commands.runner.resolve_profile_server",
|
|
190
|
+
return_value={
|
|
191
|
+
"alias": "sonarqube",
|
|
192
|
+
"server_name": "io.github.sonarsource/sonarqube-mcp-server",
|
|
193
|
+
"version": "1.2.3",
|
|
194
|
+
"status": "linked",
|
|
195
|
+
},
|
|
196
|
+
) as resolve,
|
|
197
|
+
patch(
|
|
198
|
+
"lightnow_cli.commands.runner.fetch_runtime_context",
|
|
199
|
+
return_value={"probe_request": {"transport": "stdio", "stdio": {}}},
|
|
200
|
+
) as fetch_context,
|
|
201
|
+
patch(
|
|
202
|
+
"lightnow_cli.commands.runner.launch_config_from_context",
|
|
203
|
+
return_value=("python", ["-m", "server"], {}, None),
|
|
204
|
+
),
|
|
205
|
+
patch(
|
|
206
|
+
"lightnow_cli.commands.runner.subprocess.run",
|
|
207
|
+
return_value=MagicMock(returncode=0),
|
|
208
|
+
),
|
|
209
|
+
):
|
|
210
|
+
result = runner.invoke(
|
|
211
|
+
app,
|
|
212
|
+
[
|
|
213
|
+
"run",
|
|
214
|
+
"--server",
|
|
215
|
+
"sonarqube",
|
|
216
|
+
"--profile",
|
|
217
|
+
"default",
|
|
218
|
+
"--tenant",
|
|
219
|
+
"acme",
|
|
220
|
+
"--api-url",
|
|
221
|
+
"https://registry-api.lightnow.local/v0.1",
|
|
222
|
+
],
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
assert result.exit_code == 0
|
|
226
|
+
assert resolve.call_args.kwargs["tenant"] == "acme"
|
|
227
|
+
assert fetch_context.call_args.kwargs["tenant"] == "acme"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_run_command_uses_stored_context(runner):
|
|
231
|
+
"""The local runner uses the stored organization context by default."""
|
|
232
|
+
with (
|
|
233
|
+
patch(
|
|
234
|
+
"lightnow_cli.commands.runner.require_access_token",
|
|
235
|
+
return_value="token",
|
|
236
|
+
),
|
|
237
|
+
patch(
|
|
238
|
+
"lightnow_cli.commands.runner.config_manager.effective_tenant",
|
|
239
|
+
return_value="tenant-uuid",
|
|
240
|
+
),
|
|
241
|
+
patch(
|
|
242
|
+
"lightnow_cli.commands.runner.resolve_profile_server",
|
|
243
|
+
return_value={
|
|
244
|
+
"alias": "sonarqube",
|
|
245
|
+
"server_name": "io.github.sonarsource/sonarqube-mcp-server",
|
|
246
|
+
"version": "1.2.3",
|
|
247
|
+
"status": "linked",
|
|
248
|
+
},
|
|
249
|
+
) as resolve,
|
|
250
|
+
patch(
|
|
251
|
+
"lightnow_cli.commands.runner.fetch_runtime_context",
|
|
252
|
+
return_value={"probe_request": {"transport": "stdio", "stdio": {}}},
|
|
253
|
+
) as fetch_context,
|
|
254
|
+
patch(
|
|
255
|
+
"lightnow_cli.commands.runner.launch_config_from_context",
|
|
256
|
+
return_value=("python", ["-m", "server"], {}, None),
|
|
257
|
+
),
|
|
258
|
+
patch(
|
|
259
|
+
"lightnow_cli.commands.runner.subprocess.run",
|
|
260
|
+
return_value=MagicMock(returncode=0),
|
|
261
|
+
),
|
|
262
|
+
):
|
|
263
|
+
result = runner.invoke(
|
|
264
|
+
app,
|
|
265
|
+
[
|
|
266
|
+
"run",
|
|
267
|
+
"--server",
|
|
268
|
+
"sonarqube",
|
|
269
|
+
"--profile",
|
|
270
|
+
"default",
|
|
271
|
+
"--api-url",
|
|
272
|
+
"https://registry-api.lightnow.local/v0.1",
|
|
273
|
+
],
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
assert result.exit_code == 0
|
|
277
|
+
assert resolve.call_args.kwargs["tenant"] == "tenant-uuid"
|
|
278
|
+
assert fetch_context.call_args.kwargs["tenant"] == "tenant-uuid"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def test_search_command_passes_tenant_context(runner):
|
|
282
|
+
"""Search supports organization context from the public CLI surface."""
|
|
283
|
+
fake_client = MagicMock()
|
|
284
|
+
fake_client.list_servers = AsyncMock(return_value={"servers": []})
|
|
285
|
+
|
|
286
|
+
with patch("lightnow_cli.commands.query.get_client", return_value=fake_client):
|
|
287
|
+
result = runner.invoke(
|
|
288
|
+
app, ["search", "redis", "--tenant", "acme", "--limit", "5"]
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
assert result.exit_code == 0
|
|
292
|
+
assert fake_client.list_servers.call_args.kwargs["tenant"] == "acme"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_search_command_uses_stored_context(runner):
|
|
296
|
+
"""Search uses the stored context when --tenant is omitted."""
|
|
297
|
+
fake_client = MagicMock()
|
|
298
|
+
fake_client.list_servers = AsyncMock(return_value={"servers": []})
|
|
299
|
+
|
|
300
|
+
with (
|
|
301
|
+
patch("lightnow_cli.commands.query.get_client", return_value=fake_client),
|
|
302
|
+
patch(
|
|
303
|
+
"lightnow_cli.commands.query.config_manager.effective_tenant",
|
|
304
|
+
return_value="tenant-uuid",
|
|
305
|
+
) as effective_tenant,
|
|
306
|
+
):
|
|
307
|
+
result = runner.invoke(app, ["search", "redis"])
|
|
308
|
+
|
|
309
|
+
assert result.exit_code == 0
|
|
310
|
+
effective_tenant.assert_called_once_with(None)
|
|
311
|
+
assert fake_client.list_servers.call_args.kwargs["tenant"] == "tenant-uuid"
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def test_favorites_command_passes_tenant_context(runner):
|
|
315
|
+
"""Favorites supports organization-scoped favorites from the CLI."""
|
|
316
|
+
fake_client = MagicMock()
|
|
317
|
+
fake_client.list_servers = AsyncMock(return_value={"servers": []})
|
|
318
|
+
|
|
319
|
+
with patch("lightnow_cli.commands.query.get_client", return_value=fake_client):
|
|
320
|
+
result = runner.invoke(
|
|
321
|
+
app, ["favorites", "--scope", "tenant", "--tenant", "acme"]
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
assert result.exit_code == 0
|
|
325
|
+
assert fake_client.list_servers.call_args.kwargs["favorites"] == "tenant"
|
|
326
|
+
assert fake_client.list_servers.call_args.kwargs["tenant"] == "acme"
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def test_info_command_passes_tenant_context(runner):
|
|
330
|
+
"""Server info supports organization context from the public CLI surface."""
|
|
331
|
+
fake_client = MagicMock()
|
|
332
|
+
fake_client.get_server_info = AsyncMock(
|
|
333
|
+
return_value={
|
|
334
|
+
"name": "io.github.test/server",
|
|
335
|
+
"version": "1.0.0",
|
|
336
|
+
}
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
with patch("lightnow_cli.commands.query.get_client", return_value=fake_client):
|
|
340
|
+
result = runner.invoke(
|
|
341
|
+
app, ["info", "io.github.test/server", "--tenant", "acme"]
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
assert result.exit_code == 0
|
|
345
|
+
assert fake_client.get_server_info.call_args.kwargs["tenant"] == "acme"
|
|
346
|
+
|
|
347
|
+
|
|
178
348
|
def test_run_command_reports_expired_token(runner):
|
|
179
349
|
"""Runner commands use the shared expired-token message."""
|
|
180
350
|
from lightnow_cli.commands.auth import (
|
|
@@ -304,7 +474,7 @@ def test_login_command_success(
|
|
|
304
474
|
|
|
305
475
|
# Verify mocks were called
|
|
306
476
|
mock_set_auth_config.assert_called_once_with(
|
|
307
|
-
DEFAULT_ISSUER, DEFAULT_CLIENT_ID, None
|
|
477
|
+
DEFAULT_ISSUER, DEFAULT_CLIENT_ID, None, None
|
|
308
478
|
)
|
|
309
479
|
mock_device_flow.assert_called_once_with(DEFAULT_ISSUER, DEFAULT_CLIENT_ID)
|
|
310
480
|
mock_fetch_user_info.assert_called_once_with(DEFAULT_ISSUER, "mock-access-token")
|
|
@@ -333,6 +503,7 @@ def test_login_command_local_profile(
|
|
|
333
503
|
LOCAL_ISSUER,
|
|
334
504
|
DEFAULT_CLIENT_ID,
|
|
335
505
|
LOCAL_REGISTRY_API_URL,
|
|
506
|
+
LOCAL_ADMIN_API_URL,
|
|
336
507
|
)
|
|
337
508
|
mock_device_flow.assert_called_once_with(LOCAL_ISSUER, DEFAULT_CLIENT_ID)
|
|
338
509
|
mock_fetch_user_info.assert_called_once_with(LOCAL_ISSUER, "mock-access-token")
|
|
@@ -221,6 +221,21 @@ async def test_client_lists_servers(monkeypatch) -> None:
|
|
|
221
221
|
assert fake.last_params["cursor"] == "cursor-token"
|
|
222
222
|
|
|
223
223
|
|
|
224
|
+
@pytest.mark.asyncio
|
|
225
|
+
async def test_client_lists_servers_with_tenant_header(monkeypatch) -> None:
|
|
226
|
+
"""Tenant context for list queries is sent as X-Tenant, not as a query param."""
|
|
227
|
+
fake = FakeHttpClient()
|
|
228
|
+
monkeypatch.setattr("lightnow_cli.client.httpx.AsyncClient", lambda: fake)
|
|
229
|
+
|
|
230
|
+
client = MCPRegistryClient(
|
|
231
|
+
base_url="https://registry-api.lightnow.local/v0.1", token="token"
|
|
232
|
+
)
|
|
233
|
+
await client.list_servers(favorites="tenant", tenant="tenant-uuid")
|
|
234
|
+
|
|
235
|
+
assert fake.last_headers["X-Tenant"] == "tenant-uuid"
|
|
236
|
+
assert "tenant" not in fake.last_params
|
|
237
|
+
|
|
238
|
+
|
|
224
239
|
@pytest.mark.asyncio
|
|
225
240
|
async def test_client_gets_server_info(monkeypatch) -> None:
|
|
226
241
|
"""Server info lookup returns the decoded registry payload."""
|
|
@@ -236,6 +251,21 @@ async def test_client_gets_server_info(monkeypatch) -> None:
|
|
|
236
251
|
assert fake.last_url.endswith("/servers/github/versions/1.0.0")
|
|
237
252
|
|
|
238
253
|
|
|
254
|
+
@pytest.mark.asyncio
|
|
255
|
+
async def test_client_gets_server_info_with_tenant_header(monkeypatch) -> None:
|
|
256
|
+
"""Tenant context for server detail lookup is sent as X-Tenant."""
|
|
257
|
+
fake = FakeHttpClient()
|
|
258
|
+
monkeypatch.setattr("lightnow_cli.client.httpx.AsyncClient", lambda: fake)
|
|
259
|
+
|
|
260
|
+
client = MCPRegistryClient(
|
|
261
|
+
base_url="https://registry-api.lightnow.local/v0.1", token="token"
|
|
262
|
+
)
|
|
263
|
+
await client.get_server_info("github", version="1.0.0", tenant="tenant-uuid")
|
|
264
|
+
|
|
265
|
+
assert fake.last_headers["X-Tenant"] == "tenant-uuid"
|
|
266
|
+
assert "tenant" not in fake.last_params
|
|
267
|
+
|
|
268
|
+
|
|
239
269
|
@pytest.mark.asyncio
|
|
240
270
|
async def test_client_encodes_registry_server_names(monkeypatch) -> None:
|
|
241
271
|
"""Registry server names with slashes are encoded in path parameters."""
|
|
@@ -7,6 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
import pytest
|
|
8
8
|
|
|
9
9
|
from lightnow_cli.config import (
|
|
10
|
+
DEFAULT_ADMIN_API_URL,
|
|
10
11
|
DEFAULT_CLIENT_ID,
|
|
11
12
|
DEFAULT_ISSUER,
|
|
12
13
|
DEFAULT_REGISTRY_API_URL,
|
|
@@ -48,6 +49,9 @@ def test_load_default_config(config_manager):
|
|
|
48
49
|
assert config.issuer == DEFAULT_ISSUER
|
|
49
50
|
assert config.client_id == DEFAULT_CLIENT_ID
|
|
50
51
|
assert config.registry_api_url == DEFAULT_REGISTRY_API_URL
|
|
52
|
+
assert config.admin_api_url == DEFAULT_ADMIN_API_URL
|
|
53
|
+
assert config.context_type == "personal"
|
|
54
|
+
assert config.context_tenant is None
|
|
51
55
|
|
|
52
56
|
|
|
53
57
|
def test_save_and_load_config(config_manager):
|
|
@@ -120,20 +124,54 @@ def test_auth_config_methods(config_manager):
|
|
|
120
124
|
assert config.issuer == "https://auth.example.com"
|
|
121
125
|
assert config.client_id == "my-client"
|
|
122
126
|
assert config.registry_api_url == DEFAULT_REGISTRY_API_URL
|
|
127
|
+
assert config.admin_api_url == DEFAULT_ADMIN_API_URL
|
|
123
128
|
|
|
124
129
|
|
|
125
|
-
def
|
|
126
|
-
"""Local auth can persist
|
|
130
|
+
def test_auth_config_accepts_api_overrides(config_manager):
|
|
131
|
+
"""Local auth can persist matching local API URLs."""
|
|
127
132
|
config_manager.set_auth_config(
|
|
128
133
|
"https://auth.example.com",
|
|
129
134
|
"my-client",
|
|
130
135
|
"https://registry-api.example.com/v0.1",
|
|
136
|
+
"https://admin-api.example.com/v0/portal",
|
|
131
137
|
)
|
|
132
138
|
|
|
133
139
|
config = config_manager.load_config()
|
|
134
140
|
assert config.issuer == "https://auth.example.com"
|
|
135
141
|
assert config.client_id == "my-client"
|
|
136
142
|
assert config.registry_api_url == "https://registry-api.example.com/v0.1"
|
|
143
|
+
assert config.admin_api_url == "https://admin-api.example.com/v0/portal"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_context_methods(config_manager):
|
|
147
|
+
"""Stored context controls default tenant headers."""
|
|
148
|
+
assert config_manager.effective_tenant() is None
|
|
149
|
+
assert config_manager.context_display_name() == "Personal"
|
|
150
|
+
|
|
151
|
+
config_manager.set_tenant_context("tenant-uuid", "Acme (acme)")
|
|
152
|
+
|
|
153
|
+
assert config_manager.effective_tenant() == "tenant-uuid"
|
|
154
|
+
assert config_manager.effective_tenant("explicit-tenant") == "explicit-tenant"
|
|
155
|
+
assert config_manager.context_display_name() == "Acme (acme)"
|
|
156
|
+
|
|
157
|
+
config_manager.set_personal_context()
|
|
158
|
+
|
|
159
|
+
assert config_manager.effective_tenant() is None
|
|
160
|
+
assert config_manager.context_display_name() == "Personal"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_logout_clears_context(config_manager):
|
|
164
|
+
"""Logging out removes account-scoped organization context."""
|
|
165
|
+
config_manager.set_token("token", "refresh", None)
|
|
166
|
+
config_manager.set_tenant_context("tenant-uuid", "Acme (acme)")
|
|
167
|
+
|
|
168
|
+
config_manager.clear_token()
|
|
169
|
+
|
|
170
|
+
config = config_manager.load_config()
|
|
171
|
+
assert config.access_token is None
|
|
172
|
+
assert config.refresh_token is None
|
|
173
|
+
assert config.context_type == "personal"
|
|
174
|
+
assert config.context_tenant is None
|
|
137
175
|
|
|
138
176
|
|
|
139
177
|
def test_config_file_permissions(config_manager):
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Tests for LightNow context selection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import MagicMock, patch
|
|
6
|
+
|
|
7
|
+
from typer.testing import CliRunner
|
|
8
|
+
|
|
9
|
+
from lightnow_cli.config import Config
|
|
10
|
+
from lightnow_cli.main import app
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FakeResponse:
|
|
14
|
+
"""Small response stub for context API tests."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, status_code: int, payload: object, text: str = "") -> None:
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
self._payload = payload
|
|
19
|
+
self.text = text
|
|
20
|
+
|
|
21
|
+
def json(self) -> object:
|
|
22
|
+
return self._payload
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_context_show_displays_personal_context() -> None:
|
|
26
|
+
"""The default context is Personal."""
|
|
27
|
+
runner = CliRunner()
|
|
28
|
+
|
|
29
|
+
with patch(
|
|
30
|
+
"lightnow_cli.commands.context.config_manager.load_config",
|
|
31
|
+
return_value=Config(),
|
|
32
|
+
):
|
|
33
|
+
result = runner.invoke(app, ["context", "--show"])
|
|
34
|
+
|
|
35
|
+
assert result.exit_code == 0
|
|
36
|
+
assert "Personal" in result.stdout
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_context_personal_stores_personal_context() -> None:
|
|
40
|
+
"""Users can switch back to personal context explicitly."""
|
|
41
|
+
runner = CliRunner()
|
|
42
|
+
|
|
43
|
+
with patch(
|
|
44
|
+
"lightnow_cli.commands.context.config_manager.set_personal_context"
|
|
45
|
+
) as set_personal:
|
|
46
|
+
result = runner.invoke(app, ["context", "--personal"])
|
|
47
|
+
|
|
48
|
+
assert result.exit_code == 0
|
|
49
|
+
set_personal.assert_called_once_with()
|
|
50
|
+
assert "Context set to Personal" in result.stdout
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_context_tenant_resolves_subdomain_and_stores_tenant_id() -> None:
|
|
54
|
+
"""Tenant subdomains are resolved once and stored as tenant IDs."""
|
|
55
|
+
runner = CliRunner()
|
|
56
|
+
response = FakeResponse(
|
|
57
|
+
200,
|
|
58
|
+
[
|
|
59
|
+
{
|
|
60
|
+
"id": "tenant-uuid",
|
|
61
|
+
"name": "Acme Inc.",
|
|
62
|
+
"subdomain": "acme",
|
|
63
|
+
"role": "owner",
|
|
64
|
+
"plan": "enterprise",
|
|
65
|
+
}
|
|
66
|
+
],
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
with (
|
|
70
|
+
patch(
|
|
71
|
+
"lightnow_cli.commands.context.require_access_token", return_value="token"
|
|
72
|
+
),
|
|
73
|
+
patch(
|
|
74
|
+
"lightnow_cli.commands.context.config_manager.load_config",
|
|
75
|
+
return_value=Config(
|
|
76
|
+
admin_api_url="https://admin-api.example.com/v0/portal"
|
|
77
|
+
),
|
|
78
|
+
),
|
|
79
|
+
patch(
|
|
80
|
+
"lightnow_cli.commands.context.request_with_refresh", return_value=response
|
|
81
|
+
) as request,
|
|
82
|
+
patch(
|
|
83
|
+
"lightnow_cli.commands.context.config_manager.set_tenant_context"
|
|
84
|
+
) as set_tenant,
|
|
85
|
+
):
|
|
86
|
+
result = runner.invoke(app, ["context", "--tenant", "acme"])
|
|
87
|
+
|
|
88
|
+
assert result.exit_code == 0
|
|
89
|
+
request.assert_called_once()
|
|
90
|
+
assert request.call_args.args[:2] == (
|
|
91
|
+
"GET",
|
|
92
|
+
"https://admin-api.example.com/v0/portal/tenants",
|
|
93
|
+
)
|
|
94
|
+
set_tenant.assert_called_once_with("tenant-uuid", "Acme Inc. (acme)")
|
|
95
|
+
assert "Context set to organization Acme Inc. (acme)" in result.stdout
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_context_unknown_tenant_fails_without_changing_context() -> None:
|
|
99
|
+
"""Unknown tenants fail explicitly."""
|
|
100
|
+
runner = CliRunner()
|
|
101
|
+
response = FakeResponse(
|
|
102
|
+
200,
|
|
103
|
+
[{"id": "tenant-uuid", "name": "Acme Inc.", "subdomain": "acme"}],
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
with (
|
|
107
|
+
patch(
|
|
108
|
+
"lightnow_cli.commands.context.require_access_token", return_value="token"
|
|
109
|
+
),
|
|
110
|
+
patch(
|
|
111
|
+
"lightnow_cli.commands.context.config_manager.load_config",
|
|
112
|
+
return_value=Config(
|
|
113
|
+
admin_api_url="https://admin-api.example.com/v0/portal"
|
|
114
|
+
),
|
|
115
|
+
),
|
|
116
|
+
patch(
|
|
117
|
+
"lightnow_cli.commands.context.request_with_refresh", return_value=response
|
|
118
|
+
),
|
|
119
|
+
patch(
|
|
120
|
+
"lightnow_cli.commands.context.config_manager.set_tenant_context"
|
|
121
|
+
) as set_tenant,
|
|
122
|
+
):
|
|
123
|
+
result = runner.invoke(app, ["context", "--tenant", "missing"])
|
|
124
|
+
|
|
125
|
+
assert result.exit_code == 1
|
|
126
|
+
set_tenant.assert_not_called()
|
|
127
|
+
assert "No organization matches 'missing'" in result.stdout
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_context_interactive_choice_stores_selected_tenant() -> None:
|
|
131
|
+
"""Interactive context selection stores the chosen organization."""
|
|
132
|
+
runner = CliRunner()
|
|
133
|
+
response = FakeResponse(
|
|
134
|
+
200,
|
|
135
|
+
[
|
|
136
|
+
{"id": "tenant-uuid", "name": "Acme Inc.", "subdomain": "acme"},
|
|
137
|
+
],
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
with (
|
|
141
|
+
patch(
|
|
142
|
+
"lightnow_cli.commands.context.require_access_token", return_value="token"
|
|
143
|
+
),
|
|
144
|
+
patch(
|
|
145
|
+
"lightnow_cli.commands.context.config_manager.load_config",
|
|
146
|
+
return_value=Config(
|
|
147
|
+
admin_api_url="https://admin-api.example.com/v0/portal"
|
|
148
|
+
),
|
|
149
|
+
),
|
|
150
|
+
patch(
|
|
151
|
+
"lightnow_cli.commands.context.request_with_refresh", return_value=response
|
|
152
|
+
),
|
|
153
|
+
patch(
|
|
154
|
+
"lightnow_cli.commands.context.config_manager.set_tenant_context"
|
|
155
|
+
) as set_tenant,
|
|
156
|
+
):
|
|
157
|
+
result = runner.invoke(app, ["context"], input="2\n")
|
|
158
|
+
|
|
159
|
+
assert result.exit_code == 0
|
|
160
|
+
set_tenant.assert_called_once_with("tenant-uuid", "Acme Inc. (acme)")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_context_api_401_uses_authentication_error() -> None:
|
|
164
|
+
"""Tenant listing uses the shared auth error path."""
|
|
165
|
+
runner = CliRunner()
|
|
166
|
+
response = MagicMock()
|
|
167
|
+
response.status_code = 401
|
|
168
|
+
|
|
169
|
+
with (
|
|
170
|
+
patch(
|
|
171
|
+
"lightnow_cli.commands.context.require_access_token", return_value="token"
|
|
172
|
+
),
|
|
173
|
+
patch(
|
|
174
|
+
"lightnow_cli.commands.context.config_manager.load_config",
|
|
175
|
+
return_value=Config(
|
|
176
|
+
admin_api_url="https://admin-api.example.com/v0/portal"
|
|
177
|
+
),
|
|
178
|
+
),
|
|
179
|
+
patch(
|
|
180
|
+
"lightnow_cli.commands.context.request_with_refresh", return_value=response
|
|
181
|
+
),
|
|
182
|
+
patch(
|
|
183
|
+
"lightnow_cli.commands.context.authentication_error_from_response",
|
|
184
|
+
side_effect=ValueError("unauthorized"),
|
|
185
|
+
),
|
|
186
|
+
):
|
|
187
|
+
result = runner.invoke(app, ["context", "--tenant", "acme"])
|
|
188
|
+
|
|
189
|
+
assert result.exit_code == 1
|
|
190
|
+
assert "Context failed" in result.stdout
|
|
@@ -173,6 +173,74 @@ def test_sync_defaults_to_plaintext_export_mode() -> None:
|
|
|
173
173
|
assert fetch.call_args.kwargs["secret_mode"] == "plaintext"
|
|
174
174
|
|
|
175
175
|
|
|
176
|
+
def test_sync_passes_tenant_to_export_request() -> None:
|
|
177
|
+
"""Client export sync uses the selected organization context."""
|
|
178
|
+
runner = CliRunner()
|
|
179
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
180
|
+
target = Path(tmp) / "config.toml"
|
|
181
|
+
with (
|
|
182
|
+
patch(
|
|
183
|
+
"lightnow_cli.commands.integrations.require_access_token",
|
|
184
|
+
return_value="token",
|
|
185
|
+
),
|
|
186
|
+
patch(
|
|
187
|
+
"lightnow_cli.commands.integrations.fetch_export",
|
|
188
|
+
return_value='[mcp_servers.github]\ncommand = "docker"\n',
|
|
189
|
+
) as fetch,
|
|
190
|
+
):
|
|
191
|
+
result = runner.invoke(
|
|
192
|
+
app,
|
|
193
|
+
[
|
|
194
|
+
"sync",
|
|
195
|
+
"--client",
|
|
196
|
+
"codex",
|
|
197
|
+
"--tenant",
|
|
198
|
+
"acme",
|
|
199
|
+
"--config-path",
|
|
200
|
+
str(target),
|
|
201
|
+
],
|
|
202
|
+
input="y\n",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
assert result.exit_code == 0
|
|
206
|
+
assert fetch.call_args.kwargs["tenant"] == "acme"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_sync_uses_stored_context_when_tenant_is_omitted() -> None:
|
|
210
|
+
"""Client export sync uses the stored organization context by default."""
|
|
211
|
+
runner = CliRunner()
|
|
212
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
213
|
+
target = Path(tmp) / "config.toml"
|
|
214
|
+
with (
|
|
215
|
+
patch(
|
|
216
|
+
"lightnow_cli.commands.integrations.require_access_token",
|
|
217
|
+
return_value="token",
|
|
218
|
+
),
|
|
219
|
+
patch(
|
|
220
|
+
"lightnow_cli.commands.integrations.config_manager.effective_tenant",
|
|
221
|
+
return_value="tenant-uuid",
|
|
222
|
+
),
|
|
223
|
+
patch(
|
|
224
|
+
"lightnow_cli.commands.integrations.fetch_export",
|
|
225
|
+
return_value='[mcp_servers.github]\ncommand = "docker"\n',
|
|
226
|
+
) as fetch,
|
|
227
|
+
):
|
|
228
|
+
result = runner.invoke(
|
|
229
|
+
app,
|
|
230
|
+
[
|
|
231
|
+
"sync",
|
|
232
|
+
"--client",
|
|
233
|
+
"codex",
|
|
234
|
+
"--config-path",
|
|
235
|
+
str(target),
|
|
236
|
+
],
|
|
237
|
+
input="y\n",
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
assert result.exit_code == 0
|
|
241
|
+
assert fetch.call_args.kwargs["tenant"] == "tenant-uuid"
|
|
242
|
+
|
|
243
|
+
|
|
176
244
|
def test_sync_json_writes_manifest_and_preserves_user_config() -> None:
|
|
177
245
|
"""JSON sync stores the LightNow-owned aliases in a sibling manifest."""
|
|
178
246
|
runner = CliRunner()
|
|
@@ -509,6 +577,98 @@ def test_sync_runner_dry_run_does_not_fetch_plaintext_export() -> None:
|
|
|
509
577
|
assert not target.exists()
|
|
510
578
|
|
|
511
579
|
|
|
580
|
+
def test_sync_runner_passes_tenant_to_profile_server_lookup() -> None:
|
|
581
|
+
"""Runner sync builds organization-aware client commands from tenant profiles."""
|
|
582
|
+
runner = CliRunner()
|
|
583
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
584
|
+
target = Path(tmp) / "config.toml"
|
|
585
|
+
with (
|
|
586
|
+
patch(
|
|
587
|
+
"lightnow_cli.commands.integrations.require_access_token",
|
|
588
|
+
return_value="token",
|
|
589
|
+
),
|
|
590
|
+
patch(
|
|
591
|
+
"lightnow_cli.commands.integrations.fetch_profile_servers",
|
|
592
|
+
return_value={
|
|
593
|
+
"servers": [
|
|
594
|
+
{
|
|
595
|
+
"alias": "sonarqube",
|
|
596
|
+
"server_name": "io.github.sonarsource/sonarqube-mcp-server",
|
|
597
|
+
"version": "1.2.3",
|
|
598
|
+
"status": "linked",
|
|
599
|
+
}
|
|
600
|
+
]
|
|
601
|
+
},
|
|
602
|
+
) as fetch_profile,
|
|
603
|
+
):
|
|
604
|
+
result = runner.invoke(
|
|
605
|
+
app,
|
|
606
|
+
[
|
|
607
|
+
"sync",
|
|
608
|
+
"--client",
|
|
609
|
+
"codex",
|
|
610
|
+
"--tenant",
|
|
611
|
+
"acme",
|
|
612
|
+
"--runner",
|
|
613
|
+
"--config-path",
|
|
614
|
+
str(target),
|
|
615
|
+
"--dry-run",
|
|
616
|
+
],
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
assert result.exit_code == 0
|
|
620
|
+
assert fetch_profile.call_args.kwargs["tenant"] == "acme"
|
|
621
|
+
assert "--tenant" in result.stdout
|
|
622
|
+
assert "acme" in result.stdout
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def test_sync_runner_uses_stored_context_in_generated_wrappers() -> None:
|
|
626
|
+
"""Runner sync embeds the stored organization context in client wrappers."""
|
|
627
|
+
runner = CliRunner()
|
|
628
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
629
|
+
target = Path(tmp) / "config.toml"
|
|
630
|
+
with (
|
|
631
|
+
patch(
|
|
632
|
+
"lightnow_cli.commands.integrations.require_access_token",
|
|
633
|
+
return_value="token",
|
|
634
|
+
),
|
|
635
|
+
patch(
|
|
636
|
+
"lightnow_cli.commands.integrations.config_manager.effective_tenant",
|
|
637
|
+
return_value="tenant-uuid",
|
|
638
|
+
),
|
|
639
|
+
patch(
|
|
640
|
+
"lightnow_cli.commands.integrations.fetch_profile_servers",
|
|
641
|
+
return_value={
|
|
642
|
+
"servers": [
|
|
643
|
+
{
|
|
644
|
+
"alias": "sonarqube",
|
|
645
|
+
"server_name": "io.github.sonarsource/sonarqube-mcp-server",
|
|
646
|
+
"version": "1.2.3",
|
|
647
|
+
"status": "linked",
|
|
648
|
+
}
|
|
649
|
+
]
|
|
650
|
+
},
|
|
651
|
+
) as fetch_profile,
|
|
652
|
+
):
|
|
653
|
+
result = runner.invoke(
|
|
654
|
+
app,
|
|
655
|
+
[
|
|
656
|
+
"sync",
|
|
657
|
+
"--client",
|
|
658
|
+
"codex",
|
|
659
|
+
"--runner",
|
|
660
|
+
"--config-path",
|
|
661
|
+
str(target),
|
|
662
|
+
"--dry-run",
|
|
663
|
+
],
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
assert result.exit_code == 0
|
|
667
|
+
assert fetch_profile.call_args.kwargs["tenant"] == "tenant-uuid"
|
|
668
|
+
assert "--tenant" in result.stdout
|
|
669
|
+
assert "tenant-uuid" in result.stdout
|
|
670
|
+
|
|
671
|
+
|
|
512
672
|
def test_fetch_runtime_context_requests_local_runner_secret_context() -> None:
|
|
513
673
|
"""The runner asks for secret context with consumer=local-runner."""
|
|
514
674
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|