systemlink-cli 2.2.2__py3-none-any.whl → 2.3.0__py3-none-any.whl

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.
slcli/_version.py CHANGED
@@ -1,4 +1,4 @@
1
1
  """Version information for slcli."""
2
2
 
3
3
  # This file is auto-generated. Do not edit manually.
4
- __version__ = "2.2.2"
4
+ __version__ = "2.3.0"
slcli/config_click.py CHANGED
@@ -6,6 +6,7 @@ import os
6
6
  import re
7
7
  import ssl
8
8
  import sys
9
+ from pathlib import Path
9
10
  from typing import Any, NoReturn, Optional
10
11
  from urllib.parse import urlparse
11
12
 
@@ -26,14 +27,16 @@ from .platform import (
26
27
  from .profiles import Profile, ProfileConfig, check_config_file_permissions
27
28
  from .rich_output import render_table
28
29
  from .ssl_trust import (
30
+ get_managed_trust_path,
29
31
  get_managed_trust_records,
30
32
  get_ssl_server_origin,
31
33
  inspect_server_certificate,
34
+ load_ca_certificate,
32
35
  remove_managed_trust,
33
36
  save_managed_certificate,
34
37
  )
35
38
  from .table_utils import output_formatted_list
36
- from .utils import ExitCodes, get_base_url, get_base_url_resolution
39
+ from .utils import ExitCodes, get_base_url_resolution
37
40
 
38
41
  API_KEY_LENGTH = 42
39
42
  API_KEY_PATTERN = re.compile(rf"^[A-Za-z0-9_-]{{{API_KEY_LENGTH}}}$")
@@ -765,12 +768,31 @@ def register_config_commands(cli: Any) -> None:
765
768
  )
766
769
 
767
770
  certificate_details = certificate.to_dict()
771
+ trusted_certificate = None
772
+ try:
773
+ trusted_path = get_managed_trust_path(certificate.origin)
774
+ if trusted_path is not None:
775
+ trusted_certificate = json.loads(
776
+ trusted_path.with_suffix(".json").read_text(encoding="utf-8")
777
+ )
778
+ trusted_certificate["origin"] = certificate.origin
779
+ trusted_certificate.setdefault("trust-type", "leaf")
780
+ except (OSError, ValueError) as exc:
781
+ click.echo(f"Could not read managed certificate metadata: {exc}.", err=True)
782
+ trusted_certificate = None
768
783
  if output_format == "json":
784
+ certificate_details["trusted-certificate"] = trusted_certificate
769
785
  click.echo(json.dumps(certificate_details, indent=2))
770
786
  return
771
787
 
772
788
  click.echo(f"Server certificate for {certificate_details.get('origin', 'unknown')}")
773
789
  _show_certificate_details(certificate_details)
790
+ if trusted_certificate:
791
+ kind = "CA" if trusted_certificate.get("trust-type") == "ca" else "leaf"
792
+ click.echo(f"\nTrusted {kind} certificate for this origin")
793
+ _show_certificate_details(trusted_certificate)
794
+ else:
795
+ click.echo("\nNo managed trust certificate for this origin.")
774
796
 
775
797
  @trust.command(name="list")
776
798
  @click.option(
@@ -794,13 +816,14 @@ def register_config_commands(cli: Any) -> None:
794
816
  [
795
817
  str(record.get("origin", "")),
796
818
  str(record.get("fingerprint", "")),
819
+ "CA" if record.get("trust-type") == "ca" else "leaf",
797
820
  "yes" if record.get("self-signed") else "no",
798
821
  ]
799
822
  for record in records
800
823
  ]
