systemlink-cli 1.4.8__tar.gz → 1.5.0__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.4.8 → systemlink_cli-1.5.0}/PKG-INFO +1 -1
  2. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/pyproject.toml +1 -1
  3. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/_version.py +1 -1
  4. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/skills/slcli/SKILL.md +14 -7
  5. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/skills/systemlink-webapp/SKILL.md +46 -4
  6. systemlink_cli-1.5.0/slcli/skills/systemlink-webapp/references/layout-patterns.md +71 -0
  7. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/webapp_click.py +528 -120
  8. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/LICENSE +0 -0
  9. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/dff-editor/editor.js +0 -0
  10. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/dff-editor/index.html +0 -0
  11. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/__init__.py +0 -0
  12. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/__main__.py +0 -0
  13. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/asset_click.py +0 -0
  14. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/cli_formatters.py +0 -0
  15. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/cli_utils.py +0 -0
  16. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/comment_click.py +0 -0
  17. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/completion_click.py +0 -0
  18. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/config.py +0 -0
  19. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/config_click.py +0 -0
  20. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/dff_click.py +0 -0
  21. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/dff_decorators.py +0 -0
  22. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/example_click.py +0 -0
  23. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/example_loader.py +0 -0
  24. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/example_provisioner.py +0 -0
  25. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/examples/README.md +0 -0
  26. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/examples/_schema/schema-v1.0.json +0 -0
  27. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/examples/demo-complete-workflow/README.md +0 -0
  28. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/examples/demo-complete-workflow/config.yaml +0 -0
  29. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/examples/demo-test-plans/README.md +0 -0
  30. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/examples/demo-test-plans/config.yaml +0 -0
  31. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/examples/exercise-5-1-parametric-insights/README.md +0 -0
  32. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/examples/exercise-5-1-parametric-insights/config.yaml +0 -0
  33. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/examples/exercise-7-1-test-plans/README.md +0 -0
  34. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/examples/exercise-7-1-test-plans/config.yaml +0 -0
  35. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/examples/spec-compliance-notebooks/README.md +0 -0
  36. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/examples/spec-compliance-notebooks/config.yaml +0 -0
  37. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +0 -0
  38. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +0 -0
  39. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +0 -0
  40. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
  41. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/feed_click.py +0 -0
  42. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/file_click.py +0 -0
  43. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/function_click.py +0 -0
  44. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/function_templates.py +0 -0
  45. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/main.py +0 -0
  46. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/mcp_click.py +0 -0
  47. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/mcp_server.py +0 -0
  48. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/notebook_click.py +0 -0
  49. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/platform.py +0 -0
  50. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/policy_click.py +0 -0
  51. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/policy_utils.py +0 -0
  52. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/profiles.py +0 -0
  53. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/response_handlers.py +0 -0
  54. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/routine_click.py +0 -0
  55. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/skill_click.py +0 -0
  56. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/skills/slcli/references/analysis-recipes.md +0 -0
  57. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/skills/slcli/references/filtering.md +0 -0
  58. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/skills/systemlink-webapp/references/deployment.md +0 -0
  59. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/skills/systemlink-webapp/references/nimble-angular.md +0 -0
  60. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/skills/systemlink-webapp/references/systemlink-services.md +0 -0
  61. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/ssl_trust.py +0 -0
  62. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/system_click.py +0 -0
  63. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/table_utils.py +0 -0
  64. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/tag_click.py +0 -0
  65. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/templates_click.py +0 -0
  66. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/testmonitor_click.py +0 -0
  67. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/universal_handlers.py +0 -0
  68. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/user_click.py +0 -0
  69. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/utils.py +0 -0
  70. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/web_editor.py +0 -0
  71. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/workflow_preview.py +0 -0
  72. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/workflows_click.py +0 -0
  73. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/workitem_click.py +0 -0
  74. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/slcli/workspace_click.py +0 -0
  75. {systemlink_cli-1.4.8 → systemlink_cli-1.5.0}/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.8
3
+ Version: 1.5.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
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "systemlink-cli"
3
- version = "1.4.8"
3
+ version = "1.5.0"
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.8"
4
+ __version__ = "1.5.0"
@@ -665,18 +665,25 @@ slcli workitem create \
665
665
  Scaffold, package, and publish custom web applications to SystemLink.
666
666
 
667
667
  ```bash
668
- slcli webapp init [--template html|angular] [--directory DIR] # Scaffold a new project
669
- slcli webapp pack [--directory DIR] [-o OUTPUT_FILE] # Package webapp into a .zip
668
+ slcli webapp init <DIRECTORY> # Scaffold the Angular starter
669
+ slcli webapp manifest init <DIRECTORY> [OPTIONS] # Create manifest.json + nipkg.config.json
670
+ slcli webapp pack [FOLDER] [--config FILE] [-o OUTPUT_FILE] # Package a webapp into a .nipkg
670
671
  slcli webapp list [-w WORKSPACE] [-t INT] [-f json]
671
672
  slcli webapp get <WEBAPP_ID> [-f json]
672
- slcli webapp publish --file PATH [--workspace NAME] # Upload and publish a webapp
673
+ slcli webapp publish PATH [--workspace NAME] # Upload and publish a webapp
673
674
  slcli webapp delete <WEBAPP_ID>
674
675
  slcli webapp open <WEBAPP_ID> # Open webapp URL in browser
675
676
  ```
676
677
 
