lightnow-cli 1.6.0__tar.gz → 1.6.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.
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/.github/workflows/ci.yml +20 -1
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/.github/workflows/release.yml +3 -3
- lightnow_cli-1.6.1/.release-please-manifest.json +3 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/CHANGELOG.md +9 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/PKG-INFO +2 -2
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/README.md +1 -1
- lightnow_cli-1.6.1/ci/create_client_artifact.py +126 -0
- lightnow_cli-1.6.1/ci/test_client_artifact.py +124 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/docs/release.md +23 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/lightnow_cli/__init__.py +1 -1
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/query.py +1 -1
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/pyproject.toml +1 -1
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/renovate.json +2 -2
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/tests/test_cli.py +44 -2
- lightnow_cli-1.6.0/.release-please-manifest.json +0 -3
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/.github/workflows/release-please.yml +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/.gitignore +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/CONTRIBUTING.md +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/LICENSE +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/Makefile +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/SECURITY.md +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/docs/development.md +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/examples/README.md +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/examples/docs.md +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/examples/openapi.json +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/examples/server.json +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/lightnow_cli/authenticated_http.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/lightnow_cli/client.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/__init__.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/auth.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/context.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/integrations.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/publish.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/runner.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/updates.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/validate.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/lightnow_cli/config.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/lightnow_cli/main.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/lightnow_cli/target.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/lightnow_cli/tls.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/lightnow_cli/updates.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/lightnow_cli/validation.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/release-please-config.json +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/tests/__init__.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/tests/test_auth_flow.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/tests/test_authenticated_http.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/tests/test_client.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/tests/test_config.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/tests/test_connection_target.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/tests/test_context.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/tests/test_integrations.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/tests/test_tls.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/tests/test_updates.py +0 -0
- {lightnow_cli-1.6.0 → lightnow_cli-1.6.1}/tests/test_validation.py +0 -0
|
@@ -73,8 +73,27 @@ jobs:
|
|
|
73
73
|
- name: Build and check distributions
|
|
74
74
|
run: make package
|
|
75
75
|
|
|
76
|
+
- name: Verify distribution provenance contract
|
|
77
|
+
run: python -m unittest discover -s ci -p 'test_*.py'
|
|
78
|
+
|
|
79
|
+
- name: Bind PR distribution provenance
|
|
80
|
+
if: github.event_name == 'pull_request'
|
|
81
|
+
env:
|
|
82
|
+
ARTIFACT_REPOSITORY: ${{ github.repository }}
|
|
83
|
+
ARTIFACT_PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
84
|
+
ARTIFACT_HEAD_REVISION: ${{ github.event.pull_request.head.sha }}
|
|
85
|
+
ARTIFACT_RUN_ID: ${{ github.run_id }}
|
|
86
|
+
ARTIFACT_RUN_ATTEMPT: ${{ github.run_attempt }}
|
|
87
|
+
run: |
|
|
88
|
+
python ci/create_client_artifact.py --component cli \
|
|
89
|
+
--repository "$ARTIFACT_REPOSITORY" \
|
|
90
|
+
--pr-number "$ARTIFACT_PR_NUMBER" \
|
|
91
|
+
--head-revision "$ARTIFACT_HEAD_REVISION" \
|
|
92
|
+
--run-id "$ARTIFACT_RUN_ID" \
|
|
93
|
+
--run-attempt "$ARTIFACT_RUN_ATTEMPT"
|
|
94
|
+
|
|
76
95
|
- name: Upload distributions
|
|
77
|
-
uses: actions/upload-artifact@
|
|
96
|
+
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
|
78
97
|
with:
|
|
79
98
|
name: python-distributions
|
|
80
99
|
path: dist/*
|
|
@@ -51,7 +51,7 @@ jobs:
|
|
|
51
51
|
run: make package
|
|
52
52
|
|
|
53
53
|
- name: Upload distributions
|
|
54
|
-
uses: actions/upload-artifact@
|
|
54
|
+
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
|
55
55
|
with:
|
|
56
56
|
name: python-distributions
|
|
57
57
|
path: dist/*
|
|
@@ -66,7 +66,7 @@ jobs:
|
|
|
66
66
|
|
|
67
67
|
steps:
|
|
68
68
|
- name: Download distributions
|
|
69
|
-
uses: actions/download-artifact@
|
|
69
|
+
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
|
70
70
|
with:
|
|
71
71
|
name: python-distributions
|
|
72
72
|
path: dist
|
|
@@ -89,7 +89,7 @@ jobs:
|
|
|
89
89
|
|
|
90
90
|
steps:
|
|
91
91
|
- name: Download distributions
|
|
92
|
-
uses: actions/download-artifact@
|
|
92
|
+
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
|
93
93
|
with:
|
|
94
94
|
name: python-distributions
|
|
95
95
|
path: dist
|
|
@@ -4,6 +4,15 @@ All notable changes to the LightNow CLI are documented here.
|
|
|
4
4
|
|
|
5
5
|
This project follows semantic versioning.
|
|
6
6
|
|
|
7
|
+
## [1.6.1](https://github.com/lightnow-ai/lightnow-cli/compare/v1.6.0...v1.6.1) (2026-11-09)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Bug Fixes
|
|
11
|
+
|
|
12
|
+
* **cli:** use active context for default favorites ([5399173](https://github.com/lightnow-ai/lightnow-cli/commit/539917321a8fbafedb05394841b5fadd0b5319e0))
|
|
13
|
+
* **cli:** use active context for default favorites ([1ccba54](https://github.com/lightnow-ai/lightnow-cli/commit/1ccba543e34b236d449bdedeb644029c54f49f03))
|
|
14
|
+
* **cli:** use active context for default favorites ([#45](https://github.com/lightnow-ai/lightnow-cli/issues/45)) ([5399173](https://github.com/lightnow-ai/lightnow-cli/commit/539917321a8fbafedb05394841b5fadd0b5319e0))
|
|
15
|
+
|
|
7
16
|
## [1.6.0](https://github.com/lightnow-ai/lightnow-cli/compare/v1.5.0...v1.6.0) (2026-10-09)
|
|
8
17
|
|
|
9
18
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lightnow-cli
|
|
3
|
-
Version: 1.6.
|
|
3
|
+
Version: 1.6.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
|
|
@@ -176,7 +176,7 @@ Show details for one server:
|
|
|
176
176
|
lightnow info io.github.SonarSource/sonarqube-mcp-server
|
|
177
177
|
```
|
|
178
178
|
|
|
179
|
-
Show
|
|
179
|
+
Show favorites for the active personal or organization context:
|
|
180
180
|
|
|
181
181
|
```bash
|
|
182
182
|
lightnow favorites
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Bind a PR build to its tested checkout and one universal Python wheel."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import tomllib
|
|
9
|
+
import zipfile
|
|
10
|
+
from email.parser import Parser
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
SCHEMA_VERSION = "platform.lightnow.ai/feature-stack-client-artifact/v1"
|
|
14
|
+
MANIFEST_FILENAME = "feature-stack-client-artifact.json"
|
|
15
|
+
COMPONENTS = {"cli": "lightnow-cli", "proxy": "lightnow-proxy"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_manifest(
|
|
19
|
+
root: Path,
|
|
20
|
+
component: str,
|
|
21
|
+
repository: str,
|
|
22
|
+
pr_number: int,
|
|
23
|
+
head_revision: str,
|
|
24
|
+
checkout_revision: str,
|
|
25
|
+
run_id: int,
|
|
26
|
+
run_attempt: int,
|
|
27
|
+
) -> dict:
|
|
28
|
+
"""Validate wheel contents and runner identity before creating provenance."""
|
|
29
|
+
package_name = COMPONENTS.get(component)
|
|
30
|
+
if package_name is None or repository != f"lightnow-ai/{package_name}":
|
|
31
|
+
raise ValueError("Component and repository do not match")
|
|
32
|
+
if any(value <= 0 for value in (pr_number, run_id, run_attempt)):
|
|
33
|
+
raise ValueError("PR number, run ID and run attempt must be positive")
|
|
34
|
+
if any(
|
|
35
|
+
re.fullmatch(r"[0-9a-f]{40}", value) is None
|
|
36
|
+
for value in (head_revision, checkout_revision)
|
|
37
|
+
):
|
|
38
|
+
raise ValueError("Head and checkout revisions must be full Git SHA-1 values")
|
|
39
|
+
|
|
40
|
+
with (root / "pyproject.toml").open("rb") as project_file:
|
|
41
|
+
project = tomllib.load(project_file)["project"]
|
|
42
|
+
if project["name"] != package_name:
|
|
43
|
+
raise ValueError("Repository package metadata does not match component")
|
|
44
|
+
|
|
45
|
+
wheels = sorted((root / "dist").glob("*.whl"))
|
|
46
|
+
if len(wheels) != 1 or wheels[0].is_symlink() or not wheels[0].is_file():
|
|
47
|
+
raise ValueError("Exactly one regular wheel is required")
|
|
48
|
+
wheel = wheels[0]
|
|
49
|
+
wheel_package = package_name.replace("-", "_")
|
|
50
|
+
expected_filename = f"{wheel_package}-{project['version']}-py3-none-any.whl"
|
|
51
|
+
if wheel.name != expected_filename:
|
|
52
|
+
raise ValueError("Expected the repository's universal py3-none-any wheel")
|
|
53
|
+
|
|
54
|
+
prefix = f"{wheel_package}-{project['version']}.dist-info/"
|
|
55
|
+
with zipfile.ZipFile(wheel) as archive:
|
|
56
|
+
for name in (f"{prefix}METADATA", f"{prefix}WHEEL"):
|
|
57
|
+
if archive.namelist().count(name) != 1:
|
|
58
|
+
raise ValueError("Wheel metadata must be unique and match its filename")
|
|
59
|
+
metadata_files = [
|
|
60
|
+
name for name in archive.namelist() if name.endswith(".dist-info/METADATA")
|
|
61
|
+
]
|
|
62
|
+
if metadata_files != [f"{prefix}METADATA"]:
|
|
63
|
+
raise ValueError("Wheel must contain exactly one distribution's metadata")
|
|
64
|
+
metadata = Parser().parsestr(archive.read(f"{prefix}METADATA").decode("utf-8"))
|
|
65
|
+
wheel_metadata = Parser().parsestr(
|
|
66
|
+
archive.read(f"{prefix}WHEEL").decode("utf-8")
|
|
67
|
+
)
|
|
68
|
+
if metadata.get_all("Name") != [package_name] or metadata.get_all("Version") != [
|
|
69
|
+
project["version"]
|
|
70
|
+
]:
|
|
71
|
+
raise ValueError("Wheel METADATA name/version do not match repository metadata")
|
|
72
|
+
if wheel_metadata.get_all("Tag") != ["py3-none-any"] or wheel_metadata.get_all(
|
|
73
|
+
"Root-Is-Purelib"
|
|
74
|
+
) != ["true"]:
|
|
75
|
+
raise ValueError("Wheel must declare the universal pure-Python tag")
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
79
|
+
"component": component,
|
|
80
|
+
"repository": repository,
|
|
81
|
+
"packageName": metadata["Name"],
|
|
82
|
+
"packageVersion": metadata["Version"],
|
|
83
|
+
"filename": wheel.name,
|
|
84
|
+
"sha256": hashlib.sha256(wheel.read_bytes()).hexdigest(),
|
|
85
|
+
"source": {
|
|
86
|
+
"kind": "pull-request",
|
|
87
|
+
"number": pr_number,
|
|
88
|
+
"headRevision": head_revision,
|
|
89
|
+
"checkoutRevision": checkout_revision,
|
|
90
|
+
"runId": run_id,
|
|
91
|
+
"runAttempt": run_attempt,
|
|
92
|
+
"workflowPath": ".github/workflows/ci.yml",
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main() -> None:
|
|
98
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
99
|
+
parser.add_argument("--component", choices=tuple(COMPONENTS), required=True)
|
|
100
|
+
parser.add_argument("--repository", required=True)
|
|
101
|
+
parser.add_argument("--pr-number", type=int, required=True)
|
|
102
|
+
parser.add_argument("--head-revision", required=True)
|
|
103
|
+
parser.add_argument("--run-id", type=int, required=True)
|
|
104
|
+
parser.add_argument("--run-attempt", type=int, required=True)
|
|
105
|
+
args = parser.parse_args()
|
|
106
|
+
root = Path(__file__).resolve().parents[1]
|
|
107
|
+
checkout_revision = subprocess.check_output(
|
|
108
|
+
["git", "rev-parse", "HEAD"], cwd=root, text=True
|
|
109
|
+
).strip()
|
|
110
|
+
manifest = build_manifest(
|
|
111
|
+
root,
|
|
112
|
+
args.component,
|
|
113
|
+
args.repository,
|
|
114
|
+
args.pr_number,
|
|
115
|
+
args.head_revision,
|
|
116
|
+
checkout_revision,
|
|
117
|
+
args.run_id,
|
|
118
|
+
args.run_attempt,
|
|
119
|
+
)
|
|
120
|
+
(root / "dist" / MANIFEST_FILENAME).write_text(
|
|
121
|
+
json.dumps(manifest, indent=2) + "\n", encoding="utf-8"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
main()
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Offline provenance contract checks; no installation or external services."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
import zipfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from create_client_artifact import build_manifest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ClientArtifactTests(unittest.TestCase):
|
|
13
|
+
def setUp(self) -> None:
|
|
14
|
+
self.directory = tempfile.TemporaryDirectory()
|
|
15
|
+
self.addCleanup(self.directory.cleanup)
|
|
16
|
+
self.root = Path(self.directory.name)
|
|
17
|
+
(self.root / "dist").mkdir()
|
|
18
|
+
(self.root / "pyproject.toml").write_text(
|
|
19
|
+
'[project]\nname = "lightnow-cli"\nversion = "1.2.3"\n'
|
|
20
|
+
)
|
|
21
|
+
self.wheel = self.root / "dist/lightnow_cli-1.2.3-py3-none-any.whl"
|
|
22
|
+
self.write_wheel()
|
|
23
|
+
|
|
24
|
+
def write_wheel(
|
|
25
|
+
self,
|
|
26
|
+
name: str = "lightnow-cli",
|
|
27
|
+
version: str = "1.2.3",
|
|
28
|
+
tag: str = "py3-none-any",
|
|
29
|
+
) -> None:
|
|
30
|
+
with zipfile.ZipFile(self.wheel, "w") as archive:
|
|
31
|
+
archive.writestr(
|
|
32
|
+
"lightnow_cli-1.2.3.dist-info/METADATA",
|
|
33
|
+
f"Metadata-Version: 2.3\nName: {name}\nVersion: {version}\n",
|
|
34
|
+
)
|
|
35
|
+
archive.writestr(
|
|
36
|
+
"lightnow_cli-1.2.3.dist-info/WHEEL",
|
|
37
|
+
f"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: {tag}\n",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def manifest(self, **overrides: object) -> dict:
|
|
41
|
+
arguments = dict(
|
|
42
|
+
root=self.root,
|
|
43
|
+
component="cli",
|
|
44
|
+
repository="lightnow-ai/lightnow-cli",
|
|
45
|
+
pr_number=42,
|
|
46
|
+
head_revision="a" * 40,
|
|
47
|
+
checkout_revision="b" * 40,
|
|
48
|
+
run_id=123,
|
|
49
|
+
run_attempt=2,
|
|
50
|
+
)
|
|
51
|
+
arguments.update(overrides)
|
|
52
|
+
return build_manifest(**arguments)
|
|
53
|
+
|
|
54
|
+
def test_exact_contract_keeps_head_and_tested_checkout_separate(self) -> None:
|
|
55
|
+
self.assertEqual(
|
|
56
|
+
self.manifest(),
|
|
57
|
+
{
|
|
58
|
+
"schemaVersion": "platform.lightnow.ai/feature-stack-client-artifact/v1",
|
|
59
|
+
"component": "cli",
|
|
60
|
+
"repository": "lightnow-ai/lightnow-cli",
|
|
61
|
+
"packageName": "lightnow-cli",
|
|
62
|
+
"packageVersion": "1.2.3",
|
|
63
|
+
"filename": self.wheel.name,
|
|
64
|
+
"sha256": hashlib.sha256(self.wheel.read_bytes()).hexdigest(),
|
|
65
|
+
"source": {
|
|
66
|
+
"kind": "pull-request",
|
|
67
|
+
"number": 42,
|
|
68
|
+
"headRevision": "a" * 40,
|
|
69
|
+
"checkoutRevision": "b" * 40,
|
|
70
|
+
"runId": 123,
|
|
71
|
+
"runAttempt": 2,
|
|
72
|
+
"workflowPath": ".github/workflows/ci.yml",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def test_rejects_unbound_identity(self) -> None:
|
|
78
|
+
for arguments in (
|
|
79
|
+
{"repository": "other/lightnow-cli"},
|
|
80
|
+
{"component": "proxy"},
|
|
81
|
+
{"head_revision": "a" * 7},
|
|
82
|
+
{"checkout_revision": "not-a-revision"},
|
|
83
|
+
{"pr_number": 0},
|
|
84
|
+
{"run_id": -1},
|
|
85
|
+
{"run_attempt": 0},
|
|
86
|
+
):
|
|
87
|
+
with self.subTest(arguments=arguments), self.assertRaises(ValueError):
|
|
88
|
+
self.manifest(**arguments)
|
|
89
|
+
|
|
90
|
+
def test_rejects_metadata_or_platform_mismatch(self) -> None:
|
|
91
|
+
for arguments in (
|
|
92
|
+
{"name": "another-package"},
|
|
93
|
+
{"version": "9.0.0"},
|
|
94
|
+
{"tag": "cp312-cp312-linux_x86_64"},
|
|
95
|
+
):
|
|
96
|
+
with self.subTest(arguments=arguments):
|
|
97
|
+
self.write_wheel(**arguments)
|
|
98
|
+
with self.assertRaises(ValueError):
|
|
99
|
+
self.manifest()
|
|
100
|
+
|
|
101
|
+
def test_rejects_ambiguous_missing_or_symlinked_wheel(self) -> None:
|
|
102
|
+
extra = self.wheel.with_name("another.whl")
|
|
103
|
+
extra.write_bytes(self.wheel.read_bytes())
|
|
104
|
+
with self.assertRaises(ValueError):
|
|
105
|
+
self.manifest()
|
|
106
|
+
extra.unlink()
|
|
107
|
+
self.wheel.unlink()
|
|
108
|
+
with self.assertRaises(ValueError):
|
|
109
|
+
self.manifest()
|
|
110
|
+
self.wheel.symlink_to(self.root / "outside.whl")
|
|
111
|
+
with self.assertRaises(ValueError):
|
|
112
|
+
self.manifest()
|
|
113
|
+
|
|
114
|
+
def test_rejects_duplicate_distribution_metadata(self) -> None:
|
|
115
|
+
with zipfile.ZipFile(self.wheel, "a") as archive:
|
|
116
|
+
archive.writestr(
|
|
117
|
+
"other-1.0.dist-info/METADATA", "Name: other\nVersion: 1.0\n"
|
|
118
|
+
)
|
|
119
|
+
with self.assertRaises(ValueError):
|
|
120
|
+
self.manifest()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
unittest.main()
|
|
@@ -101,3 +101,26 @@ tag. If the project does not exist yet, use PyPI's pending publisher flow for
|
|
|
101
101
|
```bash
|
|
102
102
|
python -m twine check dist/*
|
|
103
103
|
```
|
|
104
|
+
|
|
105
|
+
## PR distribution provenance
|
|
106
|
+
|
|
107
|
+
PR CI archives `feature-stack-client-artifact.json` alongside the wheel and
|
|
108
|
+
source distribution in `python-distributions`. The manifest uses
|
|
109
|
+
`platform.lightnow.ai/feature-stack-client-artifact/v1` and binds the universal
|
|
110
|
+
wheel SHA-256 and its actual METADATA name/version to the PR number, head
|
|
111
|
+
revision, tested checkout revision, workflow, run ID, and run attempt. The head
|
|
112
|
+
and tested merge checkout are separate fields. Consumers must verify these
|
|
113
|
+
bindings and the wheel checksum against the selected successful CI run before
|
|
114
|
+
installing a candidate; the manifest itself is build metadata, not a signature
|
|
115
|
+
or a runtime test result.
|
|
116
|
+
|
|
117
|
+
The generator uses only Python's standard library. After a clean package build,
|
|
118
|
+
run its focused offline contract checks with:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
python -m unittest discover -s ci -p 'test_*.py'
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Main CI does not fabricate PR metadata. Tagged release publishing is unchanged:
|
|
125
|
+
release consumers qualify the exact Git tag and published PyPI wheel separately.
|
|
126
|
+
The PR-only JSON never enters the existing PyPI release upload.
|
|
@@ -74,7 +74,7 @@ def favorite_servers(
|
|
|
74
74
|
"--scope",
|
|
75
75
|
help="Favorite scope: effective, user, tenant, system, or true.",
|
|
76
76
|
),
|
|
77
|
-
] = "
|
|
77
|
+
] = "effective",
|
|
78
78
|
sort: Annotated[
|
|
79
79
|
Optional[str],
|
|
80
80
|
typer.Option("--sort", help="Sort order supported by the Registry API."),
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lightnow-cli"
|
|
7
|
-
version = "1.6.
|
|
7
|
+
version = "1.6.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"}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
3
3
|
"extends": [
|
|
4
|
-
"github>apitree/renovate:python
|
|
5
|
-
"github>apitree/renovate:docker
|
|
4
|
+
"github>apitree/renovate:python",
|
|
5
|
+
"github>apitree/renovate:docker",
|
|
6
6
|
"helpers:pinGitHubActionDigests",
|
|
7
7
|
"default:automergeDigest",
|
|
8
8
|
":automergeStableNonMajor",
|
|
@@ -17,6 +17,7 @@ from lightnow_cli.config import (
|
|
|
17
17
|
LOCAL_ADMIN_API_URL,
|
|
18
18
|
LOCAL_ISSUER,
|
|
19
19
|
LOCAL_REGISTRY_API_URL,
|
|
20
|
+
ConfigManager,
|
|
20
21
|
)
|
|
21
22
|
from lightnow_cli.main import app
|
|
22
23
|
from lightnow_cli.target import ConnectionTarget
|
|
@@ -335,6 +336,47 @@ def test_favorites_command_passes_tenant_context(runner):
|
|
|
335
336
|
assert fake_client.list_servers.call_args.kwargs["tenant"] == "acme"
|
|
336
337
|
|
|
337
338
|
|
|
339
|
+
@pytest.mark.parametrize("tenant_id", [None, "active-organization"])
|
|
340
|
+
def test_favorites_default_uses_selected_context(runner, tmp_path, tenant_id):
|
|
341
|
+
"""The default list must follow actual persisted personal/organization context."""
|
|
342
|
+
manager = ConfigManager()
|
|
343
|
+
manager.config_dir = tmp_path / ".lightnow"
|
|
344
|
+
manager.config_file = manager.config_dir / "config.json"
|
|
345
|
+
if tenant_id is None:
|
|
346
|
+
manager.set_personal_context()
|
|
347
|
+
else:
|
|
348
|
+
manager.set_tenant_context(tenant_id, "Selected organization")
|
|
349
|
+
fake_client = MagicMock()
|
|
350
|
+
fake_client.list_servers = AsyncMock(return_value={"servers": []})
|
|
351
|
+
with (
|
|
352
|
+
patch("lightnow_cli.commands.query.config_manager", manager),
|
|
353
|
+
patch("lightnow_cli.commands.query.get_client", return_value=fake_client),
|
|
354
|
+
):
|
|
355
|
+
result = runner.invoke(app, ["favorites"])
|
|
356
|
+
assert result.exit_code == 0
|
|
357
|
+
assert fake_client.list_servers.call_args.kwargs["favorites"] == "effective"
|
|
358
|
+
assert fake_client.list_servers.call_args.kwargs["tenant"] == tenant_id
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@pytest.mark.parametrize("scope", ["user", "tenant", "system", "effective", "true"])
|
|
362
|
+
def test_favorites_explicit_scope_is_not_silently_replaced(runner, tmp_path, scope):
|
|
363
|
+
"""Explicit scope flags do not silently change the active organization."""
|
|
364
|
+
manager = ConfigManager()
|
|
365
|
+
manager.config_dir = tmp_path / ".lightnow"
|
|
366
|
+
manager.config_file = manager.config_dir / "config.json"
|
|
367
|
+
manager.set_tenant_context("active-organization", "Selected organization")
|
|
368
|
+
fake_client = MagicMock()
|
|
369
|
+
fake_client.list_servers = AsyncMock(return_value={"servers": []})
|
|
370
|
+
with (
|
|
371
|
+
patch("lightnow_cli.commands.query.config_manager", manager),
|
|
372
|
+
patch("lightnow_cli.commands.query.get_client", return_value=fake_client),
|
|
373
|
+
):
|
|
374
|
+
result = runner.invoke(app, ["favorites", "--scope", scope])
|
|
375
|
+
assert result.exit_code == 0
|
|
376
|
+
assert fake_client.list_servers.call_args.kwargs["favorites"] == scope
|
|
377
|
+
assert fake_client.list_servers.call_args.kwargs["tenant"] == "active-organization"
|
|
378
|
+
|
|
379
|
+
|
|
338
380
|
def test_info_command_passes_tenant_context(runner):
|
|
339
381
|
"""Server info supports organization context from the public CLI surface."""
|
|
340
382
|
fake_client = MagicMock()
|
|
@@ -847,12 +889,12 @@ def test_favorites_command_empty(mock_list_servers, runner):
|
|
|
847
889
|
|
|
848
890
|
assert result.exit_code == 0
|
|
849
891
|
assert "No favorite servers found" in result.stdout
|
|
850
|
-
assert mock_list_servers.call_args.kwargs["favorites"] == "
|
|
892
|
+
assert mock_list_servers.call_args.kwargs["favorites"] == "effective"
|
|
851
893
|
|
|
852
894
|
|
|
853
895
|
@patch("lightnow_cli.client.MCPRegistryClient.list_servers")
|
|
854
896
|
def test_favorites_command_allows_effective_scope(mock_list_servers, runner):
|
|
855
|
-
"""
|
|
897
|
+
"""Explicit effective scope remains bound to the selected context."""
|
|
856
898
|
mock_list_servers.return_value = {"servers": [], "metadata": {"count": 0}}
|
|
857
899
|
|
|
858
900
|
result = runner.invoke(app, ["favorites", "--scope", "effective"])
|
|
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
|
|
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
|