systemlink-cli 1.5.0__tar.gz → 1.5.2__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 (75) hide show
  1. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/PKG-INFO +1 -1
  2. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/pyproject.toml +1 -1
  3. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/_version.py +1 -1
  4. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/skills/slcli/SKILL.md +3 -2
  5. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/skills/systemlink-webapp/SKILL.md +33 -0
  6. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/skills/systemlink-webapp/references/layout-patterns.md +28 -1
  7. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/webapp_click.py +120 -18
  8. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/LICENSE +0 -0
  9. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/dff-editor/editor.js +0 -0
  10. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/dff-editor/index.html +0 -0
  11. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/__init__.py +0 -0
  12. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/__main__.py +0 -0
  13. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/asset_click.py +0 -0
  14. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/cli_formatters.py +0 -0
  15. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/cli_utils.py +0 -0
  16. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/comment_click.py +0 -0
  17. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/completion_click.py +0 -0
  18. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/config.py +0 -0
  19. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/config_click.py +0 -0
  20. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/dff_click.py +0 -0
  21. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/dff_decorators.py +0 -0
  22. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/example_click.py +0 -0
  23. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/example_loader.py +0 -0
  24. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/example_provisioner.py +0 -0
  25. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/examples/README.md +0 -0
  26. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/examples/_schema/schema-v1.0.json +0 -0
  27. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/examples/demo-complete-workflow/README.md +0 -0
  28. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/examples/demo-complete-workflow/config.yaml +0 -0
  29. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/examples/demo-test-plans/README.md +0 -0
  30. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/examples/demo-test-plans/config.yaml +0 -0
  31. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/examples/exercise-5-1-parametric-insights/README.md +0 -0
  32. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/examples/exercise-5-1-parametric-insights/config.yaml +0 -0
  33. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/examples/exercise-7-1-test-plans/README.md +0 -0
  34. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/examples/exercise-7-1-test-plans/config.yaml +0 -0
  35. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/examples/spec-compliance-notebooks/README.md +0 -0
  36. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/examples/spec-compliance-notebooks/config.yaml +0 -0
  37. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +0 -0
  38. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +0 -0
  39. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +0 -0
  40. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
  41. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/feed_click.py +0 -0
  42. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/file_click.py +0 -0
  43. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/function_click.py +0 -0
  44. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/function_templates.py +0 -0
  45. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/main.py +0 -0
  46. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/mcp_click.py +0 -0
  47. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/mcp_server.py +0 -0
  48. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/notebook_click.py +0 -0
  49. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/platform.py +0 -0
  50. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/policy_click.py +0 -0
  51. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/policy_utils.py +0 -0
  52. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/profiles.py +0 -0
  53. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/response_handlers.py +0 -0
  54. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/routine_click.py +0 -0
  55. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/skill_click.py +0 -0
  56. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/skills/slcli/references/analysis-recipes.md +0 -0
  57. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/skills/slcli/references/filtering.md +0 -0
  58. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/skills/systemlink-webapp/references/deployment.md +0 -0
  59. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/skills/systemlink-webapp/references/nimble-angular.md +0 -0
  60. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/skills/systemlink-webapp/references/systemlink-services.md +0 -0
  61. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/ssl_trust.py +0 -0
  62. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/system_click.py +0 -0
  63. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/table_utils.py +0 -0
  64. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/tag_click.py +0 -0
  65. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/templates_click.py +0 -0
  66. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/testmonitor_click.py +0 -0
  67. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/universal_handlers.py +0 -0
  68. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/user_click.py +0 -0
  69. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/utils.py +0 -0
  70. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/web_editor.py +0 -0
  71. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/workflow_preview.py +0 -0
  72. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/workflows_click.py +0 -0
  73. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/workitem_click.py +0 -0
  74. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/workspace_click.py +0 -0
  75. {systemlink_cli-1.5.0 → systemlink_cli-1.5.2}/slcli/workspace_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: systemlink-cli
3
- Version: 1.5.0
3
+ Version: 1.5.2
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.5.0"
3
+ version = "1.5.2"
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.5.0"
4
+ __version__ = "1.5.2"
@@ -682,8 +682,9 @@ conventions described by the `systemlink-webapp` skill.
682
682
 
683
683
  `webapp manifest init` writes `manifest.json` and `nipkg.config.json` using the Plugin Manager