677
- Templates:
678
- - `html` (default) minimal index.html
679
- - `angular` Nimble Angular project with `PROMPTS.md`, `README.md`, and bundled AI skills installed into `.agents/skills/`
678
+ `webapp init` creates the SystemLink Angular starter, not a generic HTML app. The starter installs
679
+ project-scoped skills into `.agents/skills/` and creates `PROMPTS.md` plus `START_HERE.md` so an
680
+ AI assistant can bootstrap the Angular workspace in place with the same Nimble/SystemLink
681
+ conventions described by the `systemlink-webapp` skill.
682
+
683
+ `webapp manifest init` writes `manifest.json` and `nipkg.config.json` using the Plugin Manager
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`.
680
687
 
681
688
  ### skill — AI skill installation
682
689
 
@@ -693,7 +700,7 @@ Client paths:
693
700
 
694
701
  Notes:
695
702
  - `agents` is the default client in interactive mode.
696
- - `webapp init --template angular` installs project-scoped skills into `.agents/skills/` by default.
703
+ - `webapp init` installs project-scoped skills into `.agents/skills/` by default.
697
704
 
698
705
  ### example — Built-in example resource provisioning
699
706
 
@@ -19,6 +19,14 @@ SystemLink webapps are Angular Single-Page Applications built with the Nimble de
19
19
  connected to SystemLink REST APIs, and deployed via `slcli webapp publish`. This skill captures
20
20
  every gotcha learned from building and deploying real apps.
21
21
 
22
+ If the user is starting from scratch, prefer `slcli webapp init <app-dir>` first.
23
+ That command lays down the SystemLink starter layer (`.agents/skills/`, `PROMPTS.md`, and
24
+ `START_HERE.md`) while Angular CLI remains responsible for generating the Angular workspace.
25
+
26
+ When the user wants to package the app for Plugin Manager submission, prefer
27
+ `slcli webapp manifest init <app-dir> ...` to generate `manifest.json` and `nipkg.config.json`
28
+ with the current Plugin Manager field names, then use `slcli webapp pack --config ...`.
29
+
22
30
  ---
23
31
 
24
32
  ## Step 1: Understand what the user needs
@@ -34,15 +42,49 @@ You do NOT need to ask about Angular version or Nimble versions — always use A
34
42
 
35
43
  ---
36
44
 
37
- ## Step 2: Scaffold the Angular project
45
+ ## Step 2: Bootstrap the Angular workspace
46
+
47
+ When the project was created with `slcli webapp init`, generate Angular in the existing starter
48
+ directory so the starter files and bundled skills remain at the project root.
38
49
 
39
50
  ```bash
40
- npx -y @angular/cli@20 new <app-name> --routing --style=scss --skip-git --no-standalone
41
- cd <app-name>
51
+ npx -y @angular/cli@20 new <app-name> --directory . --routing --style=scss --skip-git --no-standalone --defaults --force
42
52
  npm install @ni/nimble-angular