801
824
  render_table(
802
- headers=["SERVER", "SHA-256", "SELF-SIGNED"],
803
- column_widths=[40, 64, 12],
825
+ headers=["SERVER", "SHA-256", "TRUST TYPE", "SELF-SIGNED"],
826
+ column_widths=[40, 64, 10, 12],
804
827
  rows=rows,
805
828
  show_total=True,
806
829
  total_label="certificate(s)",
@@ -808,30 +831,47 @@ def register_config_commands(cli: Any) -> None:
808
831
 
809
832
  @trust.command(name="add")
810
833
  @click.option("--url", help="HTTPS server URL (defaults to the active API URL)")
834
+ @click.option(
835
+ "--certificate",
836
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
837
+ help="Import one public CA certificate (PEM or DER), scoped to this server origin",
838
+ )
811
839
  @click.option(
812
840
  "--fingerprint",
813
841
  required=True,
814
842
  help="Expected SHA-256 fingerprint of the certificate to trust",
815
843
  )
816
- def add_trusted_certificate(url: Optional[str], fingerprint: str) -> None:
817
- """Trust a server certificate after verifying its fingerprint."""
818
- server_url = url or get_base_url()
844
+ def add_trusted_certificate(
845
+ url: Optional[str], certificate: Optional[Path], fingerprint: str
846
+ ) -> None:
847
+ """Trust a live server certificate or imported CA after fingerprint verification."""
848
+ server_url = url or get_base_url_resolution().value
819
849
  try:
820
850
  expected_fingerprint = _normalize_fingerprint(fingerprint)
821
- certificate = inspect_server_certificate(server_url)
851
+ trusted_certificate = (
852
+ load_ca_certificate(server_url, certificate)
853
+ if certificate is not None
854
+ else inspect_server_certificate(server_url)
855
+ )
822
856
  except (OSError, ValueError, ssl.SSLError) as exc:
823
- _exit_with_validation_error(f"Could not inspect the server certificate: {exc}.")
857
+ _exit_with_validation_error(f"Could not load the certificate: {exc}.")
824
858
 
825
- _show_certificate_warning(certificate.to_dict())
826
- if certificate.fingerprint != expected_fingerprint:
859
+ _show_certificate_details(trusted_certificate.to_dict(), err=True)
860
+ if trusted_certificate.fingerprint != expected_fingerprint:
827
861
  _exit_with_validation_error(
828
- "The server certificate fingerprint does not match the supplied fingerprint."
862
+ "The certificate fingerprint does not match the supplied fingerprint."
863
+ )
864
+ if trusted_certificate.trust_type == "ca":
865
+ click.echo(
866
+ "Trusting certificates issued by this CA for this origin only; "
867
+ "hostname and validity verification remain enabled.",
868
+ err=True,
829
869
  )
830
870
  try:
831
- path = save_managed_certificate(certificate)
871
+ path = save_managed_certificate(trusted_certificate)
832
872
  except OSError as exc:
833
873
  _exit_with_validation_error(f"Could not save the trusted certificate: {exc}.")
834
- click.echo(f"✓ Trusted certificate for {certificate.origin}")
874
+ click.echo(f"✓ Trusted certificate for {trusted_certificate.origin}")
835
875
  click.echo(f" Certificate file: {path}")
836
876
 
837
877
  @trust.command(name="remove")
@@ -839,7 +879,7 @@ def register_config_commands(cli: Any) -> None:
839
879
  @click.option("--force", is_flag=True, help="Skip confirmation prompt")
840
880
  def remove_trusted_certificate(url: Optional[str], force: bool) -> None:
841
881
  """Remove a server certificate from the managed trust store."""
842
- server_url = url or get_base_url()
882
+ server_url = url or get_base_url_resolution().value
843
883
  try:
844
884
  origin = get_ssl_server_origin(server_url)
845
885
  except ValueError as exc:
slcli/main.py CHANGED
@@ -35,7 +35,7 @@ from .rich_output import install_rich_output, render_table
35
35
  from .routine_click import register_routine_commands
36
36
  from .skill_click import register_skill_commands
37
37
  from .spec_click import register_spec_commands
38
- from .ssl_trust import OS_TRUST_INJECTED, OS_TRUST_REASON
38
+ from .ssl_trust import OS_TRUST_INJECTED, OS_TRUST_REASON, create_explicit_tls_context
39
39
  from .state_click import register_state_commands
40
40
  from .system_click import register_system_commands
41
41
  from .tag_click import register_tag_commands
@@ -143,11 +143,11 @@ def _build_tls_debug_context(ssl_verify: Union[bool, str]) -> ssl.SSLContext:
143
143
  return ctx
144
144
 
145
145
  if isinstance(ssl_verify, str):
146
- return ssl.create_default_context(cafile=ssl_verify)
146
+ return create_explicit_tls_context(ssl_verify)
147
147
 
148
148
  verify_env = os.environ.get("REQUESTS_CA_BUNDLE") or os.environ.get("SSL_CERT_FILE")
149
149
  if verify_env:
150
- return ssl.create_default_context(cafile=verify_env)
150
+ return create_explicit_tls_context(verify_env)
151
151
 
152
152
  if OS_TRUST_INJECTED:
153
153
  try:
slcli/ssl_trust.py CHANGED
@@ -21,16 +21,21 @@ import sys
21
21
  import traceback
22
22
  from contextlib import contextmanager
23
23
  from dataclasses import dataclass
24
+ from datetime import datetime, timezone
24
25
  from pathlib import Path
26
+ from threading import RLock
25
27
  from typing import Any, Dict, Iterator, List, Optional, Union
26
28
  from urllib.parse import urlparse
27
29
 
30
+ import idna
28
31
  from cryptography import x509
32
+ from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm
29
33
  from cryptography.hazmat.primitives import hashes, serialization
30
34
 
31
35
  OS_TRUST_INJECTED: bool = False
32
36
  OS_TRUST_REASON: str = "not-attempted"
33
37
  _STANDARD_SSL_CONTEXT = ssl.SSLContext
38
+ _SSL_CONTEXT_LOCK = RLock()
34
39
 
35
40
 
36
41
  @dataclass(frozen=True)
@@ -46,6 +51,7 @@ class ServerCertificate:
46
51
  not_before: str
47
52
  not_after: str
48
53
  self_signed: bool
54
+ trust_type: str = "leaf"
49
55
 
50
56
  def to_dict(self) -> Dict[str, Any]:
51
57
  """Return certificate metadata without including the PEM bytes."""
@@ -58,6 +64,7 @@ class ServerCertificate:
58
64
  "not-before": self.not_before,
59
65
  "not-after": self.not_after,
60
66
  "self-signed": self.self_signed,
67
+ "trust-type": self.trust_type,
61
68
  }
62
69
 
63
70
 