684
684
  field names (`section`, `maintainer`, `homepage`, `xbPlugin`, `slPluginManagerTags`,
685
- `slPluginManagerMinServerVersion`). `webapp pack --config ...` consumes that metadata and writes
686
- the matching control-file fields into the generated `.nipkg`.
685
+ `slPluginManagerMinServerVersion`, `iconFile`). `webapp pack --config ...` consumes that
686
+ metadata, carries the icon into the package, and writes the matching control-file fields into the
687
+ generated `.nipkg`.
687
688
 
688
689
  ### skill — AI skill installation
689
690
 
@@ -80,6 +80,9 @@ Use Nimble layout tokens and spacing rules consistently across the shell and fea
80
80
  aligned multi-column layouts.
81
81
  - Inside accordion panels, keep a column layout with `mediumPadding` gaps and
82
82
  `standardPadding` bottom padding.
83
+ - In dense side panels with tabs, use `15px 30px 30px 15px` on the tab container,
84
+ `20px 0 0 15px` on the active tab panel, let the panel own scrolling, and avoid forcing nested
85
+ content blocks to `height: 100%`.
83
86
  - Treat `controlHeight` (32px), `controlSlimHeight` (24px), and `labelHeight` (16px) as the
84
87
  baseline sizing tokens for controls and labels.
85
88
  - Separate major sections with `largePadding` and subsections with `standardPadding`.
@@ -380,6 +383,36 @@ If you want compile-time token values in SCSS, you can also import Nimble's toke
380
383
  }