43
53
  ```
44
54
 
45
- > Use `--no-standalone` to generate an NgModule-based app. SystemLink webapps work best with NgModule because it makes it easy to register all Nimble modules in one place.
55
+ > Use `--no-standalone` to generate an NgModule-based app. SystemLink webapps work best with
56
+ > NgModule because it makes it easy to register all Nimble modules in one place.
57
+
58
+ If the user has not run `slcli webapp init` yet and they want a new SystemLink webapp, tell them
59
+ to do that first unless they explicitly want a manual setup.
60
+
61
+ ### Starter shell expectations
62
+
63
+ Before building feature-specific pages, establish a reusable shell that is aligned with other
64
+ SystemLink apps:
65
+
66
+ - Root `nimble-theme-provider` that mirrors the host shell theme
67
+ - Responsive page header with title, summary text, and an action area
68
+ - Shared loading, error, and empty states instead of one-off page-specific handling
69
+ - Route-backed top-level navigation only when the app truly has multiple views
70
+ - Reusable API helpers and service-layer code rather than fetch logic embedded in templates
71
+
72
+ Use Nimble layout tokens and spacing rules consistently across the shell and feature pages:
73
+
74
+ - Prefer Nimble spacing tokens over ad-hoc pixel values: `smallPadding` for tight inline gaps,
75
+ `mediumPadding` for default control spacing, `standardPadding` for section padding, and
76
+ `largePadding` between major content regions.
77
+ - Stack controls vertically with a column layout, `mediumPadding` gap, and `standardPadding`
78
+ around the control group.
79
+ - Use `mediumPadding` or `standardPadding` gaps for side-by-side controls; prefer CSS grid for
80
+ aligned multi-column layouts.
81
+ - Inside accordion panels, keep a column layout with `mediumPadding` gaps and
82
+ `standardPadding` bottom padding.
83
+ - Treat `controlHeight` (32px), `controlSlimHeight` (24px), and `labelHeight` (16px) as the
84
+ baseline sizing tokens for controls and labels.
85
+ - Separate major sections with `largePadding` and subsections with `standardPadding`.
86
+
87
+ See [references/layout-patterns.md](references/layout-patterns.md) for the detailed layout guide.
46
88
 
47
89
  ---
48
90
 
@@ -0,0 +1,71 @@
1
+ # Layout Patterns
2
+
3
+ Guidance for spacing between controls vertically and horizontally within Nimble-based
4
+ SystemLink webapps.
5
+
6
+ ## Spacing tokens
7
+
8
+ Use Nimble design tokens for consistent spacing between controls.
9
+
10
+ | Token | Value | Usage |
11
+ | ----------------- | ----- | ------------------------------------------------------ |
12
+ | `smallPadding` | 4px | Tight spacing for icon margins and inline element gaps |
13
+ | `mediumPadding` | 8px | Default spacing between stacked controls |
14
+ | `standardPadding` | 16px | Section padding and content block margin |
15
+ | `largePadding` | 24px | Separation between major layout sections |
16
+
17
+ ## Control heights
18
+
19
+ | Token | Value | Usage |
20
+ | ------------------- | ----- | ------------------------------- |
21
+ | `controlHeight` | 32px | Standard height for controls |
22
+ | `controlSlimHeight` | 24px | Compact control variants |
23
+ | `labelHeight` | 16px | Height of labels above controls |
24
+
25
+ ## Vertical stacking
26
+
27
+ When stacking controls vertically, such as text fields, number fields, and checkboxes:
28
+
29
+ - Use `mediumPadding` (8px) as the gap between controls in a flex column.
30
+ - Use `standardPadding` (16px) for content padding around the group.
31
+ - Labels above controls add `labelHeight` (16px) to the effective row height.
32
+
33
+ ```html
34
+ <div
35
+ style="display: flex; flex-direction: column; gap: var(--ni-nimble-medium-padding);"
36
+ >
37
+ <nimble-text-field>Label 1</nimble-text-field>
38
+ <nimble-text-field>Label 2</nimble-text-field>
39
+ </div>
40
+ ```
41
+
42
+ ## Horizontal layout
43
+
44
+ When placing controls side by side:
45
+
46
+ - Use `mediumPadding` (8px) or `standardPadding` (16px) as the gap.
47
+ - Prefer CSS grid with equal columns for aligned layouts.
48
+
49
+ ```html
50
+ <div
51
+ style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--ni-nimble-medium-padding);"
52
+ >
53
+ <nimble-checkbox>Option A</nimble-checkbox>
54
+ <nimble-checkbox>Option B</nimble-checkbox>
55
+ </div>
56
+ ```
57
+
58
+ ## Accordion content
59
+
60
+ Inside accordion item content panels:
61
+
62
+ - Use `flex-direction: column` with `mediumPadding` (8px) gap between controls.
63
+ - Indent content by the icon width plus padding so it aligns with the header text.
64
+ - Use `standardPadding` (16px) for bottom padding before the next section.
65
+
66
+ ## Section spacing
67
+
68
+ Between major sections or groups of controls:
69
+
70
+ - Use `largePadding` (24px) between distinct content areas.
71
+ - Use `standardPadding` (16px) for subsections within a group.
@@ -5,6 +5,7 @@ management (list, get, delete, publish, open).
5
5
  """
6
6
 
7
7
  import io
8
+ import re
8
9
  import sys
9
10
  import tarfile
10
11
  import tempfile
@@ -25,14 +26,49 @@ from .utils import (
25
26
  get_base_url,
26
27
  get_web_url,
27
28
  get_headers,
29
+ load_json_file,
28
30
  get_ssl_verify,
29
31
  get_workspace_id_with_fallback,
30
32
  get_workspace_map,
31
33
  handle_api_error,
32
34
  sanitize_filename,
35
+ save_json_file,
33
36
  )
34
37
  from .workspace_utils import get_effective_workspace, get_workspace_display_name
35
38
 
39
+ _PACKAGE_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
40
+ _VERSION_PATTERN = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
41
+ _MAINTAINER_PATTERN = re.compile(r"^[^<>]+\s<[^<>@\s]+@[^<>@\s]+>$")
42
+ _XB_PLUGIN_VALUES = ("webapp", "notebook", "dashboard", "routine", "bundle")
43
+ _MAX_PACKAGE_LENGTH = 100
44
+ _MAX_DISPLAY_NAME_LENGTH = 200
45
+ _MAX_DESCRIPTION_LENGTH = 5000
46
+ _ALLOWED_PLUGIN_MANAGER_KEYS = {
47
+ "buildCommand",
48
+ "buildDir",
49
+ "description",
50
+ "displayName",
51
+ "homepage",
52
+ "iconFile",
53
+ "license",
54
+ "maintainer",
55
+ "nipkgFile",
56
+ "package",
57
+ "section",
58
+ "slPluginManagerMinServerVersion",
59
+ "slPluginManagerTags",
60
+ "version",
61
+ "xbPlugin",
62
+ }
63
+ _LEGACY_MANIFEST_KEY_MAP = {
64
+ "appStoreCategory": "section",
65
+ "appStoreType": "xbPlugin",
66
+ "appStoreAuthor": "maintainer",
67
+ "appStoreRepo": "homepage",
68
+ "appStoreTags": "slPluginManagerTags",
69
+ "appStoreMinServerVersion": "slPluginManagerMinServerVersion",
70
+ }
71
+
36
72
 
37
73
  def _get_webapp_base_url() -> str:
38
74
  return f"{get_base_url()}/niapp/v1"
@@ -127,7 +163,130 @@ def _fetch_webapps_page(
127
163
  return items, cont, total
128
164
 
129
165
 
130
- def _pack_folder_to_nipkg(folder: Path, output: Optional[Path] = None) -> Path:
166
+ def _default_nipkg_filename(package_name: str, version: str) -> str:
167
+ """Return the canonical Plugin Manager package filename."""
168
+ return f"{package_name}_{version}_all.nipkg"
169
+
170
+
171
+ def _default_display_name(package_name: str) -> str:
172
+ """Return a display name derived from a package identifier."""
173
+ words = re.split(r"[._-]+", package_name)
174
+ return " ".join(word.capitalize() for word in words if word)
175
+
176
+
177
+ def _default_angular_build_dir(directory: Path) -> str:
178
+ """Return the default Angular production output path for a starter directory."""
179
+ return f"dist/{_default_angular_project_name(directory)}/browser"
180
+
181
+
182
+ def _normalize_plugin_manager_metadata(raw_metadata: Dict[str, Any]) -> Dict[str, Any]:
183
+ """Normalize legacy App Store keys to Plugin Manager keys."""
184
+ metadata = dict(raw_metadata)
185
+ for old_key, new_key in _LEGACY_MANIFEST_KEY_MAP.items():
186
+ if old_key in metadata and new_key not in metadata:
187
+ metadata[new_key] = metadata[old_key]
188
+ for old_key in _LEGACY_MANIFEST_KEY_MAP:
189
+ metadata.pop(old_key, None)
190
+ return metadata
191
+
192
+
193
+ def _validate_plugin_manager_metadata(
194
+ raw_metadata: Dict[str, Any], require_build_dir: bool = False
195
+ ) -> Dict[str, str]:
196
+ """Validate and normalize Plugin Manager manifest metadata."""
197
+ from urllib.parse import urlparse
198
+
199
+ metadata = _normalize_plugin_manager_metadata(raw_metadata)
200
+ unexpected_keys = sorted(set(metadata) - _ALLOWED_PLUGIN_MANAGER_KEYS)
201
+
202
+ package_name = str(metadata.get("package", "")).strip()
203
+ version = str(metadata.get("version", "")).strip()
204
+ display_name = str(metadata.get("displayName", "")).strip()
205
+ description = str(metadata.get("description", "")).strip()
206
+ section = str(metadata.get("section", "")).strip()
207
+ maintainer = str(metadata.get("maintainer", "")).strip()
208
+ homepage = str(metadata.get("homepage", "")).strip()
209
+ license_name = str(metadata.get("license", "")).strip()
210
+ xb_plugin = str(metadata.get("xbPlugin", "")).strip()
211
+ tags = str(metadata.get("slPluginManagerTags", "")).strip()
212
+ min_server_version = str(metadata.get("slPluginManagerMinServerVersion", "")).strip()
213
+ nipkg_file = str(metadata.get("nipkgFile", "")).strip()
214
+ build_dir = str(metadata.get("buildDir", "")).strip()
215
+ build_command = str(metadata.get("buildCommand", "")).strip()
216
+ icon_file = str(metadata.get("iconFile", "")).strip()
217
+
218
+ errors: List[str] = []
219
+
220
+ if unexpected_keys:
221
+ errors.append("unexpected field(s): " + ", ".join(unexpected_keys))
222
+
223
+ if not package_name or not _PACKAGE_PATTERN.match(package_name) or len(package_name) < 3:
224
+ errors.append("package must match ^[a-z0-9][a-z0-9._-]*$ and be at least 3 characters")
225
+ elif len(package_name) > _MAX_PACKAGE_LENGTH:
226
+ errors.append(f"package must be at most {_MAX_PACKAGE_LENGTH} characters")
227
+ if not version or not _VERSION_PATTERN.match(version):
228
+ errors.append("version must be strict semver in MAJOR.MINOR.PATCH format")
229
+ if not display_name or len(display_name) < 3:
230
+ errors.append("displayName must be at least 3 characters")
231
+ elif len(display_name) > _MAX_DISPLAY_NAME_LENGTH:
232
+ errors.append(f"displayName must be at most {_MAX_DISPLAY_NAME_LENGTH} characters")
233
+ if not description or len(description) < 20:
234
+ errors.append("description must be at least 20 characters")
235
+ elif len(description) > _MAX_DESCRIPTION_LENGTH:
236
+ errors.append(f"description must be at most {_MAX_DESCRIPTION_LENGTH} characters")
237
+ if not section or len(section) < 2:
238
+ errors.append("section must be at least 2 characters")
239
+ if not maintainer or not _MAINTAINER_PATTERN.match(maintainer):
240
+ errors.append("maintainer must be in the format 'Name <email@example.com>'")
241
+ if homepage:
242
+ parsed_homepage = urlparse(homepage)
243
+ if not parsed_homepage.scheme or not parsed_homepage.netloc:
244
+ errors.append("homepage must be a valid absolute URI")
245
+ if not license_name or len(license_name) < 2:
246
+ errors.append("license must be at least 2 characters")
247
+ if xb_plugin not in _XB_PLUGIN_VALUES:
248
+ errors.append(f"xbPlugin must be one of: {', '.join(_XB_PLUGIN_VALUES)}")
249
+ if nipkg_file and not nipkg_file.endswith(".nipkg"):
250
+ errors.append("nipkgFile must end with .nipkg")
251
+ if require_build_dir and not build_dir:
252
+ errors.append("buildDir is required when packing from config without a folder argument")
253
+
254
+ if errors:
255
+ click.echo("✗ Invalid plugin manager metadata:", err=True)
256
+ for error in errors:
257
+ click.echo(f" - {error}", err=True)
258
+ sys.exit(ExitCodes.INVALID_INPUT)
259
+
260
+ validated: Dict[str, str] = {
261
+ "package": package_name,
262
+ "version": version,
263
+ "displayName": display_name,
264
+ "description": description,
265
+ "section": section,
266
+ "maintainer": maintainer,
267
+ "license": license_name,
268
+ "xbPlugin": xb_plugin,
269
+ "nipkgFile": nipkg_file or _default_nipkg_filename(package_name, version),
270
+ }
271
+ if homepage:
272
+ validated["homepage"] = homepage
273
+ if tags:
274
+ validated["slPluginManagerTags"] = tags
275
+ if min_server_version:
276
+ validated["slPluginManagerMinServerVersion"] = min_server_version
277
+ if build_dir:
278
+ validated["buildDir"] = build_dir
279
+ if build_command:
280
+ validated["buildCommand"] = build_command
281
+ if icon_file:
282
+ validated["iconFile"] = icon_file
283
+
284
+ return validated
285
+
286
+
287
+ def _pack_folder_to_nipkg(
288
+ folder: Path, output: Optional[Path] = None, metadata: Optional[Dict[str, str]] = None
289
+ ) -> Path:
131
290
  """Pack a folder into a .nipkg (ar) file and return the output path.
132
291
 
133
292
  The .nipkg produced by this helper uses a Debian-style ar layout with
@@ -140,8 +299,28 @@ def _pack_folder_to_nipkg(folder: Path, output: Optional[Path] = None) -> Path:
140
299
  if not folder.exists() or not folder.is_dir():
141
300
  raise click.ClickException(f"Folder not found: {folder}")
142
301
 
302
+ if metadata is not None:
303
+ package_name = metadata["package"]
304
+ version = metadata["version"]
305
+ architecture = "all"
306
+ else:
307
+ package_name = sanitize_filename(folder.name)
308
+ version = "1.0.0"
309
+ architecture = "all"
310
+ if "_" in folder.name:
311
+ first, rest = folder.name.split("_", 1)
312
+ package_name = sanitize_filename(first)
313
+ rest_parts = rest.split("_")
314
+ if rest_parts:
315
+ version = rest_parts[0]
316
+ if len(rest_parts) > 1:
317
+ architecture = "_".join(rest_parts[1:])
318
+
143
319
  if output is None:
144
- output = folder.with_suffix(".nipkg")
320
+ if metadata is not None:
321
+ output = folder.parent / metadata["nipkgFile"]
322
+ else:
323
+ output = folder.with_suffix(".nipkg")
145
324
 
146
325
  # Ensure parent exists
147
326
  output.parent.mkdir(parents=True, exist_ok=True)
@@ -150,26 +329,36 @@ def _pack_folder_to_nipkg(folder: Path, output: Optional[Path] = None) -> Path:
150
329
  # - control.tar.gz (contains a control file with package metadata)
151
330
  # - data.tar.gz (contains the payload files)
152
331
 
153
- # Derive package metadata from folder name where possible.
154
- pkg_name = sanitize_filename(folder.name)
155
- version = "1.0.0"
156
- architecture = "all"
157
- if "_" in folder.name:
158
- first, rest = folder.name.split("_", 1)
159
- pkg_name = sanitize_filename(first)
160
- rest_parts = rest.split("_")
161
- if rest_parts:
162
- version = rest_parts[0]
163
- if len(rest_parts) > 1:
164
- architecture = "_".join(rest_parts[1:])
165
-
166
- control_fields = {
167
- "Package": pkg_name,
168
- "Version": version,
169
- "Architecture": architecture,
170
- "Maintainer": "slcli <no-reply@example.com>",
171
- "Description": f"Package created by slcli for {pkg_name}",
172
- }
332
+ if metadata is not None:
333
+ control_fields = {
334
+ "Package": metadata["package"],
335
+ "Version": metadata["version"],
336
+ "Architecture": architecture,
337
+ "Description": metadata["description"],
338
+ "Section": metadata["section"],
339
+ "Maintainer": metadata["maintainer"],
340
+ "XB-DisplayName": metadata["displayName"],
341
+ "XB-DisplayVersion": metadata["version"],
342
+ "XB-Plugin": metadata["xbPlugin"],
343
+ "XB-UserVisible": "yes",
344
+ "XB-SlPluginManagerLicense": metadata["license"],
345
+ }
346
+ if metadata.get("homepage"):
347
+ control_fields["Homepage"] = metadata["homepage"]
348
+ if metadata.get("slPluginManagerTags"):
349
+ control_fields["XB-SlPluginManagerTags"] = metadata["slPluginManagerTags"]
350
+ if metadata.get("slPluginManagerMinServerVersion"):
351
+ control_fields["XB-SlPluginManagerMinServerVersion"] = metadata[
352
+ "slPluginManagerMinServerVersion"
353
+ ]
354
+ else:
355
+ control_fields = {
356
+ "Package": package_name,
357
+ "Version": version,
358
+ "Architecture": architecture,
359
+ "Maintainer": "slcli <no-reply@example.com>",
360
+ "Description": f"Package created by slcli for {package_name}",
361
+ }
173
362
 
174
363
  control_lines = [f"{k}: {v}" for k, v in control_fields.items()]
175
364
  control_content = ("\n".join(control_lines) + "\n").encode("utf-8")
@@ -248,79 +437,123 @@ def _pack_folder_to_nipkg(folder: Path, output: Optional[Path] = None) -> Path:
248
437
  # ── Template scaffolding helpers ──────────────────────────────────────────
249
438
 
250
439
 
251
- def _init_html_template(directory: Path, force: bool) -> None:
252
- """Scaffold a minimal HTML webapp."""
253
- directory.mkdir(parents=True, exist_ok=True)
254
- target_folder = directory / "app"
255
- target_folder.mkdir(parents=True, exist_ok=True)
256
- index = target_folder / "index.html"
257
- if index.exists() and not force:
258
- click.echo("✗ app/index.html already exists. Use --force to overwrite.", err=True)
259
- sys.exit(ExitCodes.INVALID_INPUT)
440
+ def _default_angular_project_name(directory: Path) -> str:
441
+ """Return a safe Angular project name derived from the target directory."""
442
+ project_name = sanitize_filename(directory.name)
443
+ return project_name or "systemlink-webapp"
260
444
 
261
- content = """<!doctype html>
262
- <html>
263
- <head>
264
- <meta charset="utf-8">
265
- <title>Example WebApp</title>
266
- </head>
267
- <body>
268
- <h1>Example WebApp</h1>
269
- <p>Created with slcli webapp init</p>
270
- </body>
271
- </html>
272
- """
273
- index.write_text(content, encoding="utf-8")
274
- format_success("Created example index.html", {"Path": str(index)})
275
445
 
446
+ def _build_angular_bootstrap_command(directory: Path) -> str:
447
+ """Build the canonical Angular CLI command for this starter directory."""
448
+ project_name = _default_angular_project_name(directory)
449
+ return (
450
+ f"npx -y @angular/cli@20 new {project_name} --directory . "
451
+ "--routing --style=scss --skip-git --no-standalone --defaults --force"
452
+ )
453
+
454
+
455
+ def _build_webapp_manifest_and_config(
456
+ package_name: str,
457
+ version: str,
458
+ display_name: str,
459
+ description: str,
460
+ section: str,
461
+ maintainer: str,
462
+ homepage: str,
463
+ license_name: str,
464
+ xb_plugin: str,
465
+ tags: str,
466
+ min_server_version: str,
467
+ build_dir: str,
468
+ build_command: str,
469
+ icon_file: str,
470
+ ) -> tuple[Dict[str, str], Dict[str, str]]:
471
+ """Build validated submission manifest and nipkg config payloads."""
472
+ manifest = _validate_plugin_manager_metadata(
473
+ {
474
+ "package": package_name,
475
+ "version": version,
476
+ "displayName": display_name,
477
+ "description": description,
478
+ "section": section,
479
+ "maintainer": maintainer,
480
+ "homepage": homepage,
481
+ "license": license_name,
482
+ "xbPlugin": xb_plugin,
483
+ "slPluginManagerTags": tags,
484
+ "slPluginManagerMinServerVersion": min_server_version,
485
+ "nipkgFile": _default_nipkg_filename(package_name, version),
486
+ }
487
+ )
488
+
489
+ pack_config = dict(manifest)
490
+ pack_config.pop("nipkgFile", None)
491
+ pack_config["buildDir"] = build_dir
492
+ pack_config["buildCommand"] = build_command
493
+ if icon_file:
494
+ pack_config["iconFile"] = icon_file
495
+
496
+ return manifest, pack_config
276
497
 
277
- _ANGULAR_PROMPTS_MD = """\
278
- # SystemLink WebApp — AI Prompts
279
498
 
280
- This project was scaffolded with `slcli webapp init --template angular`.
281
- The **systemlink-webapp** skill has been installed into this project so
282
- your AI assistant already knows how to build Nimble Angular apps for
283
- SystemLink including component choices, API patterns, routing, theming,
284
- and deployment. Just describe what you want.
499
+ def _render_angular_prompts_md(directory: Path) -> str:
500
+ """Render the prompt file for the Angular starter."""
501
+ bootstrap_command = _build_angular_bootstrap_command(directory)
502
+ return f"""# SystemLink WebApp - AI Prompts
285
503
 
286
- ## Getting Started
504
+ This project was initialized with `slcli webapp init`.
505
+ The bundled `systemlink-webapp` and `slcli` skills are already installed into
506
+ this project so your AI assistant can scaffold the Angular workspace and apply
507
+ the SystemLink-specific conventions immediately.
287
508
 
288
- Open this project in your editor and describe your app:
509
+ ## Starter Prompt
289
510
 
290
- > "I need a web dashboard for monitoring our production test systems.
291
- > It should show which systems are online, recent test results, and
292
- > any assets due for calibration."
511
+ Use this prompt first when the directory still only contains starter files:
293
512
 
294
- Your AI assistant will create the Angular project, install the right
295
- packages, and build the pages for you.
513
+ > "Bootstrap this directory into a maintainable Angular 20 SystemLink webapp.
514
+ > Run `{bootstrap_command}` to generate the Angular workspace in place, then
515
+ > install `@ni/nimble-angular` and `@ni/systemlink-clients-ts`. Create a
516
+ > reusable app shell aligned with other SystemLink apps: `nimble-theme-provider`
517
+ > at the root, a responsive page header, content regions for summary cards and
518
+ > tables, and shared loading, error, and empty states. Keep the app NgModule-
519
+ > based, configure `APP_BASE_HREF`, remove the `<base>` tag, use hash routing,
520
+ > disable `inlineCritical` in production, import Nimble fonts, and sync the app
521
+ > theme with the host SystemLink shell."
296
522
 
297
- ## Example Prompts
523
+ ## Manual Bootstrap
298
524
 
299
- Describe your goals the skill handles the technical details.
525
+ If you want to do the initial setup yourself before handing the project to AI:
526
+
527
+ ```bash
528
+ {bootstrap_command}
529
+ npm install @ni/nimble-angular @ni/systemlink-clients-ts
530
+ ```
531
+
532
+ The Angular workspace should be created in this directory, not inside a nested
533
+ subfolder.
534
+
535
+ ## Example Feature Prompts
300
536
 
301
537
  ### Fleet monitoring
302
538
 
303
- > "Build a dashboard that shows all connected systems with their
304
- > status, operating system, and last check-in time. Highlight any
305
- > systems that have been offline for more than 24 hours."
539
+ > "Build a dashboard that shows all connected systems with their status,
540
+ > operating system, and last check-in time. Highlight systems that have been
541
+ > offline for more than 24 hours."
306
542
 
307
543
  ### Test results review
308
544
 
309
- > "Create a page where I can browse recent test results, filter by
310
- > status (passed, failed, running) and program name, and see a
311
- > summary of failure rates."
545
+ > "Create a page where I can browse recent test results, filter by status,
546
+ > program name, and workspace, and see a summary of failure rates."
312
547
 
313
- ### Asset & calibration tracking
548
+ ### Asset and calibration tracking
314
549
 
315
- > "Show all tracked assets grouped by calibration status. I want to
316
- > see which assets are due soon, which are overdue, and be able to
317
- > click on an asset to see its full details."
550
+ > "Show tracked assets grouped by calibration status. I want overdue and due-
551
+ > soon sections, plus an asset details page with key metadata and history."
318
552
 
319
553
  ### Production KPIs
320
554
 
321
- > "Build a dashboard with key metrics: first-pass yield, test
322
- > throughput per hour, and a trend chart of failures over the last
323
- > 30 days."
555
+ > "Build a dashboard with first-pass yield, throughput per hour, and a trend
556
+ > chart of failures over the last 30 days."
324
557
 
325
558
  ### Build and deploy
326
559
 
@@ -333,24 +566,46 @@ Describe your goals — the skill handles the technical details.
333
566
  - [slcli webapp commands](https://ni-kismet.github.io/systemlink-cli/commands.html#webapp)
334
567
  """
335
568
 
336
- _ANGULAR_README_MD = """\
337
- # SystemLink WebApp
338
569
 
339
- A Nimble Angular web application for SystemLink, scaffolded with
340
- `slcli webapp init --template angular`.
570
+ def _render_angular_start_here_md(directory: Path) -> str:
571
+ """Render the starter guide for the Angular workflow."""
572
+ bootstrap_command = _build_angular_bootstrap_command(directory)
573
+ return f"""# SystemLink Angular WebApp Starter
574
+
575
+ This directory was initialized with `slcli webapp init`.
576
+
577
+ `slcli` owns the SystemLink-specific starter layer for this workflow:
578
+
579
+ - bundled AI skills in `.agents/skills/`
580
+ - ready-made prompts in [PROMPTS.md](PROMPTS.md)
581
+ - deployment guidance for `slcli webapp publish`
582
+ - Plugin Manager manifest scaffolding via `slcli webapp manifest init`
583
+
584
+ Angular CLI remains the source of truth for the Angular workspace itself. That
585
+ keeps the generated project aligned with current Angular defaults while the
586
+ skills and starter files enforce the SystemLink-specific best practices.
341
587
 
342
- ## Prerequisites
588
+ ## Bootstrap the Angular workspace
343
589
 
344
- - [Node.js](https://nodejs.org/) 18+ and npm
345
- - [slcli](https://ni-kismet.github.io/systemlink-cli/)
590
+ ```bash
591
+ {bootstrap_command}
592
+ npm install @ni/nimble-angular @ni/systemlink-clients-ts
593
+ ```
346
594
 
347
- ## Getting Started
595
+ If you use an AI assistant, ask it to follow the starter prompt in
596
+ [PROMPTS.md](PROMPTS.md). The Angular app should be created in this directory,
597
+ not inside a nested subfolder.
348
598
 
349
- Open this directory in your editor and ask your AI assistant to create
350
- the project — see [PROMPTS.md](PROMPTS.md) for ready-made prompts.
599
+ ## Baseline conventions
351
600
 
352
- The AI skills for SystemLink webapp development are already installed
353
- in this project directory.
601
+ - Angular 20 with an NgModule-based app (`--no-standalone`)
602
+ - `@ni/nimble-angular` for UI and design tokens
603
+ - `@ni/systemlink-clients-ts` as the default API integration path
604
+ - `APP_BASE_HREF` provided in DI and no `<base>` tag in `index.html`
605
+ - Hash routing for SystemLink sub-path hosting
606
+ - `inlineCritical: false` in the production build configuration
607
+ - A reusable SystemLink-aligned shell with theme sync, page header, content
608
+ regions, and shared loading, error, and empty states
354
609
 
355
610
  ## Deploy to SystemLink
356
611
 
@@ -359,22 +614,34 @@ ng build --configuration production
359
614
  slcli webapp publish dist/<project-name>/browser/ \\
360
615
  --name "My Dashboard" --workspace Default
361
616
  ```
