systemlink-cli 1.4.2__tar.gz → 1.4.4__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 (74) hide show
  1. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/PKG-INFO +1 -1
  2. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/pyproject.toml +1 -1
  3. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/_version.py +1 -1
  4. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/config_click.py +8 -0
  5. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/examples/demo-complete-workflow/README.md +1 -3
  6. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/file_click.py +136 -11
  7. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/main.py +10 -0
  8. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/platform.py +107 -53
  9. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/user_click.py +1 -1
  10. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/LICENSE +0 -0
  11. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/dff-editor/editor.js +0 -0
  12. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/dff-editor/index.html +0 -0
  13. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/__init__.py +0 -0
  14. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/__main__.py +0 -0
  15. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/asset_click.py +0 -0
  16. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/cli_formatters.py +0 -0
  17. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/cli_utils.py +0 -0
  18. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/comment_click.py +0 -0
  19. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/completion_click.py +0 -0
  20. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/config.py +0 -0
  21. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/dff_click.py +0 -0
  22. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/dff_decorators.py +0 -0
  23. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/example_click.py +0 -0
  24. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/example_loader.py +0 -0
  25. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/example_provisioner.py +0 -0
  26. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/examples/README.md +0 -0
  27. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/examples/_schema/schema-v1.0.json +0 -0
  28. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/examples/demo-complete-workflow/config.yaml +0 -0
  29. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/examples/demo-test-plans/README.md +0 -0
  30. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/examples/demo-test-plans/config.yaml +0 -0
  31. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/examples/exercise-5-1-parametric-insights/README.md +0 -0
  32. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/examples/exercise-5-1-parametric-insights/config.yaml +0 -0
  33. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/examples/exercise-7-1-test-plans/README.md +0 -0
  34. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/examples/exercise-7-1-test-plans/config.yaml +0 -0
  35. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/examples/spec-compliance-notebooks/README.md +0 -0
  36. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/examples/spec-compliance-notebooks/config.yaml +0 -0
  37. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +0 -0
  38. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +0 -0
  39. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +0 -0
  40. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
  41. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/feed_click.py +0 -0
  42. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/function_click.py +0 -0
  43. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/function_templates.py +0 -0
  44. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/mcp_click.py +0 -0
  45. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/mcp_server.py +0 -0
  46. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/notebook_click.py +0 -0
  47. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/policy_click.py +0 -0
  48. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/policy_utils.py +0 -0
  49. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/profiles.py +0 -0
  50. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/response_handlers.py +0 -0
  51. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/routine_click.py +0 -0
  52. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/skill_click.py +0 -0
  53. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/skills/slcli/SKILL.md +0 -0
  54. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/skills/slcli/references/analysis-recipes.md +0 -0
  55. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/skills/slcli/references/filtering.md +0 -0
  56. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/skills/systemlink-webapp/SKILL.md +0 -0
  57. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/skills/systemlink-webapp/references/deployment.md +0 -0
  58. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/skills/systemlink-webapp/references/nimble-angular.md +0 -0
  59. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/skills/systemlink-webapp/references/systemlink-services.md +0 -0
  60. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/ssl_trust.py +0 -0
  61. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/system_click.py +0 -0
  62. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/table_utils.py +0 -0
  63. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/tag_click.py +0 -0
  64. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/templates_click.py +0 -0
  65. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/testmonitor_click.py +0 -0
  66. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/universal_handlers.py +0 -0
  67. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/utils.py +0 -0
  68. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/web_editor.py +0 -0
  69. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/webapp_click.py +0 -0
  70. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/workflow_preview.py +0 -0
  71. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/workflows_click.py +0 -0
  72. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/workitem_click.py +0 -0
  73. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/workspace_click.py +0 -0
  74. {systemlink_cli-1.4.2 → systemlink_cli-1.4.4}/slcli/workspace_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: systemlink-cli
3
- Version: 1.4.2
3
+ Version: 1.4.4
4
4
  Summary: SystemLink Integrator CLI - cross-platform CLI for SystemLink workflows and templates.
5
5
  License-File: LICENSE
6
6
  Author: Fred Visser
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "systemlink-cli"
3
- version = "1.4.2"
3
+ version = "1.4.4"
4
4
  description = "SystemLink Integrator CLI - cross-platform CLI for SystemLink workflows and templates."
5
5
  authors = ["Fred Visser <fred.visser@emerson.com>"]
6
6
  packages = [{ include = "slcli" }]
@@ -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__ = "1.4.2"
4
+ __version__ = "1.4.4"
@@ -64,6 +64,7 @@ def _add_profile_impl(
64
64
  elif not url.startswith("https://"):
65
65
  click.echo("⚠️ Warning: Adding HTTPS protocol to URL.")
66
66
  url = f"https://{url}"
67
+ url = url.rstrip("/")
67
68
 
68
69
  # Get API key - either from flag or prompt
69
70
  if not api_key:
@@ -86,6 +87,7 @@ def _add_profile_impl(
86
87
  elif not web_url.startswith("https://"):
87
88
  click.echo("⚠️ Warning: Adding HTTPS protocol to web URL.")
88
89
  web_url = f"https://{web_url}"
90
+ web_url = web_url.rstrip("/")
89
91
 
90
92
  # Detect platform type and check service status
91
93
  click.echo("Checking server connectivity and services...")
@@ -113,6 +115,12 @@ def _add_profile_impl(
113
115
  elif status["auth_valid"] is True:
114
116
  click.echo(" API key: ✓ Authorized")
115
117
 
118
+ if status.get("elasticsearch_available") is False:
119
+ click.echo(" File query: query-files-linq (Elasticsearch unavailable)")
120
+ click.echo(
121
+ " 'slcli file list' will fall back automatically; 'slcli file query' requires search-files."
122
+ )
123
+
116
124
  # Report individual service status
117
125
  services = status.get("services", {})
118
126
  problem_services = [
@@ -303,9 +303,7 @@ slcli result list -w <workspace>
303
303
 
304
304
  ## Additional Resources
305
305
 
306
- - **SystemLink API Documentation**: https://dev-api.lifecyclesolutions.ni.com/
307
- - **Test Monitor Specs**: https://dev-api.lifecyclesolutions.ni.com/nitestmonitor/swagger/
308
- - **Work Item API**: https://dev-api.lifecyclesolutions.ni.com/niworkitem/swagger/
306
+ - **SystemLink API Documentation**: https://demo-api.lifecyclesolutions.ni.com/niapis/
309
307
  - **systemlink-cli GitHub**: https://github.com/ni/systemlink-cli
310
308
 
311
309
  ## Contact & Support
@@ -10,9 +10,10 @@ from typing import Any, Dict, List, Optional
10
10
 
11
11
  import click
12
12
  import questionary
13
+ import requests as requests_lib
13
14
 
14
15
  from .cli_utils import validate_output_format
15
- from .universal_handlers import UniversalResponseHandler
16
+ from .universal_handlers import FilteredResponse, UniversalResponseHandler
16
17
  from .utils import (
17
18
  ExitCodes,
18
19
  format_success,
@@ -29,6 +30,120 @@ def _get_file_service_url() -> str:
29
30
  return f"{get_base_url()}/nifile/v1/service-groups/Default"
30
31
 
31
32
 
33
+ def _get_search_files_url() -> str:
34
+ """Get the search-files endpoint URL."""
35
+ return f"{_get_file_service_url()}/search-files"
36
+
37
+
38
+ def _is_search_files_unavailable(exc: requests_lib.HTTPError) -> bool:
39
+ """Return whether the search-files endpoint is unavailable."""
40
+ return exc.response is not None and exc.response.status_code in (404, 501)
41
+
42
+
43
+ def _exit_search_files_required() -> None:
44
+ """Exit with a user-facing message when search-files is unavailable."""
45
+ click.echo(
46
+ "✗ File query requires the search-files endpoint, but Elasticsearch is not available.",
47
+ err=True,
48
+ )
49
+ click.echo(
50
+ " Use 'slcli file list' for automatic fallback to query-files-linq.",
51
+ err=True,
52
+ )
53
+ sys.exit(ExitCodes.INVALID_INPUT)
54
+
55
+
56
+ def _search_files_with_fallback(
57
+ payload: Dict[str, Any],
58
+ workspace_id: Optional[str],
59
+ name_filter: Optional[str],
60
+ id_filter: Optional[str],
61
+ ) -> Any:
62
+ """Try search-files endpoint, fall back to query-files-linq if unavailable.
63
+
64
+ The search-files endpoint requires additional software (DataFinder) to be
65
+ installed. If it is not available (HTTP 404), we fall back to query-files-linq.
66
+
67
+ Args:
68
+ payload: The search-files request payload
69
+ workspace_id: Optional workspace ID filter
70
+ name_filter: Optional name filter string
71
+ id_filter: Optional comma-separated file IDs
72
+
73
+ Returns:
74
+ Response object (real or FilteredResponse)
75
+ """
76
+ try:
77
+ return make_api_request(
78
+ "POST", _get_search_files_url(), payload=payload, handle_errors=False
79
+ )
80
+ except requests_lib.HTTPError as exc:
81
+ if _is_search_files_unavailable(exc):
82
+ return _query_files_linq_fallback(
83
+ take=payload.get("take", 25),
84
+ workspace_id=workspace_id,
85
+ name_filter=name_filter,
86
+ id_filter=id_filter,
87
+ )
88
+ raise
89
+
90
+
91
+ def _query_files_linq_fallback(
92
+ take: int,
93
+ workspace_id: Optional[str],
94
+ name_filter: Optional[str],
95
+ id_filter: Optional[str],
96
+ ) -> Any:
97
+ """List files using query-files-linq as a fallback when search-files is unavailable.
98
+
99
+ Args:
100
+ take: Maximum number of files to return
101
+ workspace_id: Optional workspace ID filter
102
+ name_filter: Optional name filter string (applied client-side)
103
+ id_filter: Optional comma-separated file IDs
104
+
105
+ Returns:
106
+ Response or FilteredResponse with availableFiles data
107
+ """
108
+ base_url = _get_file_service_url()
109
+ url = f"{base_url}/query-files-linq"
110
+
111
+ # Build LINQ-style filter
112
+ filter_parts: List[str] = []
113
+
114
+ if id_filter:
115
+ ids = [fid.strip() for fid in id_filter.split(",")]
116
+ id_conditions = [f'id = "{fid}"' for fid in ids]
117
+ filter_parts.append("(" + " or ".join(id_conditions) + ")")
118
+
119
+ linq_payload: Dict[str, Any] = {}
120
+
121
+ # Request more if we need to filter client-side by name
122
+ if name_filter:
123
+ linq_payload["take"] = max(take * 4, 1000)
124
+ else:
125
+ linq_payload["take"] = take
126
+
127
+ if filter_parts:
128
+ linq_payload["filter"] = " and ".join(filter_parts)
129
+
130
+ # Workspace filtering via query parameter
131
+ if workspace_id:
132
+ url += f"?workspace={workspace_id}"
133
+
134
+ resp = make_api_request("POST", url, payload=linq_payload)
135
+
136
+ # Client-side name filtering (query-files-linq doesn't support name wildcards)
137
+ if name_filter:
138
+ data = resp.json()
139
+ files = data.get("availableFiles", [])
140
+ needle = name_filter.lower()
141
+ filtered = [f for f in files if needle in _get_file_name(f).lower()][:take]
142
+ return FilteredResponse({"availableFiles": filtered})
143
+
144
+ return resp
145
+
146
+
32
147
  def _format_file_size(size_bytes: Optional[int]) -> str:
33
148
  """Format file size in human-readable format.
34
149
 
@@ -178,9 +293,6 @@ def register_file_commands(cli: Any) -> None:
178
293
  """
179
294
  format_output = validate_output_format(format)
180
295
 
181
- # Use search-files endpoint for better performance
182
- url = f"{_get_file_service_url()}/search-files"
183
-
184
296
  try:
185
297
  # Resolve workspace name to ID if needed
186
298
  workspace_id = None
@@ -189,7 +301,7 @@ def register_file_commands(cli: Any) -> None:
189
301
  workspace_map = get_workspace_map()
190
302
  workspace_id = resolve_workspace_filter(workspace, workspace_map)
191
303
 
192
- # Build search filter
304
+ # Build search filter for search-files endpoint
193
305
  filter_parts = []
194
306
 
195
307
  if workspace_id:
@@ -221,7 +333,13 @@ def register_file_commands(cli: Any) -> None:
221
333
  if filter_parts:
222
334
  payload["filter"] = " AND ".join(filter_parts)
223
335
 
224
- resp = make_api_request("POST", url, payload=payload)
336
+ # Try search-files, fall back to query-files-linq if unavailable
337
+ resp = _search_files_with_fallback(
338
+ payload=payload,
339
+ workspace_id=workspace_id,
340
+ name_filter=name_filter,
341
+ id_filter=id_filter,
342
+ )
225
343
 
226
344
  def file_formatter(file_item: dict) -> list:
227
345
  name = _get_file_name(file_item)
@@ -567,11 +685,8 @@ def register_file_commands(cli: Any) -> None:
567
685
  """
568
686
  format_output = validate_output_format(format)
569
687
 
570
- # Use search-files endpoint for better performance
571
- url = f"{_get_file_service_url()}/search-files"
572
-
573
688
  try:
574
- # Build request body
689
+ # Build request body for search-files
575
690
  query_body: Dict[str, Any] = {
576
691
  "take": take if format_output.lower() == "json" else (take if take != 25 else 1000),
577
692
  "orderByDescending": descending,
@@ -584,6 +699,7 @@ def register_file_commands(cli: Any) -> None:
584
699
  filter_parts.append(filter_query)
585
700
 
586
701
  # Resolve workspace name to ID if needed
702
+ workspace_id = None
587
703
  workspace = get_effective_workspace(workspace)
588
704
  if workspace:
589
705
  workspace_map = get_workspace_map()
@@ -598,7 +714,12 @@ def register_file_commands(cli: Any) -> None:
598
714
  else:
599
715
  query_body["orderBy"] = "updated"
600
716
 
601
- resp = make_api_request("POST", url, payload=query_body)
717
+ resp = make_api_request(
718
+ "POST",
719
+ _get_search_files_url(),
720
+ payload=query_body,
721
+ handle_errors=False,
722
+ )
602
723
 
603
724
  def file_formatter(file_item: dict) -> list:
604
725
  name = _get_file_name(file_item)
@@ -620,6 +741,10 @@ def register_file_commands(cli: Any) -> None:
620
741
  page_size=take,
621
742
  )
622
743
 
744
+ except requests_lib.HTTPError as exc:
745
+ if _is_search_files_unavailable(exc):
746
+ _exit_search_files_required()
747
+ handle_api_error(exc)
623
748
  except Exception as exc:
624
749
  handle_api_error(exc)
625
750
 
@@ -368,6 +368,15 @@ def info(format: str, skip_health: bool) -> None:
368
368
  workspace_display = truncate(workspace)
369
369
  click.echo(f"│ Workspace: {workspace_display:<48}│")
370
370
 
371
+ file_query_endpoint = platform_info.get("file_query_endpoint")
372
+ if file_query_endpoint:
373
+ if platform_info.get("elasticsearch_available") is False:
374
+ file_query_display = f"{file_query_endpoint} (Elasticsearch unavailable)"
375
+ else:
376
+ file_query_display = str(file_query_endpoint)
377
+ file_query_display = truncate(file_query_display)
378
+ click.echo(f"│ File Query:{file_query_display:<48}│")
379
+
371
380
  # Service Health section (only when services were checked)
372
381
  services = platform_info.get("services")
373
382
  if services:
@@ -377,6 +386,7 @@ def info(format: str, skip_health: bool) -> None:
377
386
 
378
387
  status_display = {
379
388
  "ok": ("✓", "OK"),
389
+ "fallback": ("!", "Fallback (no Elasticsearch)"),
380
390
  "unauthorized": ("✗", "Unauthorized"),
381
391
  "not_found": ("—", "Not available"),
382
392
  "error": ("✗", "Error"),
@@ -56,6 +56,9 @@ FEATURE_DISPLAY_NAMES: Dict[str, str] = {
56
56
  "webapp": "Web Applications",
57
57
  }
58
58
 
59
+ FILE_SEARCH_PATH = "/nifile/v1/service-groups/Default/search-files"
60
+ FILE_QUERY_LINQ_PATH = "/nifile/v1/service-groups/Default/query-files-linq"
61
+
59
62
 
60
63
  def _get_keyring_config() -> Dict[str, Any]:
61
64
  """Attempt to read a single JSON config entry from keyring.
@@ -84,7 +87,6 @@ def detect_platform(api_url: str, api_key: str) -> str:
84
87
  Uses check_service_status to probe services and determine:
85
88
  - Platform type (SLE vs SLS)
86
89
  - Server reachability
87
- Falls back to URL pattern matching when probe is inconclusive.
88
90
 
89
91
  Args:
90
92
  api_url: The SystemLink API base URL
@@ -98,45 +100,6 @@ def detect_platform(api_url: str, api_key: str) -> str:
98
100
  return status["platform"]
99
101
 
100
102
 
101
- def _detect_platform_from_url(api_url: str) -> str:
102
- """Detect platform from URL pattern without making network requests.
103
-
104
- This is a lightweight detection for use when environment variables
105
- are set and we need quick platform detection.
106
-
107
- SLE (SystemLink Enterprise Cloud) URLs typically contain:
108
- - api.systemlink.io (production)
109
- - dev-api.lifecyclesolutions.ni.com (development)
110
- - demo-api.lifecyclesolutions.ni.com (demo)
111
-
112
- On-premises SystemLink Server (SLS) instances may use custom domains
113
- or even *.systemlink.io subdomains (like base.systemlink.io).
114
-
115
- Args:
116
- api_url: The SystemLink API base URL
117
-
118
- Returns:
119
- Platform identifier: PLATFORM_SLE or PLATFORM_SLS.
120
- Note: This function never returns PLATFORM_UNKNOWN - it defaults to SLS.
121
- """
122
- api_url_lower = api_url.lower()
123
-
124
- # SLE cloud service has specific URL patterns
125
- sle_patterns = [
126
- "api.systemlink.io", # SLE production
127
- "-api.lifecyclesolutions.ni.com", # SLE dev/demo with -api suffix
128
- "dev-api.lifecyclesolutions",
129
- "demo-api.lifecyclesolutions",
130
- ]
131
- for pattern in sle_patterns:
132
- if pattern in api_url_lower:
133
- return PLATFORM_SLE
134
-
135
- # Default to SLS for on-premises/custom URLs
136
- # This includes on-prem servers that may use *.systemlink.io subdomains
137
- return PLATFORM_SLS
138
-
139
-
140
103
  @lru_cache(maxsize=1)
141
104
  def get_platform() -> str:
142
105
  """Get the current platform from stored configuration or environment.
@@ -144,8 +107,7 @@ def get_platform() -> str:
144
107
  Detection priority:
145
108
  1. SYSTEMLINK_PLATFORM environment variable (explicit, most reliable)
146
109
  2. Stored platform from keyring config (set during login via endpoint probing)
147
- 3. URL pattern matching (fallback, less reliable)
148
- 4. Return PLATFORM_UNKNOWN if all methods fail
110
+ 3. Return PLATFORM_UNKNOWN if no explicit or stored platform is available
149
111
 
150
112
  Note: Results are cached for performance. Use clear_platform_cache() to reset.
151
113
 
@@ -166,12 +128,6 @@ def get_platform() -> str:
166
128
  if platform in (PLATFORM_SLE, PLATFORM_SLS):
167
129
  return platform
168
130
 
169
- # Priority 3: URL pattern matching (fallback, less reliable)
170
- # Only used when env vars are set but no explicit platform is provided
171
- env_url = os.environ.get("SYSTEMLINK_API_URL")
172
- if env_url:
173
- return _detect_platform_from_url(env_url)
174
-
175
131
  return PLATFORM_UNKNOWN
176
132
 
177
133
 
@@ -240,7 +196,7 @@ SERVICE_CHECKS: List[List[str]] = [
240
196
  ["Asset Management", "POST", "/niapm/v1/query-assets"],
241
197
  ["Systems", "POST", "/nisysmgmt/v1/query-systems"],
242
198
  ["Tag", "GET", "/nitag/v2/tags?take=0"],
243
- ["File", "POST", "/nifile/v1/service-groups/Default/search-files"],
199
+ ["File", "POST", FILE_SEARCH_PATH],
244
200
  ["Notebook", "POST", "/ninotebook/v1/notebook/query"],
245
201
  ["Web Application", "POST", "/niapp/v1/webapps/query"],
246
202
  ["Dynamic Form Fields", "GET", "/nidynamicformfields/v1/groups"],
@@ -248,6 +204,87 @@ SERVICE_CHECKS: List[List[str]] = [
248
204
  ]
249
205
 
250
206
 
207
+ def get_file_query_capability(api_url: str, api_key: str) -> Dict[str, Any]:
208
+ """Determine which file query endpoint is available for this server."""
209
+ headers = {
210
+ "x-ni-api-key": api_key,
211
+ "Content-Type": "application/json",
212
+ "User-Agent": "SystemLink-CLI/1.0 (cross-platform)",
213
+ }
214
+ ssl_verify = get_ssl_verify()
215
+
216
+ try:
217
+ search_resp = requests.post(
218
+ f"{api_url}{FILE_SEARCH_PATH}",
219
+ headers=headers,
220
+ json={"take": 1},
221
+ verify=ssl_verify,
222
+ timeout=10,
223
+ )
224
+ if search_resp.status_code in (200, 400):
225
+ return {
226
+ "status": "ok",
227
+ "file_query_endpoint": "search-files",
228
+ "elasticsearch_available": True,
229
+ }
230
+ if search_resp.status_code in (401, 403):
231
+ return {
232
+ "status": "unauthorized",
233
+ "file_query_endpoint": "search-files",
234
+ "elasticsearch_available": True,
235
+ }
236
+ if search_resp.status_code not in (404, 501):
237
+ return {
238
+ "status": "error" if search_resp.status_code >= 500 else "not_found",
239
+ "file_query_endpoint": None,
240
+ "elasticsearch_available": True,
241
+ }
242
+ except requests.RequestException:
243
+ return {
244
+ "status": "unreachable",
245
+ "file_query_endpoint": None,
246
+ "elasticsearch_available": None,
247
+ }
248
+
249
+ try:
250
+ fallback_resp = requests.post(
251
+ f"{api_url}{FILE_QUERY_LINQ_PATH}",
252
+ headers=headers,
253
+ json={"take": 1},
254
+ verify=ssl_verify,
255
+ timeout=10,
256
+ )
257
+ if fallback_resp.status_code in (200, 400):
258
+ return {
259
+ "status": "fallback",
260
+ "file_query_endpoint": "query-files-linq",
261
+ "elasticsearch_available": False,
262
+ }
263
+ if fallback_resp.status_code in (401, 403):
264
+ return {
265
+ "status": "unauthorized",
266
+ "file_query_endpoint": "query-files-linq",
267
+ "elasticsearch_available": False,
268
+ }
269
+ if fallback_resp.status_code in (404, 501):
270
+ return {
271
+ "status": "not_found",
272
+ "file_query_endpoint": None,
273
+ "elasticsearch_available": False,
274
+ }
275
+ return {
276
+ "status": "error",
277
+ "file_query_endpoint": None,
278
+ "elasticsearch_available": False,
279
+ }
280
+ except requests.RequestException:
281
+ return {
282
+ "status": "unreachable",
283
+ "file_query_endpoint": None,
284
+ "elasticsearch_available": False,
285
+ }
286
+
287
+
251
288
  def check_service_status(api_url: str, api_key: str) -> Dict[str, Any]:
252
289
  """Probe key SystemLink services and report their status.
253
290
 
@@ -263,8 +300,10 @@ def check_service_status(api_url: str, api_key: str) -> Dict[str, Any]:
263
300
  - auth_valid: bool | None - whether the API key is authorized (None if unreachable)
264
301
  - services: dict mapping service name to status string
265
302
  ("ok", "unauthorized", "not_found", "error", "unreachable")
266
- - platform: detected platform string (PLATFORM_SLE, PLATFORM_SLS,
267
- PLATFORM_UNREACHABLE)
303
+ - file_query_endpoint: selected file query endpoint, if available
304
+ - elasticsearch_available: bool | None - whether search-files is available
305
+ - platform: detected platform string (PLATFORM_SLE, PLATFORM_SLS,
306
+ PLATFORM_UNREACHABLE, PLATFORM_UNKNOWN)
268
307
  """
269
308
  headers = {
270
309
  "x-ni-api-key": api_key,
@@ -321,6 +360,8 @@ def check_service_status(api_url: str, api_key: str) -> Dict[str, Any]:
321
360
  "server_reachable": False,
322
361
  "auth_valid": None,
323
362
  "services": services,
363
+ "file_query_endpoint": None,
364
+ "elasticsearch_available": None,
324
365
  "platform": PLATFORM_UNREACHABLE,
325
366
  }
326
367
 
@@ -337,13 +378,17 @@ def check_service_status(api_url: str, api_key: str) -> Dict[str, Any]:
337
378
  elif workorder_status == "not_found":
338
379
  platform = PLATFORM_SLS
339
380
  else:
340
- # Fall back to URL pattern matching
341
- platform = _detect_platform_from_url(api_url)
381
+ platform = PLATFORM_UNKNOWN
382
+
383
+ file_capability = get_file_query_capability(api_url, api_key)
384
+ services["File"] = file_capability["status"]
342
385
 
343
386
  return {
344
387
  "server_reachable": True,
345
388
  "auth_valid": auth_valid,
346
389
  "services": services,
390
+ "file_query_endpoint": file_capability["file_query_endpoint"],
391
+ "elasticsearch_available": file_capability["elasticsearch_available"],
347
392
  "platform": platform,
348
393
  }
349
394
 
@@ -392,6 +437,8 @@ def get_platform_info(skip_health: bool = False) -> Dict[str, Any]:
392
437
  auth_valid: Optional[bool] = None
393
438
  services: Optional[Dict[str, str]] = None
394
439
  platform = stored_platform
440
+ file_query_endpoint: Optional[str] = None
441
+ elasticsearch_available: Optional[bool] = None
395
442
 
396
443
  if not skip_health and logged_in and isinstance(api_url, str) and api_url != "Not configured":
397
444
  status = check_service_status(api_url, api_key)
@@ -399,6 +446,8 @@ def get_platform_info(skip_health: bool = False) -> Dict[str, Any]:
399
446
  auth_valid = status["auth_valid"]
400
447
  services = status["services"]
401
448
  platform = status["platform"]
449
+ file_query_endpoint = status.get("file_query_endpoint")
450
+ elasticsearch_available = status.get("elasticsearch_available")
402
451
 
403
452
  info: Dict[str, Any] = {
404
453
  "api_url": api_url,
@@ -410,6 +459,11 @@ def get_platform_info(skip_health: bool = False) -> Dict[str, Any]:
410
459
  "auth_valid": auth_valid,
411
460
  }
412
461
 
462
+ if file_query_endpoint is not None:
463
+ info["file_query_endpoint"] = file_query_endpoint
464
+ if elasticsearch_available is not None:
465
+ info["elasticsearch_available"] = elasticsearch_available
466
+
413
467
  if services is not None:
414
468
  info["services"] = services
415
469
 
@@ -423,7 +423,7 @@ def _query_all_users(
423
423
  - Uses 'status = "active"' for filtering disabled users
424
424
 
425
425
  TODO: Follow this pattern for other API clients that support continuation tokens
426
- Reference: https://dev-api.lifecyclesolutions.ni.com/niuser/swagger/v1/niuser.yaml
426
+ Reference: https://demo-api.lifecyclesolutions.ni.com/niuser/swagger/v1/niuser.yaml
427
427
 
428
428
  Args:
429
429
  filter_str: Filter expression for users
File without changes