381
384
  ```
382
385
 
386
+ ### Tabs in dense side panels
387
+
388
+ When placing Nimble tabs inside a dense side panel or details pane, use the tab wrapper and panel
389
+ as the layout primitives instead of forcing inner content blocks to fill the available height.
390
+
391
+ ```scss
392
+ .details-tabs {
393
+ padding: 15px 30px 30px 15px;
394
+ }
395
+
396
+ .details-tab-panel {
397
+ padding: 20px 0 0 15px;
398
+ overflow: auto;
399
+ }
400
+
401
+ .details-tab-content {
402
+ display: flex;
403
+ flex-direction: column;
404
+ gap: var(--ni-nimble-medium-padding, 8px);
405
+ }
406
+ ```
407
+
408
+ - Use `padding: 15px 30px 30px 15px` on the tab control container.
409
+ - Use `padding: 20px 0 0 15px` on the active tab panel content region.
410
+ - Let the tab panel container handle scrolling.
411
+ - Do not force nested form blocks or content stacks to `height: 100%`; that tends to stretch the
412
+ layout and creates inconsistent vertical spacing between controls.
413
+ - Inside the active tab panel, keep stacked controls on an `8px` gap via
414
+ `var(--ni-nimble-medium-padding, 8px)` unless a tighter layout is explicitly needed.
415
+
383
416
  ### Why this pattern?
384
417
 
385
418
  1. **Themability** — All colors flow through Nimble's theme-aware tokens. If Nimble changes color ramps or adds dark mode, your app automatically inherits it.
@@ -63,9 +63,36 @@ Inside accordion item content panels:
63
63
  - Indent content by the icon width plus padding so it aligns with the header text.
64
64
  - Use `standardPadding` (16px) for bottom padding before the next section.
65
65
 
66
+ ## Tabs in side panels
67
+
68
+ When using tabs inside a dense side panel or details pane:
69
+
70
+ - Use `padding: 15px 30px 30px 15px` on the tab control container.
71
+ - Use `padding: 20px 0 0 15px` on the active tab panel content region.
72
+ - Let the tab panel container own scrolling.
73
+ - Avoid forcing nested form or content blocks to `height: 100%`; it tends to distort vertical
74
+ spacing between controls.
75
+ - Inside the active tab panel, keep stacked controls on a `mediumPadding` (8px) gap unless a
76
+ tighter layout is explicitly needed.
77
+
78
+ ```html
79
+ <div style="padding: 15px 30px 30px 15px;">
80
+ <nimble-tabs>
81
+ <nimble-tab id="details">Details</nimble-tab>
82
+ <nimble-tab id="settings">Settings</nimble-tab>
83
+ </nimble-tabs>
84
+ <div
85
+ style="padding: 20px 0 0 15px; overflow: auto; display: flex; flex-direction: column; gap: var(--ni-nimble-medium-padding);"
86
+ >
87
+ <nimble-text-field>Label 1</nimble-text-field>
88
+ <nimble-text-field>Label 2</nimble-text-field>
89
+ </div>
90
+ </div>
91
+ ```
92
+
66
93
  ## Section spacing
67
94
 
68
95
  Between major sections or groups of controls:
69
96
 
70
97
  - Use `largePadding` (24px) between distinct content areas.
71
- - Use `standardPadding` (16px) for subsections within a group.
98
+ - Use `standardPadding` (16px) for subsections within a group.
@@ -6,6 +6,7 @@ management (list, get, delete, publish, open).
6
6
 
7
7
  import io
8
8
  import re
9
+ import shutil
9
10
  import sys
10
11
  import tarfile
11
12
  import tempfile
@@ -40,6 +41,7 @@ _PACKAGE_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
40
41
  _VERSION_PATTERN = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
41
42
  _MAINTAINER_PATTERN = re.compile(r"^[^<>]+\s<[^<>@\s]+@[^<>@\s]+>$")
42
43
  _XB_PLUGIN_VALUES = ("webapp", "notebook", "dashboard", "routine", "bundle")
44
+ _ALLOWED_ICON_EXTENSIONS = frozenset({".svg", ".png", ".jpg", ".jpeg", ".webp", ".gif", ".ico"})
43
45
  _MAX_PACKAGE_LENGTH = 100
44
46
  _MAX_DISPLAY_NAME_LENGTH = 200
45
47
  _MAX_DESCRIPTION_LENGTH = 5000
@@ -179,6 +181,62 @@ def _default_angular_build_dir(directory: Path) -> str:
179
181
  return f"dist/{_default_angular_project_name(directory)}/browser"
180
182
 
181
183
 
184
+ def _resolve_local_path(path_value: str, base_dir: Optional[Path] = None) -> Path:
185
+ """Resolve a local path against a base directory or the current working directory."""
186
+ path = Path(path_value).expanduser()
187
+ if not path.is_absolute():
188
+ anchor = base_dir if base_dir is not None else Path.cwd()
189
+ path = anchor / path
190
+ return path.resolve()
191
+
192
+
193
+ def _validate_icon_file_value(icon_file: str, base_dir: Optional[Path] = None) -> Optional[str]:
194
+ """Validate the icon asset referenced by Plugin Manager metadata."""
195
+ if not icon_file:
196
+ return "iconFile is required"
197
+
198
+ resolved_icon = _resolve_local_path(icon_file, base_dir)
199
+ if not resolved_icon.exists() or not resolved_icon.is_file():
200
+ return f"iconFile does not exist or is not a file: {icon_file}"
201
+ if resolved_icon.suffix.lower() not in _ALLOWED_ICON_EXTENSIONS:
202
+ allowed_extensions = ", ".join(sorted(ext.lstrip(".") for ext in _ALLOWED_ICON_EXTENSIONS))
203
+ return f"iconFile must be one of: {allowed_extensions}"
204
+ return None
205
+
206
+
207
+ def _prepare_icon_file_for_directory(
208
+ icon_file: str, directory: Path, force: bool
209
+ ) -> tuple[Path, str]:
210
+ """Validate an icon asset and return its source path plus stored manifest filename."""
211
+ icon_error = _validate_icon_file_value(icon_file)
212
+ if icon_error:
213
+ click.echo(f"✗ {icon_error}", err=True)
214
+ sys.exit(ExitCodes.INVALID_INPUT)
215
+
216
+ source_icon = _resolve_local_path(icon_file)
217
+ target_icon = directory / source_icon.name
218
+
219
+ if target_icon.exists() and target_icon.resolve() != source_icon:
220
+ if not force:
221
+ click.echo(
222
+ f"✗ {target_icon.name} already exists in {directory}. Use --force to overwrite it.",
223
+ err=True,
224
+ )
225
+ sys.exit(ExitCodes.INVALID_INPUT)
226
+
227
+ return source_icon, target_icon.name
228
+
229
+
230
+ def _copy_icon_file_to_directory(source_icon: Path, directory: Path) -> bool:
231
+ """Copy an icon asset into the manifest directory when it is not already there."""
232
+ target_icon = directory / source_icon.name
233
+ if target_icon.resolve() == source_icon:
234
+ return False
235
+
236
+ shutil.copy2(source_icon, target_icon)
237
+ return True
238
+
239
+
182
240
  def _normalize_plugin_manager_metadata(raw_metadata: Dict[str, Any]) -> Dict[str, Any]:
183
241
  """Normalize legacy App Store keys to Plugin Manager keys."""
184
242
  metadata = dict(raw_metadata)
@@ -191,7 +249,9 @@ def _normalize_plugin_manager_metadata(raw_metadata: Dict[str, Any]) -> Dict[str
191
249
 
192
250
 
193
251
  def _validate_plugin_manager_metadata(
194
- raw_metadata: Dict[str, Any], require_build_dir: bool = False
252
+ raw_metadata: Dict[str, Any],
253
+ require_build_dir: bool = False,
254
+ base_dir: Optional[Path] = None,
195
255
  ) -> Dict[str, str]:
196
256
  """Validate and normalize Plugin Manager manifest metadata."""
197
257
  from urllib.parse import urlparse
@@ -250,6 +310,9 @@ def _validate_plugin_manager_metadata(
250
310
  errors.append("nipkgFile must end with .nipkg")
251
311
  if require_build_dir and not build_dir:
252
312
  errors.append("buildDir is required when packing from config without a folder argument")
313
+ icon_error = _validate_icon_file_value(icon_file, base_dir)
314
+ if icon_error:
315
+ errors.append(icon_error)
253
316
 
254
317
  if errors:
255
318
  click.echo("✗ Invalid plugin manager metadata:", err=True)
@@ -278,14 +341,16 @@ def _validate_plugin_manager_metadata(
278
341
  validated["buildDir"] = build_dir
279
342
  if build_command:
280
343
  validated["buildCommand"] = build_command
281
- if icon_file:
282
- validated["iconFile"] = icon_file
344
+ validated["iconFile"] = icon_file
283
345
 
284
346
  return validated
285
347
 
286
348
 
287
349
  def _pack_folder_to_nipkg(
288
- folder: Path, output: Optional[Path] = None, metadata: Optional[Dict[str, str]] = None
350
+ folder: Path,
351
+ output: Optional[Path] = None,
352
+ metadata: Optional[Dict[str, str]] = None,
353
+ icon_source: Optional[Path] = None,
289
354
  ) -> Path:
290
355
  """Pack a folder into a .nipkg (ar) file and return the output path.
291
356
 
@@ -351,6 +416,8 @@ def _pack_folder_to_nipkg(
351
416
  control_fields["XB-SlPluginManagerMinServerVersion"] = metadata[
352
417
  "slPluginManagerMinServerVersion"
353
418
  ]
419
+ if metadata.get("iconFile"):
420
+ control_fields["XB-SlPluginManagerIcon"] = Path(metadata["iconFile"]).name
354
421
  else:
355
422
  control_fields = {
356
423
  "Package": package_name,
@@ -374,10 +441,21 @@ def _pack_folder_to_nipkg(
374
441
 
375
442
  # Create data.tar.gz in-memory containing the folder contents at the root
376
443
  data_buf = io.BytesIO()
377
- with tarfile.open(fileobj=data_buf, mode="w:gz") as dtf:
378
- # tarfile.add will handle directories and files; preserve relative paths
379
- dtf.add(str(folder), arcname=".")
380
- data_bytes = data_buf.getvalue()
444
+ with tempfile.TemporaryDirectory() as temp_dir:
445
+ payload_folder = folder
446
+ if metadata is not None and icon_source is not None:
447
+ icon_name = Path(metadata["iconFile"]).name
448
+ folder_icon = folder.resolve() / icon_name
449
+ resolved_icon_source = icon_source.resolve()
450
+ if not folder_icon.exists() or folder_icon.resolve() != resolved_icon_source:
451
+ payload_folder = Path(temp_dir) / folder.name
452
+ shutil.copytree(folder, payload_folder)
453
+ shutil.copy2(resolved_icon_source, payload_folder / icon_name)
454
+
455
+ with tarfile.open(fileobj=data_buf, mode="w:gz") as dtf:
456
+ # tarfile.add will handle directories and files; preserve relative paths
457
+ dtf.add(str(payload_folder), arcname=".")
458
+ data_bytes = data_buf.getvalue()
381
459
 
382
460
  # debian-binary content
383
461
  debian_bin = b"2.0\n"
@@ -467,6 +545,7 @@ def _build_webapp_manifest_and_config(
467
545
  build_dir: str,
468
546
  build_command: str,
469
547
  icon_file: str,
548
+ icon_validation_base_dir: Path,
470
549
  ) -> tuple[Dict[str, str], Dict[str, str]]:
471
550
  """Build validated submission manifest and nipkg config payloads."""
472
551
  manifest = _validate_plugin_manager_metadata(
@@ -483,15 +562,15 @@ def _build_webapp_manifest_and_config(
483
562
  "slPluginManagerTags": tags,
484
563
  "slPluginManagerMinServerVersion": min_server_version,
485
564
  "nipkgFile": _default_nipkg_filename(package_name, version),
486
- }
565
+ "iconFile": icon_file,
566
+ },
567
+ base_dir=icon_validation_base_dir,
487
568
  )
488
569
 
489
570
  pack_config = dict(manifest)
490
571
  pack_config.pop("nipkgFile", None)
491
572
  pack_config["buildDir"] = build_dir
492
573
  pack_config["buildCommand"] = build_command
493
- if icon_file:
494
- pack_config["iconFile"] = icon_file
495
574
 
496
575
  return manifest, pack_config
497
576
 
@@ -622,7 +701,8 @@ slcli webapp manifest init . \\
622
701
  --description "A dashboard for monitoring fleet health and calibration status." \\
623
702
  --section Dashboard \\
624
703
  --maintainer "Your Name <you@example.com>" \\
625
- --license MIT
704
+ --license MIT \\
705
+ --icon-file ./icon.svg
626
706
 
627
707
  slcli webapp pack --config nipkg.config.json
628
708
  ```