617
+
618
+ ## Plugin Manager packaging metadata
619
+
620
+ ```bash
621
+ slcli webapp manifest init . \\
622
+ --description "A dashboard for monitoring fleet health and calibration status." \\
623
+ --section Dashboard \\
624
+ --maintainer "Your Name <you@example.com>" \\
625
+ --license MIT
626
+
627
+ slcli webapp pack --config nipkg.config.json
628
+ ```
362
629
  """
363
630
 
364
631
 
365
632
  def _init_angular_template(directory: Path, force: bool) -> None:
366
- """Scaffold a Nimble Angular project with SystemLink TypeScript clients."""
633
+ """Scaffold the SystemLink Angular starter for a new webapp."""
367
634
  directory.mkdir(parents=True, exist_ok=True)
368
635
 
369
636
  prompts_file = directory / "PROMPTS.md"
370
- readme_file = directory / "README.md"
637
+ start_here_file = directory / "START_HERE.md"
371
638
 
372
639
  # Check for existing files
373
640
  existing = []
374
641
  if prompts_file.exists() and not force:
375
642
  existing.append("PROMPTS.md")
376
- if readme_file.exists() and not force:
377
- existing.append("README.md")
643
+ if start_here_file.exists() and not force:
644
+ existing.append("START_HERE.md")
378
645
  if existing:
379
646
  click.echo(
380
647
  f"✗ {', '.join(existing)} already exist(s). Use --force to overwrite.",
@@ -382,22 +649,22 @@ def _init_angular_template(directory: Path, force: bool) -> None:
382
649
  )
383
650
  sys.exit(ExitCodes.INVALID_INPUT)
384
651
 
385
- prompts_file.write_text(_ANGULAR_PROMPTS_MD, encoding="utf-8")
386
- readme_file.write_text(_ANGULAR_README_MD, encoding="utf-8")
652
+ prompts_file.write_text(_render_angular_prompts_md(directory), encoding="utf-8")
653
+ start_here_file.write_text(_render_angular_start_here_md(directory), encoding="utf-8")
387
654
 
388
655
  # Auto-install AI skills into the project directory
389
656
  installed = install_skills_to_directory(directory)
390
657
  skill_msg = f"{installed} skill(s) installed" if installed else "skills not found"
391
658
 
392
659
  format_success(
393
- "Scaffolded Nimble Angular project",
660
+ "Scaffolded SystemLink Angular starter",
394
661
  {
395
662
  "Directory": str(directory),
396
663
  "Skills": skill_msg,
397
664
  "Next steps": (
398
665
  "1. cd " + str(directory) + "\n"
399
- " 2. Open in your editor and ask AI to create the app\n"
400
- " 3. See PROMPTS.md for example prompts"
666
+ " 2. Open START_HERE.md and PROMPTS.md\n"
667
+ " 3. Ask AI to bootstrap the Angular workspace in this directory"
401
668
  ),
402
669
  },
403
670
  )
@@ -411,42 +678,148 @@ def register_webapp_commands(cli: Any) -> None:
411
678
  """Manage web applications (init/pack locally, publish/CRUD remotely)."""
412
679
 
413
680
  @webapp.command(name="init")
414
- @click.option(
415
- "--directory",
681
+ @click.argument(
416
682
  "directory",
417
683
  type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
418
- default=Path.cwd(),
419
- show_default="CWD",
420
- help="Target directory to create the project in",
421
684
  )
685
+ @click.option("--force", is_flag=True, help="Overwrite existing starter files")
686
+ def init_webapp(directory: Path, force: bool) -> None:
687
+ """Scaffold the SystemLink Angular starter for a new webapp."""
688
+ try:
689
+ _init_angular_template(directory, force)
690
+ except SystemExit:
691
+ raise
692
+ except Exception as exc:
693
+ handle_api_error(exc)
694
+
695
+ @webapp.group(name="manifest")
696
+ def webapp_manifest() -> None:
697
+ """Create Plugin Manager submission manifests and packaging config files."""
698
+
699
+ @webapp_manifest.command(name="init")
700
+ @click.argument(
701
+ "directory",
702
+ type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
703
+ )
704
+ @click.option("--package", "package_name", default="", help="Package identifier")
705
+ @click.option("--version", default="0.1.0", show_default=True, help="Semantic version")
706
+ @click.option("--display-name", "display_name", default="", help="Human-readable name")
707
+ @click.option("--description", required=True, help="Plugin description")
708
+ @click.option("--section", required=True, help="Plugin Manager section/category")
709
+ @click.option("--maintainer", required=True, help="Maintainer in 'Name <email>' format")
710
+ @click.option("--homepage", default="", help="Project homepage or source repository URL")
711
+ @click.option("--license", "license_name", required=True, help="License identifier")
422
712
  @click.option(
423
- "--template",
424
- "template",
425
- type=click.Choice(["html", "angular"]),
426
- default="html",
713
+ "--plugin-type",
714
+ "xb_plugin",
715
+ type=click.Choice(list(_XB_PLUGIN_VALUES)),
716
+ default="webapp",
427
717
  show_default=True,
428
- help="Project template: html (simple page) or angular (Nimble Angular app)",
718
+ help="Plugin Manager top-level plugin type",
429
719
  )
430
- @click.option("--force", is_flag=True, help="Overwrite existing files")
431
- def init_webapp(directory: Path, template: str, force: bool) -> None:
432
- """Scaffold a sample webapp project.
433
-
434
- Use --template html (default) for a minimal index.html, or
435
- --template angular for a Nimble Angular project with SystemLink
436
- TypeScript clients, AI-ready prompts, and deployment configuration.
437
- """
720
+ @click.option("--tags", default="", help="Comma-separated Plugin Manager search tags")
721
+ @click.option(
722
+ "--min-server-version",
723
+ default="",
724
+ help="Minimum supported SystemLink server version",
725
+ )
726
+ @click.option("--build-dir", default="", help="Build output directory for nipkg.config.json")
727
+ @click.option(
728
+ "--build-command",
729
+ default="npm run build",
730
+ show_default=True,
731
+ help="Build command written to nipkg.config.json",
732
+ )
733
+ @click.option("--icon-file", default="", help="Icon path written to nipkg.config.json")
734
+ @click.option("--force", is_flag=True, help="Overwrite existing manifest files")
735
+ def init_manifest(
736
+ directory: Path,
737
+ package_name: str,
738
+ version: str,
739
+ display_name: str,
740
+ description: str,
741
+ section: str,
742
+ maintainer: str,
743
+ homepage: str,
744
+ license_name: str,
745
+ xb_plugin: str,
746
+ tags: str,
747
+ min_server_version: str,
748
+ build_dir: str,
749
+ build_command: str,
750
+ icon_file: str,
751
+ force: bool,
752
+ ) -> None:
753
+ """Write manifest.json and nipkg.config.json using the Plugin Manager contract."""
438
754
  try:
439
- if template == "angular":
440
- _init_angular_template(directory, force)
441
- else:
442
- _init_html_template(directory, force)
755
+ directory.mkdir(parents=True, exist_ok=True)
756
+
757
+ package_name = package_name or sanitize_filename(directory.name, "webapp")
758
+ display_name = display_name or _default_display_name(package_name)
759
+ build_dir = build_dir or _default_angular_build_dir(directory)
760
+
761
+ manifest_path = directory / "manifest.json"
762
+ config_path = directory / "nipkg.config.json"
763
+ existing = []
764
+ if manifest_path.exists() and not force:
765
+ existing.append("manifest.json")
766
+ if config_path.exists() and not force:
767
+ existing.append("nipkg.config.json")
768
+ if existing:
769
+ click.echo(
770
+ f"✗ {', '.join(existing)} already exist(s). Use --force to overwrite.",
771
+ err=True,
772
+ )
773
+ sys.exit(ExitCodes.INVALID_INPUT)
774
+
775
+ manifest, pack_config = _build_webapp_manifest_and_config(
776
+ package_name=package_name,
777
+ version=version,
778
+ display_name=display_name,
779
+ description=description,
780
+ section=section,
781
+ maintainer=maintainer,
782
+ homepage=homepage,
783
+ license_name=license_name,
784
+ xb_plugin=xb_plugin,
785
+ tags=tags,
786
+ min_server_version=min_server_version,
787
+ build_dir=build_dir,
788
+ build_command=build_command,
789
+ icon_file=icon_file,
790
+ )
791
+
792
+ save_json_file(manifest, str(manifest_path))
793
+ save_json_file(pack_config, str(config_path))
794
+ format_success(
795
+ "Created Plugin Manager manifest files",
796
+ {
797
+ "Manifest": str(manifest_path),
798
+ "Pack config": str(config_path),
799
+ "nipkgFile": manifest["nipkgFile"],
800
+ },
801
+ )
443
802
  except SystemExit:
444
803
  raise
445
804
  except Exception as exc:
446
805
  handle_api_error(exc)
447
806
 
448
807
  @webapp.command(name="pack")
449
- @click.argument("folder", type=click.Path(exists=True, file_okay=False, path_type=Path))
808
+ @click.argument(
809
+ "folder",
810
+ required=False,
811
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
812
+ )
813
+ @click.option(
814
+ "--config",
815
+ "config_path",
816
+ type=click.Path(exists=True, file_okay=True, dir_okay=False, path_type=Path),
817
+ default=None,
818
+ help=(
819
+ "Path to nipkg.config.json or compatible metadata JSON. "
820
+ "If it does not include buildDir, also pass FOLDER."
821
+ ),
822
+ )
450
823
  @click.option(
451
824
  "--output",
452
825
  "output",
@@ -454,11 +827,46 @@ def register_webapp_commands(cli: Any) -> None:
454
827
  default=None,
455
828
  help="Output .nipkg file path",
456
829
  )
457
- def pack_cmd(folder: Path, output: Optional[Path]) -> None:
830
+ def pack_cmd(
831
+ folder: Optional[Path], config_path: Optional[Path], output: Optional[Path]
832
+ ) -> None:
458
833
  """Pack a folder into a .nipkg."""
459
834
  try:
835
+ metadata: Optional[Dict[str, str]] = None
836
+ resolved_folder = folder
837
+
838
+ if config_path is not None:
839
+ raw_data = load_json_file(str(config_path))
840
+ if not isinstance(raw_data, dict):
841
+ click.echo("✗ Config file must contain a JSON object.", err=True)
842
+ sys.exit(ExitCodes.INVALID_INPUT)
843
+ metadata = _validate_plugin_manager_metadata(
844
+ raw_data, require_build_dir=resolved_folder is None
845
+ )
846
+
847
+ if resolved_folder is None:
848
+ build_dir = metadata.get("buildDir", "")
849
+ base_dir = config_path.parent
850
+ resolved_folder = Path(build_dir)
851
+ if not resolved_folder.is_absolute():
852
+ resolved_folder = base_dir / resolved_folder
853
+ resolved_folder = resolved_folder.resolve()
854
+ if not resolved_folder.exists() or not resolved_folder.is_dir():
855
+ click.echo(
856
+ f"✗ buildDir does not exist or is not a directory: {resolved_folder}",
857
+ err=True,
858
+ )
859
+ sys.exit(ExitCodes.INVALID_INPUT)
860
+
861
+ if output is None and metadata.get("nipkgFile"):
862
+ output = config_path.parent / metadata["nipkgFile"]
863
+
864
+ if resolved_folder is None:
865
+ click.echo("✗ Must provide a folder or --config with buildDir.", err=True)
866
+ sys.exit(ExitCodes.INVALID_INPUT)
867
+
460
868
  out = Path(output) if output else None
461
- result = _pack_folder_to_nipkg(folder, out)
869
+ result = _pack_folder_to_nipkg(resolved_folder, out, metadata)
462
870
  format_success("Packed folder", {"Path": str(result)})
463
871
  except SystemExit:
464
872
  raise
File without changes