lightnow-cli 1.0.2__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.2 → lightnow_cli-1.0.3}/PKG-INFO +1 -1
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/lightnow_cli/__init__.py +1 -1
- {lightnow_cli-1.0.2 → 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.2 → lightnow_cli-1.0.3}/lightnow_cli/commands/integrations.py +4 -3
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/lightnow_cli/commands/publish.py +3 -1
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/lightnow_cli/commands/query.py +11 -3
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/lightnow_cli/commands/runner.py +3 -2
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/lightnow_cli/config.py +46 -1
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/lightnow_cli/main.py +2 -1
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/pyproject.toml +1 -1
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/tests/test_cli.py +76 -2
- {lightnow_cli-1.0.2 → 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.2 → lightnow_cli-1.0.3}/tests/test_integrations.py +82 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/.github/workflows/ci.yml +0 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/.github/workflows/release.yml +0 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/.gitignore +0 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/CHANGELOG.md +0 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/CONTRIBUTING.md +0 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/LICENSE +0 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/Makefile +0 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/README.md +0 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/SECURITY.md +0 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/docs/development.md +0 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/docs/release.md +0 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/examples/README.md +0 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/examples/docs.md +0 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/examples/openapi.json +0 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/examples/server.json +0 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/lightnow_cli/authenticated_http.py +0 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/lightnow_cli/client.py +0 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/lightnow_cli/commands/__init__.py +0 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/lightnow_cli/commands/validate.py +0 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/lightnow_cli/tls.py +0 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/lightnow_cli/validation.py +0 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/tests/__init__.py +0 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/tests/test_auth_flow.py +0 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/tests/test_client.py +0 -0
- {lightnow_cli-1.0.2 → lightnow_cli-1.0.3}/tests/test_tls.py +0 -0
- {lightnow_cli-1.0.2 → 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
|
|
@@ -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",
|
|
@@ -224,6 +227,57 @@ def test_run_command_passes_tenant_to_profile_and_context_requests(runner):
|
|
|
224
227
|
assert fetch_context.call_args.kwargs["tenant"] == "acme"
|
|
225
228
|
|
|
226
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
|
+
|
|
227
281
|
def test_search_command_passes_tenant_context(runner):
|
|
228
282
|
"""Search supports organization context from the public CLI surface."""
|
|
229
283
|
fake_client = MagicMock()
|
|
@@ -238,6 +292,25 @@ def test_search_command_passes_tenant_context(runner):
|
|
|
238
292
|
assert fake_client.list_servers.call_args.kwargs["tenant"] == "acme"
|
|
239
293
|
|
|
240
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
|
+
|
|
241
314
|
def test_favorites_command_passes_tenant_context(runner):
|
|
242
315
|
"""Favorites supports organization-scoped favorites from the CLI."""
|
|
243
316
|
fake_client = MagicMock()
|
|
@@ -401,7 +474,7 @@ def test_login_command_success(
|
|
|
401
474
|
|
|
402
475
|
# Verify mocks were called
|
|
403
476
|
mock_set_auth_config.assert_called_once_with(
|
|
404
|
-
DEFAULT_ISSUER, DEFAULT_CLIENT_ID, None
|
|
477
|
+
DEFAULT_ISSUER, DEFAULT_CLIENT_ID, None, None
|
|
405
478
|
)
|
|
406
479
|
mock_device_flow.assert_called_once_with(DEFAULT_ISSUER, DEFAULT_CLIENT_ID)
|
|
407
480
|
mock_fetch_user_info.assert_called_once_with(DEFAULT_ISSUER, "mock-access-token")
|
|
@@ -430,6 +503,7 @@ def test_login_command_local_profile(
|
|
|
430
503
|
LOCAL_ISSUER,
|
|
431
504
|
DEFAULT_CLIENT_ID,
|
|
432
505
|
LOCAL_REGISTRY_API_URL,
|
|
506
|
+
LOCAL_ADMIN_API_URL,
|
|
433
507
|
)
|
|
434
508
|
mock_device_flow.assert_called_once_with(LOCAL_ISSUER, DEFAULT_CLIENT_ID)
|
|
435
509
|
mock_fetch_user_info.assert_called_once_with(LOCAL_ISSUER, "mock-access-token")
|
|
@@ -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
|
|
@@ -206,6 +206,41 @@ def test_sync_passes_tenant_to_export_request() -> None:
|
|
|
206
206
|
assert fetch.call_args.kwargs["tenant"] == "acme"
|
|
207
207
|
|
|
208
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
|
+
|
|
209
244
|
def test_sync_json_writes_manifest_and_preserves_user_config() -> None:
|
|
210
245
|
"""JSON sync stores the LightNow-owned aliases in a sibling manifest."""
|
|
211
246
|
runner = CliRunner()
|
|
@@ -587,6 +622,53 @@ def test_sync_runner_passes_tenant_to_profile_server_lookup() -> None:
|
|
|
587
622
|
assert "acme" in result.stdout
|
|
588
623
|
|
|
589
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
|
+
|
|
590
672
|
def test_fetch_runtime_context_requests_local_runner_secret_context() -> None:
|
|
591
673
|
"""The runner asks for secret context with consumer=local-runner."""
|
|
592
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
|
|
File without changes
|
|
File without changes
|