@@ -730,7 +810,11 @@ def register_webapp_commands(cli: Any) -> None:
730
810
  show_default=True,
731
811
  help="Build command written to nipkg.config.json",
732
812
  )
733
- @click.option("--icon-file", default="", help="Icon path written to nipkg.config.json")
813
+ @click.option(
814
+ "--icon-file",
815
+ required=True,
816
+ help="Path to the icon asset; copied into the manifest directory as iconFile",
817
+ )
734
818
  @click.option("--force", is_flag=True, help="Overwrite existing manifest files")
735
819
  def init_manifest(
736
820
  directory: Path,
@@ -772,6 +856,10 @@ def register_webapp_commands(cli: Any) -> None:
772
856
  )
773
857
  sys.exit(ExitCodes.INVALID_INPUT)
774
858
 
859
+ source_icon, manifest_icon_file = _prepare_icon_file_for_directory(
860
+ icon_file, directory, force
861
+ )
862
+
775
863
  manifest, pack_config = _build_webapp_manifest_and_config(
776
864
  package_name=package_name,
777
865
  version=version,
@@ -786,11 +874,19 @@ def register_webapp_commands(cli: Any) -> None:
786
874
  min_server_version=min_server_version,
787
875
  build_dir=build_dir,
788
876
  build_command=build_command,
789
- icon_file=icon_file,
877
+ icon_file=manifest_icon_file,
878
+ icon_validation_base_dir=source_icon.parent,
790
879
  )
