lightnow-cli 1.7.0__tar.gz → 1.7.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/.github/workflows/ci.yml +2 -2
  2. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/.github/workflows/release.yml +3 -3
  3. lightnow_cli-1.7.1/.release-please-manifest.json +3 -0
  4. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/CHANGELOG.md +7 -0
  5. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/PKG-INFO +1 -1
  6. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/lightnow_cli/__init__.py +1 -1
  7. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/lightnow_cli/commands/integrations.py +31 -2
  8. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/pyproject.toml +1 -1
  9. lightnow_cli-1.7.1/tests/test_import_preconditions.py +197 -0
  10. lightnow_cli-1.7.0/.release-please-manifest.json +0 -3
  11. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/.github/workflows/release-please.yml +0 -0
  12. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/.gitignore +0 -0
  13. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/CONTRIBUTING.md +0 -0
  14. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/LICENSE +0 -0
  15. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/Makefile +0 -0
  16. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/README.md +0 -0
  17. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/SECURITY.md +0 -0
  18. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/ci/create_client_artifact.py +0 -0
  19. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/ci/test_client_artifact.py +0 -0
  20. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/docs/development.md +0 -0
  21. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/docs/release.md +0 -0
  22. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/examples/README.md +0 -0
  23. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/examples/docs.md +0 -0
  24. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/examples/openapi.json +0 -0
  25. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/examples/server.json +0 -0
  26. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/lightnow_cli/authenticated_http.py +0 -0
  27. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/lightnow_cli/client.py +0 -0
  28. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/lightnow_cli/commands/__init__.py +0 -0
  29. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/lightnow_cli/commands/auth.py +0 -0
  30. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/lightnow_cli/commands/context.py +0 -0
  31. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/lightnow_cli/commands/publish.py +0 -0
  32. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/lightnow_cli/commands/query.py +0 -0
  33. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/lightnow_cli/commands/runner.py +0 -0
  34. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/lightnow_cli/commands/updates.py +0 -0
  35. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/lightnow_cli/commands/validate.py +0 -0
  36. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/lightnow_cli/config.py +0 -0
  37. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/lightnow_cli/main.py +0 -0
  38. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/lightnow_cli/target.py +0 -0
  39. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/lightnow_cli/tls.py +0 -0
  40. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/lightnow_cli/updates.py +0 -0
  41. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/lightnow_cli/validation.py +0 -0
  42. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/release-please-config.json +0 -0
  43. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/renovate.json +0 -0
  44. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/tests/__init__.py +0 -0
  45. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/tests/test_auth_flow.py +0 -0
  46. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/tests/test_authenticated_http.py +0 -0
  47. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/tests/test_cli.py +0 -0
  48. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/tests/test_client.py +0 -0
  49. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/tests/test_config.py +0 -0
  50. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/tests/test_connection_target.py +0 -0
  51. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/tests/test_context.py +0 -0
  52. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/tests/test_integrations.py +0 -0
  53. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/tests/test_tls.py +0 -0
  54. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/tests/test_updates.py +0 -0
  55. {lightnow_cli-1.7.0 → lightnow_cli-1.7.1}/tests/test_validation.py +0 -0
@@ -26,7 +26,7 @@ jobs:
26
26
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
27
27
 
28
28
  - name: Set up Python
29
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
29
+ uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
30
30
  with:
31
31
  python-version: ${{ matrix.python-version }}
32
32
  cache: pip
@@ -62,7 +62,7 @@ jobs:
62
62
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
63
63
 
64
64
  - name: Set up Python
65
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
65
+ uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
66
66
  with:
67
67
  python-version: "3.12"
68
68
  cache: pip
@@ -18,7 +18,7 @@ jobs:
18
18
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
19
19
 
20
20
  - name: Set up Python
21
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
21
+ uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
22
22
  with:
23
23
  python-version: "3.12"
24
24
  cache: pip
@@ -39,7 +39,7 @@ jobs:
39
39
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
40
40
 
41
41
  - name: Set up Python