@@ -65,10 +72,12 @@ __all__ = [
65
72
  "OS_TRUST_INJECTED",
66
73
  "OS_TRUST_REASON",
67
74
  "ServerCertificate",
75
+ "create_explicit_tls_context",
68
76
  "get_managed_trust_path",
69
77
  "get_managed_trust_records",
70
78
  "get_ssl_server_origin",
71
79
  "inspect_server_certificate",
80
+ "load_ca_certificate",
72
81
  "inject_os_trust",
73
82
  "remove_managed_trust",
74
83
  "save_managed_certificate",
@@ -83,6 +92,11 @@ def get_ssl_server_origin(api_url: str) -> str:
83
92
  raise ValueError("Managed certificate trust requires an HTTPS server URL.")
84
93
 
85
94
  hostname = parsed.hostname.lower()
95
+ try:
96
+ if not hostname.isascii():
97
+ hostname = idna.encode(hostname, uts46=True).decode("ascii")
98
+ except idna.IDNAError as error:
99
+ raise ValueError("Managed certificate trust requires a valid server hostname.") from error
86
100
  if ":" in hostname and not hostname.startswith("["):
87
101
  hostname = f"[{hostname}]"
88
102
  port = parsed.port or 443
@@ -96,11 +110,41 @@ def use_standard_ssl_context(ssl_verify: Union[bool, str]) -> Iterator[None]:
96
110
  The OS trust integration replaces SSL context implementations globally. For
97
111
  explicit CA bundle paths, requests must use the standard implementation so
98
112
  the supplied bundle is evaluated instead of the platform trust verifier.
113
+ For validated managed bundles, partial-chain verification permits the
114
+ fingerprint-matched leaf or imported CA to act as the trust anchor without
115
+ bypassing hostname or validity checks. Other bundles retain native defaults.
116
+ Managed bundles reject cross-origin redirects. Explicit contexts are serialized
117
+ so their process-global patches are restored in the correct order.
99
118
  """
100
119
  if not isinstance(ssl_verify, str):
101
120
  yield
102
121
  return
103
122
 
123
+ with _SSL_CONTEXT_LOCK:
124
+ with _use_standard_ssl_context(ssl_verify):
125
+ yield
126
+
127
+
128
+ def create_explicit_tls_context(cafile: str) -> ssl.SSLContext:
129
+ """Build a verified TLS client context using an explicit certificate bundle.
130
+
131
+ Args:
132
+ cafile: Path to a managed certificate or an ordinary CA bundle.
133
+
134
+ Returns:
135
+ A standard TLS context with partial-chain trust enabled for validated
136
+ managed certificates and native verification defaults for other bundles.
137
+ """
138
+ with use_standard_ssl_context(cafile):
139
+ context = ssl.create_default_context(cafile=cafile)
140
+ if _get_managed_origin(cafile) is not None:
141
+ context.verify_flags |= ssl.VERIFY_X509_PARTIAL_CHAIN
142
+ return context
143
+
144
+
145
+ @contextmanager
146
+ def _use_standard_ssl_context(ssl_verify: str) -> Iterator[None]:
147
+ """Apply process-global explicit trust patches while holding the context lock."""
104
148
  import requests.adapters
105
149
  import urllib3.util.ssl_ as urllib3_ssl
106
150
 
@@ -108,33 +152,62 @@ def use_standard_ssl_context(ssl_verify: Union[bool, str]) -> Iterator[None]:
108
152
  patched_urllib3_context = urllib3_ssl.SSLContext
109
153
  has_preloaded_context = hasattr(requests.adapters, "_preloaded_ssl_context")
110
154
  patched_preloaded_context = getattr(requests.adapters, "_preloaded_ssl_context", None)
155
+ original_send = requests.sessions.Session.send
156
+ managed_origin = _get_managed_origin(ssl_verify)
157
+
158
+ def send(
159
+ session: requests.Session, request: requests.PreparedRequest, **kwargs: Any
160
+ ) -> requests.Response:
161
+ if managed_origin and kwargs.get("verify") == ssl_verify:
162
+ try:
163
+ request_origin = get_ssl_server_origin(request.url or "")
164
+ except ValueError:
165
+ request_origin = None
166
+ if request_origin != managed_origin:
167
+ raise requests.exceptions.SSLError(
168
+ f"Managed certificate trust is restricted to {managed_origin}; "
169
+ "cross-origin redirects are not allowed."
170
+ )
171
+ return original_send(session, request, **kwargs)
172
+
173
+ def create_context(protocol: int) -> ssl.SSLContext:
174
+ context = _STANDARD_SSL_CONTEXT(protocol)
175
+ if managed_origin is not None:
176
+ context.verify_flags |= ssl.VERIFY_X509_PARTIAL_CHAIN
177
+ return context
178
+
111
179
  try:
112
180
  setattr(ssl, "SSLContext", _STANDARD_SSL_CONTEXT)
113
- setattr(urllib3_ssl, "SSLContext", _STANDARD_SSL_CONTEXT)
181
+ setattr(urllib3_ssl, "SSLContext", create_context)
182
+ if managed_origin:
183
+ setattr(requests.sessions.Session, "send", send)
114
184
  if has_preloaded_context:
115
185
  setattr(
116
186
  requests.adapters,
117
187
  "_preloaded_ssl_context",
118
- _STANDARD_SSL_CONTEXT(ssl.PROTOCOL_TLS_CLIENT),
188
+ create_context(ssl.PROTOCOL_TLS_CLIENT),
119
189
  )
120
190
  yield
121
191
  finally:
122
192
  setattr(ssl, "SSLContext", patched_ssl_context)
123
193
  setattr(urllib3_ssl, "SSLContext", patched_urllib3_context)
194
+ if managed_origin:
195
+ setattr(requests.sessions.Session, "send", original_send)
124
196
  if has_preloaded_context:
125
197
  setattr(requests.adapters, "_preloaded_ssl_context", patched_preloaded_context)
126
198
 
127
199
 
128
- def _get_trust_directory() -> Path:
129
- """Return the managed certificate directory, creating it when needed."""
200
+ def _get_trust_directory(create: bool = False) -> Path:
201
+ """Return the managed certificate directory, optionally creating it for writes."""
130
202
  from .profiles import ProfileConfig
131
203
 
132
204
  trust_directory = ProfileConfig.get_config_path().parent / "trust"
133
- trust_directory.mkdir(parents=True, exist_ok=True)
134
- try:
135
- trust_directory.chmod(0o700)
136
- except OSError:
137
- pass
205
+ if create:
206
+ trust_directory.mkdir(parents=True, exist_ok=True)
207
+ try:
208
+ trust_directory.chmod(0o700)
209
+ except OSError:
210
+ pass
138
211
  return trust_directory
139
212
 
140
213
 
@@ -143,22 +216,143 @@ def _get_trust_stem(origin: str) -> str:
143
216
  return hashlib.sha256(origin.encode("utf-8")).hexdigest()
144
217
 
145
218
 
219
+ def _get_managed_trust_pairs(origin: str) -> List[tuple[Path, Path]]:
220
+ """Find metadata and PEM pairs whose stored origin normalizes to the given origin."""
221
+ pairs: List[tuple[Path, Path]] = []
222
+ for metadata_path in sorted(_get_trust_directory().glob("*.json")):
223
+ try:
224
+ metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
225
+ stored_origin = metadata.get("origin") if isinstance(metadata, dict) else None
226
+ if (
227
+ not isinstance(stored_origin, str)
228
+ or metadata_path.stem != _get_trust_stem(stored_origin)
229
+ or get_ssl_server_origin(stored_origin) != origin
230
+ ):
231
+ continue
232
+ except (OSError, ValueError):
233
+ continue
234
+ pairs.append((metadata_path.with_suffix(".pem"), metadata_path))
235
+ return pairs
236
+
237
+
238
+ def _remove_managed_trust_pairs(origin: str, keep_metadata_path: Optional[Path] = None) -> bool:
239
+ """Remove all valid trust pairs for an origin except an optional replacement pair."""
240
+ removed = False
241
+ for pem_path, metadata_path in _get_managed_trust_pairs(origin):
242
+ if metadata_path == keep_metadata_path:
243
+ continue
244
+ for path in (pem_path, metadata_path):
245
+ try:
246
+ path.unlink()
247
+ removed = True
248
+ except FileNotFoundError:
249
+ pass
250
+ return removed
251
+
252
+
146
253
  def get_managed_trust_path(api_url: str) -> Optional[Path]:
147
254
  """Return the managed PEM path for a URL when a trusted certificate exists."""
148
255
  origin = get_ssl_server_origin(api_url)
149
- pem_path = _get_trust_directory() / f"{_get_trust_stem(origin)}.pem"
256
+ trust_directory = _get_trust_directory()
257
+ pem_path = trust_directory / f"{_get_trust_stem(origin)}.pem"
150
258
  metadata_path = pem_path.with_suffix(".json")
259
+
260
+ if not pem_path.is_file() or not metadata_path.is_file():
261
+ for legacy_pem_path, legacy_metadata_path in _get_managed_trust_pairs(origin):
262
+ if legacy_pem_path.is_file():
263
+ pem_path = legacy_pem_path
264
+ metadata_path = legacy_metadata_path
265
+ break
266
+
151
267
  if not pem_path.is_file() or not metadata_path.is_file():
152
268
  return None
153
269
  try:
154
270
  metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
155
- except (OSError, json.JSONDecodeError):
156
- return None
157
- if metadata.get("origin") != origin:
271
+ stored_origin = metadata.get("origin") if isinstance(metadata, dict) else None
272
+ if not isinstance(stored_origin, str) or get_ssl_server_origin(stored_origin) != origin:
273
+ return None
274
+ except (OSError, ValueError, json.JSONDecodeError):
158
275
  return None
276
+ with _SSL_CONTEXT_LOCK:
277
+ if not _restrict_managed_trust_bundle(pem_path, metadata):
278
+ return None
159
279
  return pem_path
160
280
 
161
281
 
282
+ def _restrict_managed_trust_bundle(pem_path: Path, metadata: Dict[str, Any]) -> bool:
283
+ """Keep only the metadata-fingerprinted certificate in a managed PEM file."""
284
+ trust_type = metadata.get("trust-type", "leaf")
285
+ fingerprint = metadata.get("fingerprint")
286
+ if trust_type not in ("leaf", "ca") or not isinstance(fingerprint, str):
287
+ return False
288
+
289
+ try:
290
+ original_pem = pem_path.read_bytes()
291
+ certificates = x509.load_pem_x509_certificates(original_pem)
292
+ except (OSError, ValueError):
293
+ return False
294
+
295
+ expected_fingerprint = fingerprint.replace(":", "").upper()
296
+ trusted_certificate = next(
297
+ (
298
+ certificate
299
+ for certificate in certificates
300
+ if certificate.fingerprint(hashes.SHA256()).hex().upper() == expected_fingerprint
301
+ ),
302
+ None,
303
+ )
304
+ if trusted_certificate is None:
305
+ return False
306
+ if trust_type == "ca":
307
+ try:
308
+ constraints = trusted_certificate.extensions.get_extension_for_class(
309
+ x509.BasicConstraints
310
+ )
311
+ except x509.ExtensionNotFound:
312
+ return False
313
+ if not constraints.value.ca:
314
+ return False
315
+
316
+ trusted_pem = trusted_certificate.public_bytes(serialization.Encoding.PEM)
317
+ if original_pem != trusted_pem:
318
+ temporary_pem = pem_path.with_suffix(".pem.tmp")
319
+ try:
320
+ temporary_pem.write_bytes(trusted_pem)
321
+ temporary_pem.chmod(0o600)
322
+ temporary_pem.replace(pem_path)
323
+ except OSError:
324
+ try:
325
+ temporary_pem.unlink()
326
+ except OSError:
327
+ pass
328
+ return False
329
+ return True
330
+
331
+
332
+ def _get_managed_origin(pem_path: str) -> Optional[str]:
333
+ """Identify an explicit PEM that belongs to the origin-scoped managed store."""
334
+ try:
335
+ path = Path(pem_path)
336
+ metadata = json.loads(path.with_suffix(".json").read_text(encoding="utf-8"))
337
+ origin = metadata.get("origin") if isinstance(metadata, dict) else None
338
+ if isinstance(origin, str) and get_managed_trust_path(origin) == path:
339
+ return get_ssl_server_origin(origin)
340
+ except (OSError, ValueError):
341
+ pass
342
+ return None
343
+
344
+
345
+ def _is_self_signed(certificate: x509.Certificate) -> bool:
346
+ """Distinguish self-signed certificates from self-issued rollover certificates."""
347
+ if certificate.subject != certificate.issuer:
348
+ return False
349
+ try:
350
+ certificate.verify_directly_issued_by(certificate)
351
+ except (InvalidSignature, UnsupportedAlgorithm, ValueError):
352
+ return False
353
+ return True
354
+
355
+
162
356
  def _certificate_name(name: x509.Name) -> str:
163
357
  """Format a certificate distinguished name for user-facing output."""
164
358
  return ", ".join(
@@ -190,32 +384,33 @@ def inspect_server_certificate(api_url: str, timeout: float = 5) -> ServerCertif
190
384
  assert parsed.hostname is not None
191
385
  port = parsed.port or 443
192
386
  certificate_chain_pem: Optional[bytes] = None
193
- patched_ssl_context = ssl.SSLContext
194
- try:
195
- setattr(ssl, "SSLContext", _STANDARD_SSL_CONTEXT)
196
- context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
197
- context.check_hostname = False
198
- context.verify_mode = ssl.CERT_NONE
199
-
200
- with socket.create_connection((parsed.hostname, port), timeout=timeout) as tcp_socket:
201
- with context.wrap_socket(tcp_socket, server_hostname=parsed.hostname) as tls_socket:
202
- certificate_der = tls_socket.getpeercert(binary_form=True)
203
- ssl_object = getattr(tls_socket, "_sslobj", None)
204
- get_unverified_chain = getattr(ssl_object, "get_unverified_chain", None)
205
- if callable(get_unverified_chain):
206
- try:
207
- chain = get_unverified_chain()
208
- chain_pem = b"".join(
209
- (pem.encode("ascii") if isinstance(pem, str) else pem)
210
- for chain_certificate in chain
211
- for pem in (chain_certificate.public_bytes(),)
212
- )
213
- if chain_pem:
214
- certificate_chain_pem = chain_pem
215
- except (AttributeError, OSError, ValueError):
216
- pass
217
- finally:
218
- setattr(ssl, "SSLContext", patched_ssl_context)
387
+ with _SSL_CONTEXT_LOCK:
388
+ patched_ssl_context = ssl.SSLContext
389
+ try:
390
+ setattr(ssl, "SSLContext", _STANDARD_SSL_CONTEXT)
391
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
392
+ context.check_hostname = False
393
+ context.verify_mode = ssl.CERT_NONE
394
+
395
+ with socket.create_connection((parsed.hostname, port), timeout=timeout) as tcp_socket:
396
+ with context.wrap_socket(tcp_socket, server_hostname=parsed.hostname) as tls_socket:
397
+ certificate_der = tls_socket.getpeercert(binary_form=True)
398
+ ssl_object = getattr(tls_socket, "_sslobj", None)
399
+ get_unverified_chain = getattr(ssl_object, "get_unverified_chain", None)
400
+ if callable(get_unverified_chain):
401
+ try:
402
+ chain = get_unverified_chain()
403
+ chain_pem = b"".join(
404
+ (pem.encode("ascii") if isinstance(pem, str) else pem)
405
+ for chain_certificate in chain
406
+ for pem in (chain_certificate.public_bytes(),)
407
+ )
408
+ if chain_pem:
409
+ certificate_chain_pem = chain_pem
410
+ except (AttributeError, OSError, ValueError):
411
+ pass
412
+ finally:
413
+ setattr(ssl, "SSLContext", patched_ssl_context)
219
414
 
220
415
  if not certificate_der:
221
416
  raise ssl.SSLError("The server did not provide a TLS certificate.")
@@ -238,26 +433,81 @@ def inspect_server_certificate(api_url: str, timeout: float = 5) -> ServerCertif
238
433
  sans=sans,
239
434
  not_before=not_before,
240
435
  not_after=not_after,
241
- self_signed=certificate.subject == certificate.issuer,
436
+ self_signed=_is_self_signed(certificate),
437
+ )
438
+
439
+
440
+ def load_ca_certificate(api_url: str, certificate_path: Path) -> ServerCertificate:
441
+ """Load one PEM or DER CA certificate for origin-scoped trust.
442
+
443
+ Args:
444
+ api_url: HTTPS origin to associate with the CA.
445
+ certificate_path: Public CA certificate file, not a private key or bundle.
446
+
447
+ Returns:
448
+ Validated CA certificate and identity metadata.
449
+
450
+ Raises:
451
+ ValueError: The file is not a currently valid CA certificate.
452
+ OSError: The certificate file cannot be read.
453
+ """
454
+ origin = get_ssl_server_origin(api_url)
455
+ content = certificate_path.read_bytes()
456
+ if b"-----BEGIN" in content:
457
+ certificates = x509.load_pem_x509_certificates(content)
458
+ if len(certificates) != 1 or b"PRIVATE KEY" in content:
459
+ raise ValueError("Import exactly one public CA certificate, not a bundle or key.")
460
+ certificate = certificates[0]
461
+ else:
462
+ certificate = x509.load_der_x509_certificate(content)
463
+ try:
464
+ extensions = certificate.extensions
465
+ except (x509.DuplicateExtension, x509.UnsupportedGeneralNameType, ValueError) as exc:
466
+ raise ValueError(f"Invalid CA certificate extensions: {exc}") from exc
467
+ try:
468
+ constraints = extensions.get_extension_for_class(x509.BasicConstraints)
469
+ except x509.ExtensionNotFound as exc:
470
+ raise ValueError("The certificate must have CA basic constraints.") from exc
471
+ if not constraints.value.ca:
472
+ raise ValueError("The certificate is not a CA certificate.")
473
+ try:
474
+ usage = extensions.get_extension_for_class(x509.KeyUsage)
475
+ except x509.ExtensionNotFound:
476
+ pass
477
+ else:
478
+ if not usage.value.key_cert_sign:
479
+ raise ValueError("The CA certificate does not permit certificate signing.")
480
+ not_before, not_after = _certificate_validity(certificate)
481
+ now = datetime.now(timezone.utc)
482
+ if not (
483
+ datetime.fromisoformat(not_before).replace(tzinfo=timezone.utc)
484
+ <= now
485
+ <= datetime.fromisoformat(not_after).replace(tzinfo=timezone.utc)
486
+ ):
487
+ raise ValueError("The CA certificate is expired or not yet valid.")
488
+ return ServerCertificate(
489
+ origin=origin,
490
+ pem=certificate.public_bytes(serialization.Encoding.PEM),
491
+ fingerprint=certificate.fingerprint(hashes.SHA256()).hex().upper(),
492
+ subject=_certificate_name(certificate.subject),
493
+ issuer=_certificate_name(certificate.issuer),
494
+ sans=[],
495
+ not_before=not_before,
496
+ not_after=not_after,
497
+ self_signed=_is_self_signed(certificate),
498
+ trust_type="ca",
242
499
  )
243
500
 
244
501
 
245
502
  def save_managed_certificate(certificate: ServerCertificate) -> Path:
246
503
  """Persist a server certificate and metadata in the managed trust directory."""
247
- trust_directory = _get_trust_directory()
248
- stem = _get_trust_stem(certificate.origin)
504
+ trust_directory = _get_trust_directory(create=True)
505
+ origin = get_ssl_server_origin(certificate.origin)
506
+ stem = _get_trust_stem(origin)
249
507
  pem_path = trust_directory / f"{stem}.pem"
250
508
  metadata_path = pem_path.with_suffix(".json")
251
- metadata: Dict[str, Any] = {
252
- "origin": certificate.origin,
253
- "fingerprint": certificate.fingerprint,
254
- "subject": certificate.subject,
255
- "issuer": certificate.issuer,
256
- "sans": certificate.sans,
257
- "not-before": certificate.not_before,
258
- "not-after": certificate.not_after,
259
- "self-signed": certificate.self_signed,
260
- }
509
+ metadata = certificate.to_dict()
510
+ metadata["origin"] = origin
261
511
 
262
512
  temporary_pem = pem_path.with_suffix(".pem.tmp")
263
513
  temporary_metadata = metadata_path.with_suffix(".json.tmp")
@@ -275,29 +525,36 @@ def save_managed_certificate(certificate: ServerCertificate) -> Path:
275
525
  except OSError:
276
526
  pass
277
527
  raise
528
+ _remove_managed_trust_pairs(origin, keep_metadata_path=metadata_path)
278
529
  return pem_path
279
530
 
280
531
 
281
532
  def get_managed_trust_records() -> List[Dict[str, Any]]:
282
- """Return metadata for all managed server certificates."""
533
+ """Return metadata with canonical origins for all managed server certificates."""
283
534
  records: List[Dict[str, Any]] = []
284
535
  for metadata_path in sorted(_get_trust_directory().glob("*.json")):
285
536
  try:
286
537
  metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
287
- except (OSError, json.JSONDecodeError):
538
+ origin = metadata.get("origin") if isinstance(metadata, dict) else None
539
+ if not isinstance(origin, str):
540
+ continue
541
+ metadata["origin"] = get_ssl_server_origin(origin)
542
+ except (OSError, ValueError):
288
543
  continue
289
- if isinstance(metadata, dict) and isinstance(metadata.get("origin"), str):
290
- records.append(metadata)
544
+ metadata.setdefault("trust-type", "leaf")
545
+ records.append(metadata)
291
546
  return records
292
547
 
293
548
 
294
549
  def remove_managed_trust(api_url: str) -> bool:
295
550
  """Remove the managed certificate trust entry for a server URL."""
296
551
  origin = get_ssl_server_origin(api_url)
297
- pem_path = _get_trust_directory() / f"{_get_trust_stem(origin)}.pem"
298
- metadata_path = pem_path.with_suffix(".json")
299
- removed = False
300
- for path in (pem_path, metadata_path):
552
+ trust_directory = _get_trust_directory()
553
+ removed = _remove_managed_trust_pairs(origin)
554
+ for path in (
555
+ trust_directory / f"{_get_trust_stem(origin)}.pem",
556
+ trust_directory / f"{_get_trust_stem(origin)}.json",
557
+ ):
301
558
  try:
302
559
  path.unlink()
303
560
  removed = True
slcli/webapp_click.py CHANGED
@@ -22,6 +22,7 @@ import requests
22
22
  from .cli_utils import is_interactive_environment, validate_output_format
23
23
  from .platform import PLATFORM_SLS, get_platform
24
24
  from .skill_click import install_skills_to_directory
25
+ from .ssl_trust import use_standard_ssl_context
25
26
  from .universal_handlers import UniversalResponseHandler
26
27
  from .utils import (
27
28
  ExitCodes,
@@ -109,11 +110,10 @@ def _build_published_webapp_url(
109
110
 
110
111
  if webapp_id and (not resolved_name or not resolved_workspace_id):
111
112
  try:
112
- resp = requests.get(
113
- f"{_get_webapp_base_url()}/webapps/{webapp_id}",
114
- headers=get_headers("application/json"),
115
- verify=get_ssl_verify(),
116
- )
113
+ url = f"{_get_webapp_base_url()}/webapps/{webapp_id}"
114
+ ssl_verify = get_ssl_verify(url)
115
+ with use_standard_ssl_context(ssl_verify):
116
+ resp = requests.get(url, headers=get_headers("application/json"), verify=ssl_verify)
117
117
  resp.raise_for_status()
118
118
  data = resp.json()
119
119
  resolved_name = resolved_name or str(data.get("name", ""))
@@ -295,12 +295,10 @@ def _query_webapps_http(filter_str: str, max_items: int = 1000) -> List[Dict[str
295
295
  payload["continuationToken"] = continuation_token
296
296
 
297
297
  # Request the server to include a total count when available
298
- resp = requests.post(
299
- f"{base}/webapps/query?includeTotalCount=true",
300
- headers=headers,
301
- json=payload,
302
- verify=get_ssl_verify(),
303
- )
298
+ url = f"{base}/webapps/query?includeTotalCount=true"
299
+ ssl_verify = get_ssl_verify(url)
300
+ with use_standard_ssl_context(ssl_verify):
301
+ resp = requests.post(url, headers=headers, json=payload, verify=ssl_verify)
304
302
  resp.raise_for_status()
305
303
  data = resp.json()
306
304
  page_items: List[Dict[str, Any]] = data.get("webapps", []) if isinstance(data, dict) else []
@@ -336,12 +334,10 @@ def _fetch_webapps_page(
336
334
  payload["continuationToken"] = continuation_token
337
335
 
338
336
  # Request the server to include a total count when available
339
- resp = requests.post(
340
- f"{base}/webapps/query?includeTotalCount=true",
341
- headers=headers,
342
- json=payload,
343
- verify=get_ssl_verify(),
344
- )
337
+ url = f"{base}/webapps/query?includeTotalCount=true"
338
+ ssl_verify = get_ssl_verify(url)
339
+ with use_standard_ssl_context(ssl_verify):
340
+ resp = requests.post(url, headers=headers, json=payload, verify=ssl_verify)
345
341
  resp.raise_for_status()
346
342
  data = resp.json()
347
343
  items = data.get("webapps", []) if isinstance(data, dict) else []
@@ -1491,11 +1487,10 @@ def register_webapp_commands(cli: Any) -> None:
1491
1487
  """Show webapp metadata."""
1492
1488
  try:
1493
1489
  base = _get_webapp_base_url()
1494
- resp = requests.get(
1495
- f"{base}/webapps/{webapp_id}",
1496
- headers=get_headers("application/json"),
1497
- verify=get_ssl_verify(),
1498
- )
1490
+ url = f"{base}/webapps/{webapp_id}"
1491
+ ssl_verify = get_ssl_verify(url)
1492
+ with use_standard_ssl_context(ssl_verify):
1493
+ resp = requests.get(url, headers=get_headers("application/json"), verify=ssl_verify)
1499
1494
  resp.raise_for_status()
1500
1495
  data = resp.json()
1501
1496
  if data.get("type", "") != "WebVI":
@@ -1516,9 +1511,10 @@ def register_webapp_commands(cli: Any) -> None:
1516
1511
 
1517
1512
  try:
1518
1513
  base = _get_webapp_base_url()
1519
- resp = requests.delete(
1520
- f"{base}/webapps/{webapp_id}", headers=get_headers(), verify=get_ssl_verify()
1521
- )
1514
+ url = f"{base}/webapps/{webapp_id}"
1515
+ ssl_verify = get_ssl_verify(url)
1516
+ with use_standard_ssl_context(ssl_verify):
1517
+ resp = requests.delete(url, headers=get_headers(), verify=ssl_verify)
1522
1518
  # Validate response and type if possible
1523
1519
  try:
1524
1520
  data = resp.json()
@@ -1542,11 +1538,10 @@ def register_webapp_commands(cli: Any) -> None:
1542
1538
 
1543
1539
  try:
1544
1540
  base = _get_webapp_base_url()
1545
- resp = requests.get(
1546
- f"{base}/webapps/{webapp_id}",
1547
- headers=get_headers("application/json"),
1548
- verify=get_ssl_verify(),
1549
- )
1541
+ url = f"{base}/webapps/{webapp_id}"
1542
+ ssl_verify = get_ssl_verify(url)
1543
+ with use_standard_ssl_context(ssl_verify):
1544
+ resp = requests.get(url, headers=get_headers("application/json"), verify=ssl_verify)
1550
1545
  resp.raise_for_status()
1551
1546
  data = resp.json()
1552
1547
  app_url = _build_published_webapp_url(
@@ -1630,12 +1625,15 @@ def register_webapp_commands(cli: Any) -> None:
1630
1625
  "policyIds": [],
1631
1626
  "properties": {},
1632
1627
  }
1633
- resp_create = requests.post(
1634
- f"{base}/webapps",
1635
- headers=get_headers("application/json"),
1636
- json=payload,
1637
- verify=get_ssl_verify(),
1638
- )
1628
+ url = f"{base}/webapps"
1629
+ ssl_verify = get_ssl_verify(url)
1630
+ with use_standard_ssl_context(ssl_verify):
1631
+ resp_create = requests.post(
1632
+ url,
1633
+ headers=get_headers("application/json"),
1634
+ json=payload,
1635
+ verify=ssl_verify,
1636
+ )
1639
1637
  _handle_webapp_create_conflict(
1640
1638
  resp_create,
1641
1639
  workspace_id=created_workspace_id,
@@ -1656,9 +1654,11 @@ def register_webapp_commands(cli: Any) -> None:
1656
1654
 
1657
1655
  upload_headers = get_headers("application/octet-stream")
1658
1656
  url = f"{base}/webapps/{webapp_id}/content"
1659
- resp = requests.put(
1660
- url, headers=upload_headers, data=data, verify=get_ssl_verify()
1661
- )
1657
+ ssl_verify = get_ssl_verify(url)
1658
+ with use_standard_ssl_context(ssl_verify):
1659
+ resp = requests.put(
1660
+ url, headers=upload_headers, data=data, verify=ssl_verify
1661
+ )
1662
1662
  if resp.status_code in (200, 201, 204):
1663
1663
  workspace_name_hint = (
1664
1664
  get_effective_workspace(workspace) or workspace
@@ -1708,12 +1708,15 @@ def register_webapp_commands(cli: Any) -> None:
1708
1708
  "policyIds": [],
1709
1709
  "properties": {},
1710
1710
  }
1711
- resp_create = requests.post(
1712
- f"{base}/webapps",
1713
- headers=get_headers("application/json"),
1714
- json=payload,
1715
- verify=get_ssl_verify(),
1716
- )
1711
+ url = f"{base}/webapps"
1712
+ ssl_verify = get_ssl_verify(url)
1713
+ with use_standard_ssl_context(ssl_verify):
1714
+ resp_create = requests.post(
1715
+ url,
1716
+ headers=get_headers("application/json"),
1717
+ json=payload,
1718
+ verify=ssl_verify,
1719
+ )
1717
1720
  _handle_webapp_create_conflict(
1718
1721
  resp_create,
1719
1722
  workspace_id=created_workspace_id,
@@ -1734,7 +1737,9 @@ def register_webapp_commands(cli: Any) -> None:
1734
1737
 
1735
1738
  upload_headers = get_headers("application/octet-stream")
1736
1739
  url = f"{base}/webapps/{webapp_id}/content"
1737
- resp = requests.put(url, headers=upload_headers, data=data, verify=get_ssl_verify())
1740
+ ssl_verify = get_ssl_verify(url)
1741
+ with use_standard_ssl_context(ssl_verify):
1742
+ resp = requests.put(url, headers=upload_headers, data=data, verify=ssl_verify)
1738
1743
  if resp.status_code in (200, 201, 204):
1739
1744
  workspace_name_hint = (
1740
1745
  (get_effective_workspace(workspace) or workspace)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: systemlink-cli
3
- Version: 2.2.2
3
+ Version: 2.3.0
4
4
  Summary: SystemLink Integrator CLI - cross-platform CLI for SystemLink workflows and templates.
5
5
  License-File: LICENSE
6
6
  Author: Fred Visser
@@ -18,6 +18,7 @@ Requires-Dist: cryptography (>=49.0.0)
18
18
  Requires-Dist: filelock (>=3.20,<4)
19
19
  Requires-Dist: httpcore2 (>=2.10.0,<3) ; extra == "mcp"
20
20
  Requires-Dist: httpx2 (>=2.12.0,<3) ; extra == "mcp"
21
+ Requires-Dist: idna (>=3.10,<4)
21
22
  Requires-Dist: keyring (>=25.6.0,<26.0.0)
22
23
  Requires-Dist: mcp (>=2,<3) ; extra == "mcp"
23
24
  Requires-Dist: msgpack (>=1.1,<2) ; extra == "managed-client"
@@ -2,7 +2,7 @@ dff-editor/editor.js,sha256=VQJNqtkFSeXec_hidYdbsvhcKo8-dcdMoU5qvGVn3YQ,97488
2
2
  dff-editor/index.html,sha256=Bbicid_Fx66W7ypHgSGJrUtkAEFcLP3HkRoxiGKZjCs,11657
3
3
  slcli/__init__.py,sha256=ZRF050VsFgb4zeyyQzQINQ4eHFwEBBoUq-CPyziIB6g,66
4
4
  slcli/__main__.py,sha256=wUlBlvYS3Tn4ipw9_7zMr2d1houRpNnALG0TxHg6xZg,829
5
- slcli/_version.py,sha256=VsTk7QEnWfMUjwPRqj4QXSKq0-sD6e6MAd-859KEdZg,113
5
+ slcli/_version.py,sha256=LPSvaTsKdbuxqhCtWPW3xyvCHxMTxr8qhwwJO9a3YAA,113
6
6
  slcli/alarm_click.py,sha256=0z0SOO_LJQR9dYqLdOTfZgA4Ogl8d1fsJ7TrJHukDsU,32053
7
7
  slcli/asset_click.py,sha256=rBA52bBjn5Kmv0kHv4ft1vBMXFvMGmR292AbX3s_zvY,44544
8
8
  slcli/cli_formatters.py,sha256=ShrU2RbnQUEKhJ6ahWuv8c3zw7C-yg0o20i1opq9gZk,7275
@@ -10,7 +10,7 @@ slcli/cli_utils.py,sha256=tXagGGAeEVvxlKpyHSzbNiosjhrxMmwgDUMJ4glAK1I,13087
10
10
  slcli/comment_click.py,sha256=klbOQfFYVQ-HDi1hoHWuZE3X8S_5iI2HWfPaAYyICOQ,21048
11
11
  slcli/completion_click.py,sha256=KYnGP-1LT-iQVg32BRQOmX5LQspVjZ7D--tpH0qh_MA,13952
12
12
  slcli/config.py,sha256=tdVP-1cvy6Qs2ThGXv4nPNa5AoqNu1SG6oOwgMJ6Yx0,2221
13
- slcli/config_click.py,sha256=vOpaTMHoF4smYxoqV1xLMps7Q-re_3Oi7ULCf_2lFHE,42462
13
+ slcli/config_click.py,sha256=06grrTamRCs725v_XLKNTUwevW17GjKkEM9p96Wpr-A,44393
14
14
  slcli/credentials.py,sha256=vKTS7hNAt3t7HB4ggWOZKMLNISefbT2hpCXtyEAyMCg,19996
15
15
  slcli/dataframe_click.py,sha256=2xUA9I6Rh8Fx0YfPDUMMT844SLWB2mRC3b1ccmBiEcw,44042
16
16
  slcli/dff_click.py,sha256=eqpclQ59C7yoVHTfpZ-UF6I85wNes1JvKO9_fx0yN7E,39100
@@ -44,7 +44,7 @@ slcli/feed_click.py,sha256=IzE_VwQ8IXmEgqe04afd-wE91JcV7pT3mZ5eYIT4MF4,30437
44
44
  slcli/file_click.py,sha256=5dpUgUc9ZuxG4cWO3pFjnoTyN8wB8RkndE7c5t5_x50,45841
45
45
  slcli/function_click.py,sha256=L8FqhkLc2BZVnRK97mRiLBlLImYmCu4FSOKJOVHvt1U,54278
46
46
  slcli/function_templates.py,sha256=GfmsBF0KrsIp2rBiO9ha5UAHv0O9g7yr7xcA4l8vbi8,3371
47
- slcli/main.py,sha256=Fu_RGgmDsg2UTtFxAMtwjtK0B_JucyRDhgp_ZkbKCG4,29218
47
+ slcli/main.py,sha256=HXCrJD7OwC3f4mg1VkFL70ZJREHB1gnbl3x9MhmMHW4,29235
48
48
  slcli/managed_client/__init__.py,sha256=OBOqV5QisENmueusHloKye9CWldM1QvLBOTDSDiETVQ,1564
49
49
  slcli/managed_client/crypto.py,sha256=WLtyXIciEPglCVvvAnWXKvbbOWnv2_9q79505p1PQkQ,16767
50
50
  slcli/managed_client/handlers.py,sha256=JvD2d8I0Wnm_9XwgJzgnXBM_g_3F_5YkJJ-3lqmGvP0,17770
@@ -112,7 +112,7 @@ slcli/skills/slcli/scripts/prepare_eval_workspace.py,sha256=pBQz6-wCD9kzZx_GE3Qr
112
112
  slcli/skills/slcli/scripts/render_eval_review.py,sha256=6qd7VutQT_KdpPCEW5TtidS_HRA1Bp_wXi58NCm7Kco,2171
113
113
  slcli/skills/slcli/scripts/spec_import_helper.py,sha256=p6npcjrFTK0OnAwPQRu4O211WLMJ4Fte9NtqU-73cog,11429
114
114
  slcli/spec_click.py,sha256=xDN7qZ90b4v73O94bzyeplUjJBdLpvrsj3D6Y9zMJs4,77305
115
- slcli/ssl_trust.py,sha256=EJnmSJT_dpLoYRCvLXSu_WPHXzjWrE0k91Er1Matm8g,14297
115
+ slcli/ssl_trust.py,sha256=gSU004tHLkN8k9T4apYIzuX9hdmdXkRn0-Pmivcr6C4,24857
116
116
  slcli/state_click.py,sha256=dceJN-L4OMqouj83vOs-BeIzm_HscZzchTa2ZQ5S1_I,38617
117
117
  slcli/system_click.py,sha256=HoRTM-Sv5PqIB9YMm4PTUxUv7vMc9JH_byZ9ty-sTow,109572
118
118
  slcli/system_query_utils.py,sha256=x4Fs5XXI4_3X4mhDQUsm_DLIsSX7efM1sRTF1Tcq8Lc,5156
@@ -127,7 +127,7 @@ slcli/utils.py,sha256=EAFaCt_FyZyb8HkMiYKwrS3J5fV9gvOlZEFxcjAaPuU,37356
127
127
  slcli/version_click.py,sha256=qo2VHOlvsEUiw2-Sw1L50pCo-tLBPCJvfTte02oXajY,6998
128
128
  slcli/web_editor.py,sha256=UR7IEEwxKrwvotA4T5DI6zHnmO0A-0cG7SOZHSfVYE4,17290
129
129
  slcli/webapp_bootstrap.py,sha256=pBcfsJ7G4cRMNGcK9H4rkfGhqgRqCzhVpW4WDZlsd1w,53331
130
- slcli/webapp_click.py,sha256=TgdTtWaPT7ksJatbtoVXjtfCFhMxU5vYcDK2j0agALY,69853
130
+ slcli/webapp_click.py,sha256=CfalwkU1Z3rg18gu3kj2Azsse0H3vkvTw81Bigl-rNE,70836
131
131
  slcli/webapp_templates/angular/blank/.gitignore,sha256=B4bZYaFkEMoZooOeUyaF3er0BKFS4rdFiCzo2Z6syv8,40
132
132
  slcli/webapp_templates/angular/blank/README.md,sha256=TD0MfAJVIM8sKpPEcXig_HgksOLc983Q4wKsjV7gANg,3943
133
133
  slcli/webapp_templates/angular/blank/angular.json,sha256=2YnTokjGHnHu0VEszCI_DhpFnOABkwVtmDgIZCTnCck,2677
@@ -184,8 +184,8 @@ slcli/workflows_click.py,sha256=Ymb69WCzxhAQZ5bv0jTcXWlXy1yskXhIuv7FbgRBSWk,4065
184
184
  slcli/workitem_click.py,sha256=hmWijNzAvzvNBN0Y1wXoZZbUStRyrVIqg7J2xfJBFxw,88444
185
185
  slcli/workspace_click.py,sha256=BBiaOmAe_EzkpCG6TlR8aCrwOI81xrMOzMObAfuM_5w,20932
186
186
  slcli/workspace_utils.py,sha256=7QOtotmv3ghJs6a0fDQVojKxrBKPeq_GtE4F1SmZKnM,6872
187
- systemlink_cli-2.2.2.dist-info/METADATA,sha256=oKjuNK7W_4kxQYYEEE8Rl5gA6O5_PFlr_ufOkAgi2qs,1320
188
- systemlink_cli-2.2.2.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
189
- systemlink_cli-2.2.2.dist-info/entry_points.txt,sha256=V8IyRF-NYesTOTA_yB4-lVK1EsKZsSY5zOjzGbh5zdA,288
190
- systemlink_cli-2.2.2.dist-info/licenses/LICENSE,sha256=ioLAEBMHywwQkOr14pBIc7nenmWrc-bgcMrqsB_Uvq8,1077
191
- systemlink_cli-2.2.2.dist-info/RECORD,,
187
+ systemlink_cli-2.3.0.dist-info/METADATA,sha256=BGSZgIQi6_aYfwrjLkhM0PPJuO3w4DTNNyz-JG0IjaI,1352
188
+ systemlink_cli-2.3.0.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
189
+ systemlink_cli-2.3.0.dist-info/entry_points.txt,sha256=V8IyRF-NYesTOTA_yB4-lVK1EsKZsSY5zOjzGbh5zdA,288
190
+ systemlink_cli-2.3.0.dist-info/licenses/LICENSE,sha256=ioLAEBMHywwQkOr14pBIc7nenmWrc-bgcMrqsB_Uvq8,1077
191
+ systemlink_cli-2.3.0.dist-info/RECORD,,