791
880
 
792
- save_json_file(manifest, str(manifest_path))
793
- save_json_file(pack_config, str(config_path))
881
+ copied_icon = _copy_icon_file_to_directory(source_icon, directory)
882
+ try:
883
+ save_json_file(manifest, str(manifest_path))
884
+ save_json_file(pack_config, str(config_path))
885
+ except Exception:
886
+ if copied_icon:
887
+ (directory / manifest_icon_file).unlink(missing_ok=True)
888
+ raise
889
+
794
890
  format_success(
795
891
  "Created Plugin Manager manifest files",
796
892
  {
@@ -841,7 +937,9 @@ def register_webapp_commands(cli: Any) -> None:
841
937
  click.echo("✗ Config file must contain a JSON object.", err=True)
842
938
  sys.exit(ExitCodes.INVALID_INPUT)
843
939
  metadata = _validate_plugin_manager_metadata(
844
- raw_data, require_build_dir=resolved_folder is None
940
+ raw_data,
941
+ require_build_dir=resolved_folder is None,
942
+ base_dir=config_path.parent,
845
943
  )
846
944
 
847
945
  if resolved_folder is None:
@@ -866,7 +964,11 @@ def register_webapp_commands(cli: Any) -> None:
866
964
  sys.exit(ExitCodes.INVALID_INPUT)
867
965
 
868
966
  out = Path(output) if output else None
869
- result = _pack_folder_to_nipkg(resolved_folder, out, metadata)
967
+ icon_source: Optional[Path] = None
968
+ if metadata is not None and config_path is not None:
969
+ icon_source = _resolve_local_path(metadata["iconFile"], config_path.parent)
970
+
971
+ result = _pack_folder_to_nipkg(resolved_folder, out, metadata, icon_source)
870
972
  format_success("Packed folder", {"Path": str(result)})
871
973
  except SystemExit:
872
974
  raise
File without changes