42
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
42
+ uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
43
43
  with:
44
44
  python-version: "3.12"
45
45
  cache: pip
@@ -95,7 +95,7 @@ jobs:
95
95
  path: dist
96
96
 
97
97
  - name: Publish distributions to PyPI
98
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
98
+ uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # release/v1
99
99
 
100
100
  update-homebrew:
101
101
  name: Update Homebrew formula
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "1.7.1"
3
+ }
@@ -4,6 +4,13 @@ All notable changes to the LightNow CLI are documented here.
4
4
 
5
5
  This project follows semantic versioning.
6
6
 
7
+ ## [1.7.1](https://github.com/lightnow-ai/lightnow-cli/compare/v1.7.0...v1.7.1) (2026-11-14)
8
+
9
+
10
+ ### Bug Fixes
11
+
12
+ * honor profile revisions when importing MCP config ([#50](https://github.com/lightnow-ai/lightnow-cli/issues/50)) ([343ab0d](https://github.com/lightnow-ai/lightnow-cli/commit/343ab0d545c19d3c9a44fb9097ed06e5f00ca3fb))
13
+
7
14
  ## [1.7.0](https://github.com/lightnow-ai/lightnow-cli/compare/v1.6.1...v1.7.0) (2026-11-13)
8
15
 
9
16
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lightnow-cli
3
- Version: 1.7.0
3
+ Version: 1.7.1
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
@@ -1,3 +1,3 @@
1
1
  """LightNow CLI - A command-line tool for managing MCP servers."""
2
2
 
3
- __version__ = "1.7.0" # x-release-please-version
3
+ __version__ = "1.7.1" # x-release-please-version
@@ -898,7 +898,9 @@ def import_config(
898
898
  ] = "default",
899
899
  tenant: Annotated[
900
900
  Optional[str],
901
- typer.Option("--tenant", help="Tenant id or slug, sent as X-Tenant"),
901
+ typer.Option(
902
+ "--tenant", help="Organization ID; defaults to the selected context"
903
+ ),
902
904
  ] = None,
903
905
  config_path: Annotated[
904
906
  Optional[Path],
@@ -1699,7 +1701,34 @@ def import_profile_config(
1699
1701
  ) -> dict[str, Any]:
1700
1702
  """Import rendered client config into a LightNow runtime profile."""
1701
1703
  url = f"{api_url.rstrip('/')}/integrations/import"
1704
+ headers = {"Accept": "application/json"}
1702
1705
  try:
1706
+ if not dry_run:
1707
+ current = request_with_refresh(
1708
+ "GET",
1709
+ f"{api_url.rstrip('/')}/integrations/profiles",
1710
+ headers=headers,
1711
+ token=token,
1712
+ tenant=tenant,
1713
+ timeout=30.0,
1714
+ )
1715
+ if current.status_code == 401:
1716
+ raise authentication_error_from_response(current)
1717
+ if current.status_code != 200:
1718
+ raise ValueError(f"HTTP {current.status_code}: {redact(current.text)}")
1719
+ etag = current.headers.get("ETag")
1720
+ if (
1721
+ etag is None
1722
+ or re.fullmatch(r'"[\x21\x23-\x7e\x80-\xff]*"', etag) is None
1723
+ ):
1724
+ raise ValueError(
1725
+ "Could not read the current profile version. No import was applied."
1726
+ )
1727
+ headers["If-Match"] = etag
1728
+ # The snapshot request may have refreshed an expired session.
1729
+ authorization = current.request.headers.get("Authorization", "")
1730
+ if authorization.startswith("Bearer "):
1731
+ token = authorization.removeprefix("Bearer ")
1703
1732
  response = request_with_refresh(
1704
1733
  "POST",
1705
1734
  url,
@@ -1710,7 +1739,7 @@ def import_profile_config(
1710
1739
  "profile": {"name": profile},
1711
1740
  "replace": replace,
1712
1741
  },
1713
- headers={"Accept": "application/json"},
1742
+ headers=headers,
1714
1743
  token=token,
1715
1744
  tenant=tenant,
1716
1745
  timeout=30.0,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "lightnow-cli"
7
- version = "1.7.0"
7
+ version = "1.7.1"
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"}
@@ -0,0 +1,197 @@
1
+ """Import writes retain the API's optimistic concurrency and permission checks."""
2
+
3
+ import json
4
+ from unittest.mock import patch
5
+
6
+ import httpx
7
+ import pytest
8
+
9
+ from lightnow_cli.commands.integrations import import_profile_config
10
+
11
+ API = "https://registry.example.test/v0.1"
12
+ CONTENT = '[mcp_servers.example]\ncommand = "example-mcp"\n'
13
+ ETAG = '"owner-snapshot-1"'
14
+
15
+
16
+ def import_config(
17
+ *, dry_run=False, tenant="organization-id", profile="team", replace=False
18
+ ):
19
+ return import_profile_config(
20
+ api_url=API,
21
+ token="test-access-token",
22
+ tenant=tenant,
23
+ source="codex",
24
+ content=CONTENT,
25
+ profile=profile,
26
+ dry_run=dry_run,
27
+ replace=replace,
28
+ )
29
+
30
+
31
+ @pytest.mark.parametrize(
32
+ "tenant,profile", [(None, "default"), ("organization-id", "team")]
33
+ )
34
+ @pytest.mark.parametrize("replace", [False, True])
35
+ def test_apply_reads_owner_snapshot_and_preserves_import_scope(
36
+ tenant, profile, replace
37
+ ):
38
+ requests = []
39
+
40
+ def handle(request):
41
+ requests.append(request)
42
+ assert request.headers["Authorization"] == "Bearer test-access-token"
43
+ assert request.headers.get("X-Tenant") == tenant
44
+ if request.method == "GET":
45
+ assert request.url == f"{API}/integrations/profiles"
46
+ assert not request.content
47
+ assert "If-Match" not in request.headers
48
+ return httpx.Response(200, headers={"ETag": ETAG}, json={"profiles": []})
49
+ assert request.method == "POST"
50
+ assert request.url == f"{API}/integrations/import?dry_run=false"
51
+ assert request.headers["If-Match"] == ETAG
52
+ assert json.loads(request.content) == {
53
+ "source": "codex",
54
+ "content": CONTENT,
55
+ "profile": {"name": profile},
56
+ "replace": replace,
57
+ }
58
+ return httpx.Response(200, json={"dry_run": False})
59
+
60
+ with (
61
+ httpx.Client(transport=httpx.MockTransport(handle)) as client,
62
+ patch(
63
+ "lightnow_cli.authenticated_http.httpx.request", side_effect=client.request
64
+ ),
65
+ ):
66
+ assert import_config(tenant=tenant, profile=profile, replace=replace) == {
67
+ "dry_run": False
68
+ }
69
+ assert [request.method for request in requests] == ["GET", "POST"]
70
+
71
+
72
+ def test_preview_does_not_fetch_or_send_a_write_precondition():
73
+ requests = []
74
+
75
+ def handle(request):
76
+ requests.append(request)
77
+ assert request.method == "POST"
78
+ assert request.url.params["dry_run"] == "true"
79
+ assert "If-Match" not in request.headers
80
+ return httpx.Response(200, json={"dry_run": True})
81
+
82
+ with (
83
+ httpx.Client(transport=httpx.MockTransport(handle)) as client,
84
+ patch(
85
+ "lightnow_cli.authenticated_http.httpx.request", side_effect=client.request
86
+ ),
87
+ ):
88
+ assert import_config(dry_run=True) == {"dry_run": True}
89
+ assert len(requests) == 1
90
+
91
+
92
+ @pytest.mark.parametrize(
93
+ "etag", [None, "", "*", 'W/"weak"', "unquoted", '"one", "two"']
94
+ )
95
+ def test_missing_or_invalid_snapshot_never_sends_import(etag):
96
+ requests = []
97
+
98
+ def handle(request):
99
+ requests.append(request)
100
+ assert request.method == "GET"
101
+ return httpx.Response(
102
+ 200,
103
+ headers={"ETag": etag} if etag is not None else {},
104
+ json={"profiles": []},
105
+ )
106
+
107
+ with (
108
+ httpx.Client(transport=httpx.MockTransport(handle)) as client,
109
+ patch(
110
+ "lightnow_cli.authenticated_http.httpx.request", side_effect=client.request
111
+ ),
112
+ pytest.raises(ValueError, match="current profile version"),
113
+ ):
114
+ import_config()
115
+ assert len(requests) == 1
116
+
117
+
118
+ @pytest.mark.parametrize("status", [302, 403, 503])
119
+ def test_failed_snapshot_read_never_sends_import(status):
120
+ requests = []
121
+
122
+ def handle(request):
123
+ requests.append(request)
124
+ assert request.method == "GET"
125
+ return httpx.Response(
126
+ status,
127
+ headers={"ETag": ETAG},
128
+ json={"error": {"message": "Cannot read profiles"}},
129
+ )
130
+
131
+ with (
132
+ httpx.Client(transport=httpx.MockTransport(handle)) as client,
133
+ patch(
134
+ "lightnow_cli.authenticated_http.httpx.request", side_effect=client.request
135
+ ),
136
+ pytest.raises(ValueError, match=f"HTTP {status}"),
137
+ ):
138
+ import_config()
139
+ assert len(requests) == 1
140
+
141
+
142
+ def test_conflict_stops_without_refetch_or_reapply():
143
+ requests = []
144
+
145
+ def handle(request):
146
+ requests.append(request)
147
+ if request.method == "GET":
148
+ return httpx.Response(200, headers={"ETag": ETAG}, json={"profiles": []})
149
+ assert request.headers["If-Match"] == ETAG
150
+ return httpx.Response(412, json={"message": "Profile changed"})
151
+
152
+ with (
153
+ httpx.Client(transport=httpx.MockTransport(handle)) as client,
154
+ patch(
155
+ "lightnow_cli.authenticated_http.httpx.request", side_effect=client.request
156
+ ),
157
+ pytest.raises(ValueError, match="HTTP 412"),
158
+ ):
159
+ import_config()
160
+ assert [request.method for request in requests] == ["GET", "POST"]
161
+
162
+
163
+ @pytest.mark.parametrize("expired_method", ["GET", "POST"])
164
+ def test_existing_session_refresh_preserves_scope_and_write_snapshot(expired_method):
165
+ requests = []
166
+ expired = False
167
+
168
+ def handle(request):
169
+ nonlocal expired
170
+ requests.append(request)
171
+ assert request.headers["X-Tenant"] == "organization-id"
172
+ if request.method == "POST":
173
+ assert request.headers["If-Match"] == ETAG
174
+ if request.method == expired_method and not expired:
175
+ expired = True
176
+ return httpx.Response(401, json={"error": "token_expired"})
177
+ if expired:
178
+ assert request.headers["Authorization"] == "Bearer refreshed-test-token"
179
+ if request.method == "GET":
180
+ return httpx.Response(200, headers={"ETag": ETAG}, json={"profiles": []})
181
+ return httpx.Response(200, json={"dry_run": False})
182
+
183
+ with (
184
+ httpx.Client(transport=httpx.MockTransport(handle)) as client,
185
+ patch(
186
+ "lightnow_cli.authenticated_http.httpx.request", side_effect=client.request
187
+ ),
188
+ patch(
189
+ "lightnow_cli.authenticated_http.refresh_current_session",
190
+ return_value="refreshed-test-token",
191
+ ) as refresh,
192
+ ):
193
+ assert import_config() == {"dry_run": False}
194
+ assert refresh.call_count == 1
195
+ assert [request.method for request in requests] == (
196
+ ["GET", "GET", "POST"] if expired_method == "GET" else ["GET", "POST", "POST"]
197
+ )
@@ -1,3 +0,0 @@
1
- {
2
- ".": "1.7.0"
3
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes