systemlink-cli 1.13.2__tar.gz → 1.13.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 (90) hide show
  1. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/PKG-INFO +1 -1
  2. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/dff-editor/editor.js +96 -21
  3. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/pyproject.toml +1 -1
  4. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/_version.py +1 -1
  5. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/config_click.py +113 -62
  6. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/notebook_click.py +60 -5
  7. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/web_editor.py +123 -16
  8. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/LICENSE +0 -0
  9. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/dff-editor/index.html +0 -0
  10. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/__init__.py +0 -0
  11. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/__main__.py +0 -0
  12. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/asset_click.py +0 -0
  13. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/cli_formatters.py +0 -0
  14. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/cli_utils.py +0 -0
  15. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/comment_click.py +0 -0
  16. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/completion_click.py +0 -0
  17. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/config.py +0 -0
  18. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/dataframe_click.py +0 -0
  19. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/dff_click.py +0 -0
  20. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/dff_decorators.py +0 -0
  21. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/example_click.py +0 -0
  22. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/example_loader.py +0 -0
  23. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/example_provisioner.py +0 -0
  24. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/examples/README.md +0 -0
  25. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/examples/_schema/schema-v1.0.json +0 -0
  26. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/examples/demo-complete-workflow/README.md +0 -0
  27. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/examples/demo-complete-workflow/config.yaml +0 -0
  28. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/examples/demo-test-plans/README.md +0 -0
  29. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/examples/demo-test-plans/config.yaml +0 -0
  30. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/examples/exercise-5-1-parametric-insights/README.md +0 -0
  31. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/examples/exercise-5-1-parametric-insights/config.yaml +0 -0
  32. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/examples/exercise-7-1-test-plans/README.md +0 -0
  33. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/examples/exercise-7-1-test-plans/config.yaml +0 -0
  34. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/examples/spec-compliance-notebooks/README.md +0 -0
  35. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/examples/spec-compliance-notebooks/config.yaml +0 -0
  36. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +0 -0
  37. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +0 -0
  38. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +0 -0
  39. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
  40. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/feed_click.py +0 -0
  41. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/file_click.py +0 -0
  42. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/function_click.py +0 -0
  43. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/function_templates.py +0 -0
  44. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/main.py +0 -0
  45. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/mcp_click.py +0 -0
  46. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/mcp_reachability.py +0 -0
  47. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/mcp_server.py +0 -0
  48. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/platform.py +0 -0
  49. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/policy_click.py +0 -0
  50. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/policy_utils.py +0 -0
  51. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/profiles.py +0 -0
  52. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/response_handlers.py +0 -0
  53. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/rich_output.py +0 -0
  54. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/routine_click.py +0 -0
  55. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/skill_click.py +0 -0
  56. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/skills/nipkg-file-package/SKILL.md +0 -0
  57. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/skills/slcli/SKILL.md +0 -0
  58. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/skills/slcli/references/analysis-recipes.md +0 -0
  59. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/skills/slcli/references/commands.md +0 -0
  60. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/skills/slcli/references/datasheet-workflow.md +0 -0
  61. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/skills/slcli/references/filtering.md +0 -0
  62. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/skills/slcli/references/troubleshooting.md +0 -0
  63. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/skills/systemlink-job-debugging/SKILL.md +0 -0
  64. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/skills/systemlink-notebook/SKILL.md +0 -0
  65. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/skills/systemlink-notebook/references/interfaces.md +0 -0
  66. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/skills/systemlink-notebook/references/notebook-patterns.md +0 -0
  67. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/skills/systemlink-python-test/SKILL.md +0 -0
  68. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/skills/systemlink-webapp/SKILL.md +0 -0
  69. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/skills/systemlink-webapp/references/deployment.md +0 -0
  70. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/skills/systemlink-webapp/references/layout-patterns.md +0 -0
  71. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/skills/systemlink-webapp/references/nimble-angular.md +0 -0
  72. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/skills/systemlink-webapp/references/systemlink-services.md +0 -0
  73. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/spec_click.py +0 -0
  74. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/ssl_trust.py +0 -0
  75. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/state_click.py +0 -0
  76. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/system_click.py +0 -0
  77. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/system_query_utils.py +0 -0
  78. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/table_utils.py +0 -0
  79. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/tag_click.py +0 -0
  80. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/templates_click.py +0 -0
  81. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/testmonitor_click.py +0 -0
  82. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/universal_handlers.py +0 -0
  83. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/user_click.py +0 -0
  84. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/utils.py +0 -0
  85. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/webapp_click.py +0 -0
  86. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/workflow_preview.py +0 -0
  87. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/workflows_click.py +0 -0
  88. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/workitem_click.py +0 -0
  89. {systemlink_cli-1.13.2 → systemlink_cli-1.13.4}/slcli/workspace_click.py +0 -0
  90. {systemlink_cli-1.13.2 → systemlink_cli-1.13.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.13.2
3
+ Version: 1.13.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
@@ -651,9 +651,7 @@ function downloadConfiguration() {
651
651
  const a = document.createElement('a');
652
652
  a.href = url;
653
653
  a.download = 'dff-configuration.json';
654
- document.body.appendChild(a);
655
654
  a.click();
656
- document.body.removeChild(a);
657
655
  URL.revokeObjectURL(url);
658
656
  showStatus('Configuration downloaded', 'success');
659
657
  } catch (e) {
@@ -682,53 +680,130 @@ function refreshTree() {
682
680
 
683
681
  const config = currentConfig;
684
682
  if (!config) return;
685
-
686
- let html = '<div class="tree-node" onclick="selectTreeNode(\'root\')"><span class="tree-icon">📄</span><span>Root Configuration</span></div>';
687
-
688
- // Configurations
683
+
684
+ treeView.replaceChildren();
685
+ treeView.appendChild(createTreeNode({
686
+ nodeId: 'root',
687
+ icon: '📄',
688
+ label: 'Root Configuration'
689
+ }));
690
+
689
691
  if (config.configurations && config.configurations.length > 0) {
690
692
  config.configurations.forEach((conf, i) => {
691
693
  const confLabel = conf.displayText || conf.name || conf.key || ('Configuration ' + (i + 1));
692
- html += `<div class="tree-node indent-1" onclick="selectTreeNode('config-${i}')"><span class="tree-icon">⚙️</span><span>${confLabel}</span><button class="edit-btn" aria-label="Edit configuration: ${confLabel}" title="Edit configuration" onclick="event.stopPropagation(); showEditDialog('configuration', ${i})">✎</button></div>`;
693
- // Show views under each configuration
694
+ treeView.appendChild(createTreeNode({
695
+ nodeId: `config-${i}`,
696
+ icon: '⚙️',
697
+ label: confLabel,
698
+ indent: 1,
699
+ editLabel: `Edit configuration: ${confLabel}`,
700
+ editTitle: 'Edit configuration',
701
+ onEdit: () => showEditDialog('configuration', i)
702
+ }));
703
+
694
704
  if (conf.views && conf.views.length > 0) {
695
705
  conf.views.forEach((view, vi) => {
696
- html += `<div class="tree-node indent-2" onclick="selectTreeNode('config-${i}-view-${vi}')"><span class="tree-icon">👁️</span><span>${view.displayText || view.key}</span><button class="edit-btn" aria-label="Edit view: ${view.displayText || view.key}" title="Edit view" onclick="event.stopPropagation(); showEditDialog('view', ${i}, ${vi})">✎</button></div>`;
706
+ const viewLabel = view.displayText || view.key;
707
+ treeView.appendChild(createTreeNode({
708
+ nodeId: `config-${i}-view-${vi}`,
709
+ icon: '👁️',
710
+ label: viewLabel,
711
+ indent: 2,
712
+ editLabel: `Edit view: ${viewLabel}`,
713
+ editTitle: 'Edit view',
714
+ onEdit: () => showEditDialog('view', i, vi)
715
+ }));
697
716
  });
698
717
  }
699
718
  });
700
719
  }
701
-
702
- // Groups
720
+
703
721
  if (config.groups && config.groups.length > 0) {
704
- html += '<div class="tree-node indent-1"><span class="tree-icon">📁</span><span>Groups (' + config.groups.length + ')</span></div>';
722
+ treeView.appendChild(createTreeSummaryNode('📁', `Groups (${config.groups.length})`, 1));
705
723
  config.groups.forEach((group, i) => {
706
724
  const groupLabel = group.displayText || group.key;
707
- html += `<div class="tree-node indent-2" onclick="selectTreeNode('group-${i}')"><span class="tree-icon">📦</span><span>${groupLabel}</span><button class="edit-btn" aria-label="Edit group: ${groupLabel}" title="Edit group" onclick="event.stopPropagation(); showEditDialog('group', ${i})">✎</button></div>`;
725
+ treeView.appendChild(createTreeNode({
726
+ nodeId: `group-${i}`,
727
+ icon: '📦',
728
+ label: groupLabel,
729
+ indent: 2,
730
+ editLabel: `Edit group: ${groupLabel}`,
731
+ editTitle: 'Edit group',
732
+ onEdit: () => showEditDialog('group', i)
733
+ }));
708
734
  });
709
735
  }
710
-
711
- // Fields
736
+
712
737
  if (config.fields && config.fields.length > 0) {
713
- html += '<div class="tree-node indent-1"><span class="tree-icon">📁</span><span>Fields (' + config.fields.length + ')</span></div>';
738
+ treeView.appendChild(createTreeSummaryNode('📁', `Fields (${config.fields.length})`, 1));
714
739
  config.fields.forEach((field, i) => {
715
740
  const icon = field.required ? '🏷️' : '🔖';
716
741
  const fieldLabel = field.displayText || field.key;
717
- html += `<div class="tree-node indent-2" onclick="selectTreeNode('field-${i}')"><span class="tree-icon">${icon}</span><span>${fieldLabel}</span><button class="edit-btn" aria-label="Edit field: ${fieldLabel}" title="Edit field" onclick="event.stopPropagation(); showEditDialog('field', ${i})">✎</button></div>`;
742
+ treeView.appendChild(createTreeNode({
743
+ nodeId: `field-${i}`,
744
+ icon,
745
+ label: fieldLabel,
746
+ indent: 2,
747
+ editLabel: `Edit field: ${fieldLabel}`,
748
+ editTitle: 'Edit field',
749
+ onEdit: () => showEditDialog('field', i)
750
+ }));
718
751
  });
719
752
  }
720
-
721
- treeView.innerHTML = html;
722
753
  }
723
754
 
724
- function selectTreeNode(nodeId) {
755
+ function createTreeSummaryNode(icon, label, indent = 0) {
756
+ const node = document.createElement('div');
757
+ node.className = 'tree-node';
758
+ if (indent > 0) {
759
+ node.classList.add(`indent-${indent}`);
760
+ }
761
+
762
+ const iconElement = document.createElement('span');
763
+ iconElement.className = 'tree-icon';
764
+ iconElement.textContent = icon;
765
+ node.appendChild(iconElement);
766
+
767
+ const labelElement = document.createElement('span');
768
+ labelElement.textContent = label;
769
+ node.appendChild(labelElement);
770
+
771
+ return node;
772
+ }
773
+
774
+ function createTreeNode({ nodeId, icon, label, indent = 0, editLabel = '', editTitle = '', onEdit = null }) {
775
+ const node = createTreeSummaryNode(icon, label, indent);
776
+ node.dataset.nodeId = nodeId;
777
+ node.addEventListener('click', () => selectTreeNode(nodeId, node));
778
+
779
+ if (onEdit) {
780
+ const editButton = document.createElement('button');
781
+ editButton.className = 'edit-btn';
782
+ editButton.type = 'button';
783
+ editButton.textContent = '✎';
784
+ editButton.setAttribute('aria-label', editLabel);
785
+ editButton.title = editTitle;
786
+ editButton.addEventListener('click', (event) => {
787
+ event.stopPropagation();
788
+ onEdit();
789
+ });
790
+ node.appendChild(editButton);
791
+ }
792
+
793
+ return node;
794
+ }
795
+
796
+ function selectTreeNode(nodeId, nodeElement = null) {
725
797
  selectedTreeNode = nodeId;
726
798
 
727
799
  // Update visual selection
728
800
  document.querySelectorAll('.tree-node').forEach(node => {
729
801
  node.classList.remove('selected');
730
802
  });
731
- event.currentTarget.classList.add('selected');
803
+ const activeNode = nodeElement || document.querySelector(`[data-node-id="${nodeId}"]`);
804
+ if (activeNode) {
805
+ activeNode.classList.add('selected');
806
+ }
732
807
 
733
808
  // Highlight corresponding JSON in editor
734
809
  selectNodeInEditor(nodeId);
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "systemlink-cli"
3
- version = "1.13.2"
3
+ version = "1.13.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.13.2"
4
+ __version__ = "1.13.4"
@@ -2,8 +2,10 @@
2
2
 
3
3
  import getpass
4
4
  import json
5
+ import re
5
6
  import sys
6
7
  from typing import Any, Optional
8
+ from urllib.parse import urlparse
7
9
 
8
10
  import click
9
11
  import questionary
@@ -18,6 +20,70 @@ from .rich_output import render_table
18
20
  from .table_utils import output_formatted_list
19
21
  from .utils import ExitCodes
20
22
 
23
+ API_KEY_LENGTH = 42
24
+ API_KEY_PATTERN = re.compile(rf"^[A-Za-z0-9_-]{{{API_KEY_LENGTH}}}$")
25
+
26
+
27
+ def _exit_with_validation_error(message: str, exit_code: int = ExitCodes.INVALID_INPUT) -> None:
28
+ """Exit the command with a consistent validation message."""
29
+ click.echo(f"✗ {message}", err=True)
30
+ sys.exit(exit_code)
31
+
32
+
33
+ def _normalize_profile_name(profile: str) -> str:
34
+ """Normalize and validate a profile name."""
35
+ normalized = profile.strip()
36
+ if not normalized:
37
+ _exit_with_validation_error("Profile name cannot be empty.")
38
+ return normalized
39
+
40
+
41
+ def _normalize_base_url(raw_url: str, label: str) -> str:
42
+ """Normalize and validate a SystemLink base URL."""
43
+ normalized = raw_url.strip()
44
+ if not normalized:
45
+ _exit_with_validation_error(f"{label} cannot be empty.")
46
+
47
+ if "://" not in normalized:
48
+ click.echo(f"⚠️ Warning: Adding HTTPS protocol to {label.lower()}.")
49
+ normalized = f"https://{normalized}"
50
+
51
+ parsed = urlparse(normalized)
52
+ if parsed.scheme not in ("http", "https"):
53
+ _exit_with_validation_error(f"{label} must use HTTP or HTTPS.")
54
+ if not parsed.hostname:
55
+ _exit_with_validation_error(f"{label} must include a valid host name.")
56
+ if parsed.path and parsed.path.strip("/"):
57
+ _exit_with_validation_error(
58
+ f"{label} must be a base URL without a path, query string, or fragment."
59
+ )
60
+ if parsed.params or parsed.query or parsed.fragment:
61
+ _exit_with_validation_error(
62
+ f"{label} must be a base URL without a path, query string, or fragment."
63
+ )
64
+
65
+ return normalized.rstrip("/")
66
+
67
+
68
+ def _normalize_api_key(api_key: str) -> str:
69
+ """Normalize and validate an API key before probing the server."""
70
+ normalized = api_key.strip()
71
+ if not normalized:
72
+ _exit_with_validation_error("API key cannot be empty.")
73
+ if any(character.isspace() for character in normalized):
74
+ _exit_with_validation_error("API key must not contain spaces or line breaks.")
75
+ if not API_KEY_PATTERN.fullmatch(normalized):
76
+ _exit_with_validation_error(
77
+ f"API key must be a {API_KEY_LENGTH}-character URL-safe token containing only "
78
+ "letters, digits, '-' and '_'."
79
+ )
80
+ return normalized
81
+
82
+
83
+ def _all_service_probes_unauthorized(services: dict[str, str]) -> bool:
84
+ """Return True only when every recorded service probe failed with authorization."""
85
+ return bool(services) and all(status == "unauthorized" for status in services.values())
86
+
21
87
 
22
88
  def _add_profile_impl(
23
89
  profile: Optional[str],
@@ -46,93 +112,78 @@ def _add_profile_impl(
46
112
  if not profile:
47
113
  profile = click.prompt("Profile name", default="default")
48
114
  assert isinstance(profile, str)
115
+ profile = _normalize_profile_name(profile)
49
116
 
50
117
  # Get URL - either from flag or prompt
51
118
  if not url:
52
119
  click.echo("Example: https://api.my-systemlink.com")
53
120
  url = click.prompt("Enter your SystemLink API URL")
54
- # Ensure url is a string now
55
121
  assert isinstance(url, str)
56
- if not url.strip():
57
- click.echo("SystemLink URL cannot be empty.")
58
- raise click.ClickException("SystemLink URL cannot be empty.")
59
-
60
- # Ensure URL uses HTTPS
61
- url = url.strip()
62
- if url.startswith("http://"):
63
- click.echo("⚠️ Warning: Converting HTTP to HTTPS for security.")
64
- url = url.replace("http://", "https://", 1)
65
- elif not url.startswith("https://"):
66
- click.echo("⚠️ Warning: Adding HTTPS protocol to URL.")
67
- url = f"https://{url}"
68
- url = url.rstrip("/")
122
+ url = _normalize_base_url(url, "SystemLink API URL")
69
123
 
70
124
  # Get API key - either from flag or prompt
71
125
  if not api_key:
72
126
  api_key = getpass.getpass("Enter your SystemLink API key: ")
73
- # Ensure api_key is a string now
74
127
  assert isinstance(api_key, str)
75
- if not api_key.strip():
76
- click.echo("API key cannot be empty.")
77
- raise click.ClickException("API key cannot be empty.")
128
+ api_key = _normalize_api_key(api_key)
78
129
 
79
130
  # Normalize and validate web_url (prompt if not provided)
80
131
  if not web_url:
81
132
  click.echo("Example: https://my-systemlink.com")
82
133
  web_url = click.prompt("Enter your SystemLink Web UI URL")
83
134
  assert isinstance(web_url, str)
84
- web_url = web_url.strip()
85
- if web_url.startswith("http://"):
86
- click.echo("⚠️ Warning: Converting HTTP to HTTPS for security.")
87
- web_url = web_url.replace("http://", "https://", 1)
88
- elif not web_url.startswith("https://"):
89
- click.echo("⚠️ Warning: Adding HTTPS protocol to web URL.")
90
- web_url = f"https://{web_url}"
91
- web_url = web_url.rstrip("/")
135
+ web_url = _normalize_base_url(web_url, "SystemLink Web UI URL")
92
136
 
93
137
  # Detect platform type and check service status
94
138
  click.echo("Checking server connectivity and services...")
95
- status = check_service_status(url, api_key.strip())
139
+ status = check_service_status(url, api_key)
96
140
  platform = status["platform"]
141
+ services = status.get("services", {})
97
142
 
98
143
  if not status["server_reachable"]:
99
- click.echo(" ⚠️ Could not connect to server", err=True)
100
- click.echo(" Verify the URL is correct and the server is reachable.", err=True)
101
- click.echo(
102
- " Profile will be saved — run login again when the server is available.",
103
- err=True,
144
+ _exit_with_validation_error(
145
+ "Could not connect to the SystemLink server. Verify the URL and network access. "
146
+ "Profile was not saved.",
147
+ ExitCodes.NETWORK_ERROR,
148
+ )
149
+
150
+ if status["auth_valid"] is False and _all_service_probes_unauthorized(services):
151
+ _exit_with_validation_error(
152
+ "API key validation failed. The server responded, but the key was not authorized. "
153
+ "Profile was not saved.",
154
+ ExitCodes.PERMISSION_DENIED,
155
+ )
156
+
157
+ if status["auth_valid"] is not True:
158
+ _exit_with_validation_error(
159
+ "Connected to the server, but profile verification was inconclusive. Check the "
160
+ "API URL, API key, and service availability. Profile was not saved.",
161
+ ExitCodes.GENERAL_ERROR,
104
162
  )
163
+
164
+ click.echo(" Connection: ✓ Verified")
165
+ if platform == PLATFORM_SLE:
166
+ click.echo(" Platform: SystemLink Enterprise (Cloud)")
167
+ elif platform == PLATFORM_SLS:
168
+ click.echo(" Platform: SystemLink Server (On-Premises)")
105
169
  else:
106
- if platform == PLATFORM_SLE:
107
- click.echo(" Platform: SystemLink Enterprise (Cloud)")
108
- elif platform == PLATFORM_SLS:
109
- click.echo(" Platform: SystemLink Server (On-Premises)")
110
- else:
111
- click.echo(" Platform: Unknown (will attempt all features)")
112
-
113
- # Report authorization status
114
- if status["auth_valid"] is False:
115
- click.echo(" ⚠️ API key: Unauthorized — check that the key is valid", err=True)
116
- elif status["auth_valid"] is True:
117
- click.echo(" API key: ✓ Authorized")
118
-
119
- if status.get("file_query_endpoint") == "query-files":
120
- click.echo(" File query: query-files")
121
- elif status.get("elasticsearch_available") is False:
122
- click.echo(" File query: query-files-linq (Elasticsearch unavailable)")
123
- click.echo(
124
- " 'slcli file list' will fall back automatically; 'slcli file query' requires search-files."
125
- )
170
+ click.echo(" Platform: Unknown (will attempt all features)")
126
171
 
127
- # Report individual service status
128
- services = status.get("services", {})
129
- problem_services = [
130
- name for name, svc_status in services.items() if svc_status == "unauthorized"
131
- ]
132
- if problem_services and status["auth_valid"] is not False:
133
- # Only show per-service issues if overall auth isn't completely invalid
134
- for svc_name in problem_services:
135
- click.echo(f" ⚠️ {svc_name}: unauthorized", err=True)
172
+ click.echo(" API key: ✓ Authorized")
173
+
174
+ if status.get("file_query_endpoint") == "query-files":
175
+ click.echo(" File query: query-files")
176
+ elif status.get("elasticsearch_available") is False:
177
+ click.echo(" File query: query-files-linq (Elasticsearch unavailable)")
178
+ click.echo(
179
+ " 'slcli file list' will fall back automatically; 'slcli file query' requires search-files."
180
+ )
181
+
182
+ problem_services = [
183
+ name for name, svc_status in services.items() if svc_status == "unauthorized"
184
+ ]
185
+ for svc_name in problem_services:
186
+ click.echo(f" ⚠️ {svc_name}: unauthorized", err=True)
136
187
 
137
188
  # Get default workspace (optional)
138
189
  if workspace is None:
@@ -145,7 +196,7 @@ def _add_profile_impl(
145
196
  new_profile = Profile(
146
197
  name=profile,
147
198
  server=url,
148
- api_key=api_key.strip(),
199
+ api_key=api_key,
149
200
  web_url=web_url,
150
201
  platform=platform,
151
202
  workspace=workspace,
@@ -6,6 +6,7 @@ All commands use Click for robust CLI interfaces and error handling.
6
6
 
7
7
  import datetime
8
8
  import json
9
+ import re
9
10
  import sys
10
11
  import time
11
12
  import urllib.parse
@@ -50,6 +51,56 @@ PREDEFINED_NOTEBOOK_INTERFACES = [
50
51
  "Work Item Scheduler",
51
52
  ]
52
53
 
54
+ WINDOWS_RESERVED_FILENAMES = {
55
+ "CON",
56
+ "PRN",
57
+ "AUX",
58
+ "NUL",
59
+ "COM1",
60
+ "COM2",
61
+ "COM3",
62
+ "COM4",
63
+ "COM5",
64
+ "COM6",
65
+ "COM7",
66
+ "COM8",
67
+ "COM9",
68
+ "LPT1",
69
+ "LPT2",
70
+ "LPT3",
71
+ "LPT4",
72
+ "LPT5",
73
+ "LPT6",
74
+ "LPT7",
75
+ "LPT8",
76
+ "LPT9",
77
+ }
78
+
79
+
80
+ def _get_safe_notebook_download_name(notebook_name: str, suffix: str) -> str:
81
+ """Build a local filename from remote notebook metadata without honoring path segments."""
82
+ name_segment = notebook_name.replace("\\", "/").split("/")[-1].strip()
83
+ if name_segment in {"", ".", ".."}:
84
+ name_segment = "notebook"
85
+
86
+ stem = name_segment[:-6] if name_segment.lower().endswith(".ipynb") else name_segment
87
+ safe_stem = re.sub(r"[^A-Za-z0-9._ -]+", "_", stem).strip(" ._") or "notebook"
88
+ if safe_stem.upper() in WINDOWS_RESERVED_FILENAMES:
89
+ safe_stem = f"{safe_stem}_file"
90
+ normalized_suffix = suffix if suffix.startswith(".") else f".{suffix}"
91
+
92
+ return f"{safe_stem}{normalized_suffix}"
93
+
94
+
95
+ def _get_safe_notebook_download_path(notebook_name: str, suffix: str) -> Path:
96
+ """Build a safe local output path for remote-derived notebook filenames."""
97
+ safe_filename = _get_safe_notebook_download_name(notebook_name, suffix)
98
+ working_directory = Path.cwd().resolve()
99
+ output_path = (working_directory / safe_filename).resolve()
100
+ if output_path.parent != working_directory:
101
+ raise ValueError("Notebook download path must stay within the current working directory")
102
+ return output_path
103
+
53
104
 
54
105
  def _normalize_sls_notebook(notebook: Dict[str, Any]) -> None:
55
106
  """Normalize SLS notebook response to include id/name fields.
@@ -529,9 +580,10 @@ def _download_notebook_content_and_metadata(
529
580
  if download_type in ("content", "both"):
530
581
  try:
531
582
  content = _get_notebook_content_http(notebook_id)
532
- output_path = output or (
533
- notebook_name if notebook_name.endswith(".ipynb") else f"{notebook_name}.ipynb"
534
- )
583
+ if output:
584
+ output_path = Path(output)
585
+ else:
586
+ output_path = _get_safe_notebook_download_path(notebook_name, ".ipynb")
535
587
  with open(output_path, "wb") as f:
536
588
  f.write(content)
537
589
  click.echo(f"Notebook content downloaded to {output_path}")
@@ -543,14 +595,17 @@ def _download_notebook_content_and_metadata(
543
595
  if download_type in ("metadata", "both"):
544
596
  try:
545
597
  meta = _get_notebook_http(notebook_id)
546
- meta_path = (output or notebook_name.replace(".ipynb", "")) + ".json"
598
+ if output:
599
+ meta_path = Path(f"{output}.json")
600
+ else:
601
+ meta_path = _get_safe_notebook_download_path(notebook_name, ".json")
547
602
 
548
603
  def _json_default(obj: Any) -> str:
549
604
  if isinstance(obj, (datetime.datetime, datetime.date)):
550
605
  return obj.isoformat()
551
606
  return str(obj)
552
607
 
553
- save_json_file(meta, meta_path, _json_default)
608
+ save_json_file(meta, str(meta_path), _json_default)
554
609
  click.echo(f"Notebook metadata downloaded to {meta_path}")
555
610
  except Exception as exc:
556
611
  click.echo(f"Failed to download notebook metadata: {exc}")
@@ -43,10 +43,9 @@ def _build_proxy_url(
43
43
  origin_scheme: str,
44
44
  origin_netloc: str,
45
45
  target_path: str,
46
- query: str = "",
47
46
  ) -> str:
48
47
  """Build a proxy URL from a validated origin and allowlisted path."""
49
- return urllib.parse.urlunsplit((origin_scheme, origin_netloc, target_path, query, ""))
48
+ return urllib.parse.urlunsplit((origin_scheme, origin_netloc, target_path, "", ""))
50
49
 
51
50
 
52
51
  def _validated_proxy_path(request_path: str) -> str:
@@ -59,6 +58,118 @@ def _validated_proxy_path(request_path: str) -> str:
59
58
  return decoded_path
60
59
 
61
60
 
61
+ def _validated_proxy_query_params(query: str) -> dict[str, list[str]]:
62
+ """Return parsed proxy query parameters."""
63
+ if not query:
64
+ return {}
65
+
66
+ try:
67
+ return urllib.parse.parse_qs(
68
+ query,
69
+ keep_blank_values=True,
70
+ strict_parsing=True,
71
+ separator="&",
72
+ )
73
+ except ValueError as exc:
74
+ raise ValueError("Editor proxy received an invalid query string") from exc
75
+
76
+
77
+ def _validated_single_query_value(
78
+ query_params: dict[str, list[str]],
79
+ name: str,
80
+ ) -> str:
81
+ """Return a single query value and reject repeated parameters."""
82
+ values = query_params.get(name)
83
+ if not values:
84
+ raise ValueError(f"Editor proxy requires query parameter: {name}")
85
+ if len(values) != 1:
86
+ raise ValueError(f"Editor proxy rejects repeated query parameter: {name}")
87
+ return values[0]
88
+
89
+
90
+ def _validated_integer_query_value(
91
+ query_params: dict[str, list[str]],
92
+ name: str,
93
+ *,
94
+ minimum: int,
95
+ maximum: Optional[int] = None,
96
+ ) -> str:
97
+ """Return a validated integer query value preserved as a string."""
98
+ raw_value = _validated_single_query_value(query_params, name)
99
+
100
+ try:
101
+ parsed_value = int(raw_value)
102
+ except ValueError as exc:
103
+ raise ValueError(f"Editor proxy requires an integer query parameter: {name}") from exc
104
+
105
+ if parsed_value < minimum or (maximum is not None and parsed_value > maximum):
106
+ raise ValueError(f"Editor proxy rejected out-of-range query parameter: {name}")
107
+
108
+ return str(parsed_value)
109
+
110
+
111
+ def _validated_identifier_query_value(query_params: dict[str, list[str]], name: str) -> str:
112
+ """Return a single identifier-like query value safe for proxy forwarding."""
113
+ value = _validated_single_query_value(query_params, name)
114
+ if not value.strip():
115
+ raise ValueError(f"Editor proxy requires a non-empty query parameter: {name}")
116
+ if len(value) > 256:
117
+ raise ValueError(f"Editor proxy rejected oversized query parameter: {name}")
118
+ if any(ord(character) < 32 for character in value):
119
+ raise ValueError(f"Editor proxy rejected invalid query parameter: {name}")
120
+ if any(character in value for character in "/\\?#"):
121
+ raise ValueError(f"Editor proxy rejected invalid query parameter: {name}")
122
+ return value
123
+
124
+
125
+ def _resolve_proxy_target(
126
+ method: str,
127
+ request_path: str,
128
+ query: str,
129
+ ) -> Optional[tuple[str, dict[str, str]]]:
130
+ """Resolve a frontend route to a fixed upstream path and validated params."""
131
+ query_params = _validated_proxy_query_params(query)
132
+
133
+ if method == "POST":
134
+ post_route_map = {
135
+ "/api/dff/configurations": "/nidynamicformfields/v1/configurations",
136
+ "/api/dff/update-configurations": "/nidynamicformfields/v1/update-configurations",
137
+ "/nidynamicformfields/v1/update-configurations": "/nidynamicformfields/v1/update-configurations",
138
+ }
139
+ target_path = post_route_map.get(request_path)
140
+ if target_path is None:
141
+ return None
142
+ if query_params:
143
+ raise ValueError("Editor proxy does not forward query parameters for this route")
144
+ return target_path, {}
145
+
146
+ if method == "GET" and request_path == "/niuser/v1/workspaces":
147
+ unexpected_params = set(query_params) - {"take", "skip"}
148
+ if unexpected_params:
149
+ raise ValueError("Editor proxy rejected unsupported workspace query parameters")
150
+
151
+ safe_params: dict[str, str] = {}
152
+ if "take" in query_params:
153
+ safe_params["take"] = _validated_integer_query_value(
154
+ query_params, "take", minimum=1, maximum=1000
155
+ )
156
+ if "skip" in query_params:
157
+ safe_params["skip"] = _validated_integer_query_value(query_params, "skip", minimum=0)
158
+ return "/niuser/v1/workspaces", safe_params
159
+
160
+ if method == "GET" and request_path == "/nidynamicformfields/v1/resolved-configuration":
161
+ unexpected_params = set(query_params) - {"configurationId"}
162
+ if unexpected_params:
163
+ raise ValueError(
164
+ "Editor proxy rejected unsupported resolved-configuration query parameters"
165
+ )
166
+ return "/nidynamicformfields/v1/resolved-configuration", {
167
+ "configurationId": _validated_identifier_query_value(query_params, "configurationId")
168
+ }
169
+
170
+ return None
171
+
172
+
62
173
  class DFFWebEditor:
63
174
  """Web-based editor for custom fields configurations."""
64
175
 
@@ -224,21 +335,17 @@ class DFFWebEditor:
224
335
  self.send_error(404, "Config file not found")
225
336
  return True
226
337
 
227
- # Handle API proxying
228
- path_map = {
229
- "/api/dff/configurations": "/nidynamicformfields/v1/configurations",
230
- "/api/dff/update-configurations": "/nidynamicformfields/v1/update-configurations",
231
- }
232
-
233
- if request_path in path_map:
234
- target_path = path_map[request_path]
235
- elif request_path.startswith("/nidynamicformfields/v1/"):
236
- target_path = request_path
237
- elif request_path.startswith("/niuser/v1/workspaces"):
238
- target_path = request_path
239
- else:
338
+ try:
339
+ resolved_target = _resolve_proxy_target(method, request_path, parsed.query)
340
+ except ValueError as exc:
341
+ self.send_error(400, str(exc))
342
+ return True
343
+
344
+ if resolved_target is None:
240
345
  return False
241
346
 
347
+ target_path, target_params = resolved_target
348
+
242
349
  # Require per-session secret on all proxied routes
243
350
  req_secret = self.headers.get("X-Editor-Secret")
244
351
  if not secret or req_secret != secret:
@@ -249,7 +356,6 @@ class DFFWebEditor:
249
356
  origin_scheme=api_scheme,
250
357
  origin_netloc=api_netloc,
251
358
  target_path=target_path,
252
- query=parsed.query,
253
359
  )
254
360
 
255
361
  headers = dict(default_headers)
@@ -266,6 +372,7 @@ class DFFWebEditor:
266
372
  url=target_url,
267
373
  headers=headers,
268
374
  data=data,
375
+ params=target_params or None,
269
376
  verify=ssl_verify,
270
377
  )
271
378
  except requests.RequestException as exc: # pragma: no cover
File without changes