systemlink-cli 1.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- slcli/__init__.py +1 -0
- slcli/__main__.py +23 -0
- slcli/_version.py +4 -0
- slcli/asset_click.py +1289 -0
- slcli/cli_formatters.py +218 -0
- slcli/cli_utils.py +504 -0
- slcli/comment_click.py +602 -0
- slcli/completion_click.py +418 -0
- slcli/config.py +81 -0
- slcli/config_click.py +498 -0
- slcli/dff_click.py +979 -0
- slcli/dff_decorators.py +24 -0
- slcli/example_click.py +404 -0
- slcli/example_loader.py +274 -0
- slcli/example_provisioner.py +2777 -0
- slcli/examples/README.md +134 -0
- slcli/examples/_schema/schema-v1.0.json +169 -0
- slcli/examples/demo-complete-workflow/README.md +323 -0
- slcli/examples/demo-complete-workflow/config.yaml +638 -0
- slcli/examples/demo-test-plans/README.md +132 -0
- slcli/examples/demo-test-plans/config.yaml +154 -0
- slcli/examples/exercise-5-1-parametric-insights/README.md +101 -0
- slcli/examples/exercise-5-1-parametric-insights/config.yaml +1589 -0
- slcli/examples/exercise-7-1-test-plans/README.md +93 -0
- slcli/examples/exercise-7-1-test-plans/config.yaml +323 -0
- slcli/examples/spec-compliance-notebooks/README.md +140 -0
- slcli/examples/spec-compliance-notebooks/config.yaml +112 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +1553 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +1577 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +912 -0
- slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
- slcli/feed_click.py +892 -0
- slcli/file_click.py +932 -0
- slcli/function_click.py +1400 -0
- slcli/function_templates.py +85 -0
- slcli/main.py +406 -0
- slcli/mcp_click.py +269 -0
- slcli/mcp_server.py +748 -0
- slcli/notebook_click.py +1770 -0
- slcli/platform.py +345 -0
- slcli/policy_click.py +679 -0
- slcli/policy_utils.py +411 -0
- slcli/profiles.py +411 -0
- slcli/response_handlers.py +359 -0
- slcli/routine_click.py +763 -0
- slcli/skill_click.py +253 -0
- slcli/skills/slcli/SKILL.md +713 -0
- slcli/skills/slcli/references/analysis-recipes.md +474 -0
- slcli/skills/slcli/references/filtering.md +236 -0
- slcli/skills/systemlink-webapp/SKILL.md +744 -0
- slcli/skills/systemlink-webapp/references/deployment.md +123 -0
- slcli/skills/systemlink-webapp/references/nimble-angular.md +380 -0
- slcli/skills/systemlink-webapp/references/systemlink-services.md +192 -0
- slcli/ssl_trust.py +93 -0
- slcli/system_click.py +2216 -0
- slcli/table_utils.py +124 -0
- slcli/tag_click.py +794 -0
- slcli/templates_click.py +599 -0
- slcli/testmonitor_click.py +1667 -0
- slcli/universal_handlers.py +305 -0
- slcli/user_click.py +1218 -0
- slcli/utils.py +832 -0
- slcli/web_editor.py +295 -0
- slcli/webapp_click.py +981 -0
- slcli/workflow_preview.py +287 -0
- slcli/workflows_click.py +988 -0
- slcli/workitem_click.py +2258 -0
- slcli/workspace_click.py +576 -0
- slcli/workspace_utils.py +206 -0
- systemlink_cli-1.3.1.dist-info/METADATA +20 -0
- systemlink_cli-1.3.1.dist-info/RECORD +74 -0
- systemlink_cli-1.3.1.dist-info/WHEEL +4 -0
- systemlink_cli-1.3.1.dist-info/entry_points.txt +7 -0
- systemlink_cli-1.3.1.dist-info/licenses/LICENSE +21 -0
slcli/webapp_click.py
ADDED
|
@@ -0,0 +1,981 @@
|
|
|
1
|
+
"""CLI commands for managing SystemLink WebApps via the WebApp Service.
|
|
2
|
+
|
|
3
|
+
Provides local scaffolding (init), packing helpers (pack), and remote
|
|
4
|
+
management (list, get, delete, publish, open).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import io
|
|
8
|
+
import sys
|
|
9
|
+
import tarfile
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
import questionary
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
from .cli_utils import validate_output_format
|
|
20
|
+
from .universal_handlers import UniversalResponseHandler
|
|
21
|
+
from .utils import (
|
|
22
|
+
ExitCodes,
|
|
23
|
+
format_success,
|
|
24
|
+
get_base_url,
|
|
25
|
+
get_web_url,
|
|
26
|
+
get_headers,
|
|
27
|
+
get_ssl_verify,
|
|
28
|
+
get_workspace_id_with_fallback,
|
|
29
|
+
get_workspace_map,
|
|
30
|
+
handle_api_error,
|
|
31
|
+
sanitize_filename,
|
|
32
|
+
)
|
|
33
|
+
from .workspace_utils import get_effective_workspace, get_workspace_display_name
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_webapp_base_url() -> str:
|
|
37
|
+
return f"{get_base_url()}/niapp/v1"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _query_webapps_http(filter_str: str, max_items: int = 1000) -> List[Dict[str, Any]]:
|
|
41
|
+
"""Query webapps using continuation token pagination.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
filter_str: Filter string for the query (server syntax)
|
|
45
|
+
max_items: Maximum number of items to retrieve in total
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
List of webapp dicts
|
|
49
|
+
"""
|
|
50
|
+
base = _get_webapp_base_url()
|
|
51
|
+
headers = get_headers("application/json")
|
|
52
|
+
|
|
53
|
+
all_items: List[Dict[str, Any]] = []
|
|
54
|
+
continuation_token: Optional[str] = None
|
|
55
|
+
|
|
56
|
+
# Choose a reasonable page size for server-side paging
|
|
57
|
+
page_size = 100
|
|
58
|
+
if max_items and max_items < page_size:
|
|
59
|
+
page_size = max_items
|
|
60
|
+
|
|
61
|
+
while True:
|
|
62
|
+
payload: Dict[str, Any] = {
|
|
63
|
+
"take": page_size,
|
|
64
|
+
"orderBy": "updated",
|
|
65
|
+
"orderByDescending": True,
|
|
66
|
+
}
|
|
67
|
+
if filter_str:
|
|
68
|
+
payload["filter"] = filter_str
|
|
69
|
+
if continuation_token:
|
|
70
|
+
payload["continuationToken"] = continuation_token
|
|
71
|
+
|
|
72
|
+
# Request the server to include a total count when available
|
|
73
|
+
resp = requests.post(
|
|
74
|
+
f"{base}/webapps/query?includeTotalCount=true",
|
|
75
|
+
headers=headers,
|
|
76
|
+
json=payload,
|
|
77
|
+
verify=get_ssl_verify(),
|
|
78
|
+
)
|
|
79
|
+
resp.raise_for_status()
|
|
80
|
+
data = resp.json()
|
|
81
|
+
page_items: List[Dict[str, Any]] = data.get("webapps", []) if isinstance(data, dict) else []
|
|
82
|
+
|
|
83
|
+
for it in page_items:
|
|
84
|
+
all_items.append(it)
|
|
85
|
+
|
|
86
|
+
# Stop if we've reached max_items
|
|
87
|
+
if max_items and len(all_items) >= max_items:
|
|
88
|
+
return all_items[:max_items]
|
|
89
|
+
|
|
90
|
+
continuation_token = data.get("continuationToken")
|
|
91
|
+
if not continuation_token:
|
|
92
|
+
break
|
|
93
|
+
|
|
94
|
+
return all_items
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _fetch_webapps_page(
|
|
98
|
+
filter_str: str, take: int = 100, continuation_token: Optional[str] = None
|
|
99
|
+
) -> tuple:
|
|
100
|
+
"""Fetch a single page of webapps from the server.
|
|
101
|
+
|
|
102
|
+
Returns a tuple: (items, continuationToken, total)
|
|
103
|
+
"""
|
|
104
|
+
base = _get_webapp_base_url()
|
|
105
|
+
headers = get_headers("application/json")
|
|
106
|
+
|
|
107
|
+
payload: Dict[str, Any] = {"take": take, "orderBy": "updated", "orderByDescending": True}
|
|
108
|
+
if filter_str:
|
|
109
|
+
payload["filter"] = filter_str
|
|
110
|
+
if continuation_token:
|
|
111
|
+
payload["continuationToken"] = continuation_token
|
|
112
|
+
|
|
113
|
+
# Request the server to include a total count when available
|
|
114
|
+
resp = requests.post(
|
|
115
|
+
f"{base}/webapps/query?includeTotalCount=true",
|
|
116
|
+
headers=headers,
|
|
117
|
+
json=payload,
|
|
118
|
+
verify=get_ssl_verify(),
|
|
119
|
+
)
|
|
120
|
+
resp.raise_for_status()
|
|
121
|
+
data = resp.json()
|
|
122
|
+
items = data.get("webapps", []) if isinstance(data, dict) else []
|
|
123
|
+
cont = data.get("continuationToken")
|
|
124
|
+
total = data.get("totalCount")
|
|
125
|
+
|
|
126
|
+
return items, cont, total
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _pack_folder_to_nipkg(folder: Path, output: Optional[Path] = None) -> Path:
|
|
130
|
+
"""Pack a folder into a .nipkg (ar) file and return the output path.
|
|
131
|
+
|
|
132
|
+
The .nipkg produced by this helper uses a Debian-style ar layout with
|
|
133
|
+
three members: debian-binary, control.tar.gz and data.tar.gz. The
|
|
134
|
+
implementation writes the ar archive directly; long member names are
|
|
135
|
+
truncated to 16 bytes (simple strategy) which is acceptable for our
|
|
136
|
+
use-case but could be extended to support GNU longname tables if
|
|
137
|
+
needed.
|
|
138
|
+
"""
|
|
139
|
+
if not folder.exists() or not folder.is_dir():
|
|
140
|
+
raise click.ClickException(f"Folder not found: {folder}")
|
|
141
|
+
|
|
142
|
+
if output is None:
|
|
143
|
+
output = folder.with_suffix(".nipkg")
|
|
144
|
+
|
|
145
|
+
# Ensure parent exists
|
|
146
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
# Debian-style package layout inside an ar archive:
|
|
148
|
+
# - debian-binary (contains version string, e.g. "2.0\n")
|
|
149
|
+
# - control.tar.gz (contains a control file with package metadata)
|
|
150
|
+
# - data.tar.gz (contains the payload files)
|
|
151
|
+
|
|
152
|
+
# Derive package metadata from folder name where possible.
|
|
153
|
+
pkg_name = sanitize_filename(folder.name)
|
|
154
|
+
version = "1.0.0"
|
|
155
|
+
architecture = "all"
|
|
156
|
+
if "_" in folder.name:
|
|
157
|
+
first, rest = folder.name.split("_", 1)
|
|
158
|
+
pkg_name = sanitize_filename(first)
|
|
159
|
+
rest_parts = rest.split("_")
|
|
160
|
+
if rest_parts:
|
|
161
|
+
version = rest_parts[0]
|
|
162
|
+
if len(rest_parts) > 1:
|
|
163
|
+
architecture = "_".join(rest_parts[1:])
|
|
164
|
+
|
|
165
|
+
control_fields = {
|
|
166
|
+
"Package": pkg_name,
|
|
167
|
+
"Version": version,
|
|
168
|
+
"Architecture": architecture,
|
|
169
|
+
"Maintainer": "slcli <no-reply@example.com>",
|
|
170
|
+
"Description": f"Package created by slcli for {pkg_name}",
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
control_lines = [f"{k}: {v}" for k, v in control_fields.items()]
|
|
174
|
+
control_content = ("\n".join(control_lines) + "\n").encode("utf-8")
|
|
175
|
+
|
|
176
|
+
# Create control.tar.gz in-memory containing a single file 'control'
|
|
177
|
+
control_buf = io.BytesIO()
|
|
178
|
+
with tarfile.open(fileobj=control_buf, mode="w:gz") as tf:
|
|
179
|
+
ti = tarfile.TarInfo(name="control")
|
|
180
|
+
ti.size = len(control_content)
|
|
181
|
+
ti.mtime = int(time.time())
|
|
182
|
+
tf.addfile(ti, io.BytesIO(control_content))
|
|
183
|
+
control_bytes = control_buf.getvalue()
|
|
184
|
+
|
|
185
|
+
# Create data.tar.gz in-memory containing the folder contents at the root
|
|
186
|
+
data_buf = io.BytesIO()
|
|
187
|
+
with tarfile.open(fileobj=data_buf, mode="w:gz") as dtf:
|
|
188
|
+
# tarfile.add will handle directories and files; preserve relative paths
|
|
189
|
+
dtf.add(str(folder), arcname=".")
|
|
190
|
+
data_bytes = data_buf.getvalue()
|
|
191
|
+
|
|
192
|
+
# debian-binary content
|
|
193
|
+
debian_bin = b"2.0\n"
|
|
194
|
+
|
|
195
|
+
# Write an ar archive (the Debian .deb format) but use .nipkg extension
|
|
196
|
+
def _ar_header(
|
|
197
|
+
name: str,
|
|
198
|
+
size: int,
|
|
199
|
+
mtime: Optional[int] = None,
|
|
200
|
+
uid: int = 0,
|
|
201
|
+
gid: int = 0,
|
|
202
|
+
mode: int = 0o100644,
|
|
203
|
+
) -> bytes:
|
|
204
|
+
if mtime is None:
|
|
205
|
+
mtime = int(time.time())
|
|
206
|
+
# Header fields: name(16) mtime(12) uid(6) gid(6) mode(8) size(10) magic(2)
|
|
207
|
+
name_field = name.encode("utf-8")
|
|
208
|
+
if len(name_field) > 16:
|
|
209
|
+
# use truncated name (simple strategy)
|
|
210
|
+
name_field = name_field[:16]
|
|
211
|
+
header = (
|
|
212
|
+
name_field.ljust(16, b" ")
|
|
213
|
+
+ str(int(mtime)).encode("ascii").ljust(12, b" ")
|
|
214
|
+
+ str(int(uid)).encode("ascii").ljust(6, b" ")
|
|
215
|
+
+ str(int(gid)).encode("ascii").ljust(6, b" ")
|
|
216
|
+
+ oct(mode)[2:].encode("ascii").ljust(8, b" ")
|
|
217
|
+
+ str(int(size)).encode("ascii").ljust(10, b" ")
|
|
218
|
+
+ b"`\n"
|
|
219
|
+
)
|
|
220
|
+
return header
|
|
221
|
+
|
|
222
|
+
with open(output, "wb") as out_f:
|
|
223
|
+
# Global header
|
|
224
|
+
out_f.write(b"!<arch>\n")
|
|
225
|
+
|
|
226
|
+
# debian-binary
|
|
227
|
+
out_f.write(_ar_header("debian-binary", len(debian_bin)))
|
|
228
|
+
out_f.write(debian_bin)
|
|
229
|
+
if len(debian_bin) % 2:
|
|
230
|
+
out_f.write(b"\n")
|
|
231
|
+
|
|
232
|
+
# control.tar.gz
|
|
233
|
+
out_f.write(_ar_header("control.tar.gz", len(control_bytes)))
|
|
234
|
+
out_f.write(control_bytes)
|
|
235
|
+
if len(control_bytes) % 2:
|
|
236
|
+
out_f.write(b"\n")
|
|
237
|
+
|
|
238
|
+
# data.tar.gz
|
|
239
|
+
out_f.write(_ar_header("data.tar.gz", len(data_bytes)))
|
|
240
|
+
out_f.write(data_bytes)
|
|
241
|
+
if len(data_bytes) % 2:
|
|
242
|
+
out_f.write(b"\n")
|
|
243
|
+
|
|
244
|
+
return output
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ── Template scaffolding helpers ──────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _init_html_template(directory: Path, force: bool) -> None:
|
|
251
|
+
"""Scaffold a minimal HTML webapp."""
|
|
252
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
253
|
+
target_folder = directory / "app"
|
|
254
|
+
target_folder.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
index = target_folder / "index.html"
|
|
256
|
+
if index.exists() and not force:
|
|
257
|
+
click.echo("✗ app/index.html already exists. Use --force to overwrite.", err=True)
|
|
258
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
259
|
+
|
|
260
|
+
content = """<!doctype html>
|
|
261
|
+
<html>
|
|
262
|
+
<head>
|
|
263
|
+
<meta charset="utf-8">
|
|
264
|
+
<title>Example WebApp</title>
|
|
265
|
+
</head>
|
|
266
|
+
<body>
|
|
267
|
+
<h1>Example WebApp</h1>
|
|
268
|
+
<p>Created with slcli webapp init</p>
|
|
269
|
+
</body>
|
|
270
|
+
</html>
|
|
271
|
+
"""
|
|
272
|
+
index.write_text(content, encoding="utf-8")
|
|
273
|
+
format_success("Created example index.html", {"Path": str(index)})
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
_ANGULAR_PROMPTS_MD = """\
|
|
277
|
+
# SystemLink WebApp — AI Prompts
|
|
278
|
+
|
|
279
|
+
This project was scaffolded with `slcli webapp init --template angular`.
|
|
280
|
+
|
|
281
|
+
The **systemlink-webapp** skill teaches your AI assistant how to build
|
|
282
|
+
Nimble Angular applications for SystemLink. Install it first:
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
slcli skill install --client all
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
## Getting Started Prompts
|
|
289
|
+
|
|
290
|
+
Copy-paste these into your AI assistant to build out the project:
|
|
291
|
+
|
|
292
|
+
### 1. Create a basic dashboard layout
|
|
293
|
+
|
|
294
|
+
> "Set up the AppModule with NimbleModule imports, a nimble-theme-provider
|
|
295
|
+
> with automatic theme detection, and a nimble-anchor-tabs layout with
|
|
296
|
+
> Overview and Settings tabs. Use hash routing."
|
|
297
|
+
|
|
298
|
+
### 2. Add a systems overview page
|
|
299
|
+
|
|
300
|
+
> "Create a SystemsComponent that uses the Systems Management TypeScript
|
|
301
|
+
> client to fetch connected systems and display them in a nimble-table
|
|
302
|
+
> with columns for alias, state, OS, and last-updated timestamp.
|
|
303
|
+
> Add a nimble-spinner while loading."
|
|
304
|
+
|
|
305
|
+
### 3. Add a test results page
|
|
306
|
+
|
|
307
|
+
> "Create a TestResultsComponent that uses the Test Monitor TypeScript
|
|
308
|
+
> client to list recent test results in a nimble-table. Add
|
|
309
|
+
> nimble-select filters for status (Passed/Failed/Running) and
|
|
310
|
+
> program name. Show a nimble-banner when there are failures."
|
|
311
|
+
|
|
312
|
+
### 4. Add an asset calibration tracker
|
|
313
|
+
|
|
314
|
+
> "Create a CalibrationComponent that uses the Asset Management
|
|
315
|
+
> TypeScript client to show assets grouped by calibration status.
|
|
316
|
+
> Use nimble-card components for each status category with counts.
|
|
317
|
+
> Add a nimble-drawer that shows asset details when clicked."
|
|
318
|
+
|
|
319
|
+
### 5. Build and deploy
|
|
320
|
+
|
|
321
|
+
> "Build the project for production and deploy it to SystemLink
|
|
322
|
+
> using slcli webapp publish."
|
|
323
|
+
|
|
324
|
+
## Reference
|
|
325
|
+
|
|
326
|
+
- [Nimble Angular components](https://nimble.ni.dev/)
|
|
327
|
+
- [SystemLink TypeScript clients](https://www.npmjs.com/package/@ni/systemlink-clients-ts)
|
|
328
|
+
- [slcli webapp commands](https://ni-kismet.github.io/systemlink-cli/commands.html#webapp)
|
|
329
|
+
|
|
330
|
+
## Build & Deploy
|
|
331
|
+
|
|
332
|
+
```bash
|
|
333
|
+
ng build --configuration production
|
|
334
|
+
slcli webapp publish dist/<project-name>/browser/ \\
|
|
335
|
+
--name "My Dashboard" --workspace Default
|
|
336
|
+
```
|
|
337
|
+
"""
|
|
338
|
+
|
|
339
|
+
_ANGULAR_README_MD = """\
|
|
340
|
+
# SystemLink WebApp
|
|
341
|
+
|
|
342
|
+
A Nimble Angular web application for SystemLink, scaffolded with
|
|
343
|
+
`slcli webapp init --template angular`.
|
|
344
|
+
|
|
345
|
+
## Prerequisites
|
|
346
|
+
|
|
347
|
+
- [Node.js](https://nodejs.org/) 18+ and npm
|
|
348
|
+
- [Angular CLI](https://angular.dev/tools/cli) (`npm install -g @angular/cli`)
|
|
349
|
+
- [slcli](https://ni-kismet.github.io/systemlink-cli/) with AI skills installed
|
|
350
|
+
|
|
351
|
+
## Quick Start
|
|
352
|
+
|
|
353
|
+
```bash
|
|
354
|
+
# Install dependencies
|
|
355
|
+
npm install
|
|
356
|
+
|
|
357
|
+
# Start development server
|
|
358
|
+
ng serve --open
|
|
359
|
+
|
|
360
|
+
# Build for production
|
|
361
|
+
ng build --configuration production
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
## Deploy to SystemLink
|
|
365
|
+
|
|
366
|
+
```bash
|
|
367
|
+
slcli webapp publish dist/<project-name>/browser/ \\
|
|
368
|
+
--name "My Dashboard" --workspace Default
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
## AI-Assisted Development
|
|
372
|
+
|
|
373
|
+
See [PROMPTS.md](PROMPTS.md) for example prompts to give your AI assistant.
|
|
374
|
+
Install the systemlink-webapp skill first:
|
|
375
|
+
|
|
376
|
+
```bash
|
|
377
|
+
slcli skill install --client all
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Then ask your assistant to build features using the prompts in PROMPTS.md.
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _init_angular_template(directory: Path, force: bool) -> None:
|
|
385
|
+
"""Scaffold a Nimble Angular project with SystemLink TypeScript clients."""
|
|
386
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
387
|
+
|
|
388
|
+
prompts_file = directory / "PROMPTS.md"
|
|
389
|
+
readme_file = directory / "README.md"
|
|
390
|
+
|
|
391
|
+
# Check for existing files
|
|
392
|
+
existing = []
|
|
393
|
+
if prompts_file.exists() and not force:
|
|
394
|
+
existing.append("PROMPTS.md")
|
|
395
|
+
if readme_file.exists() and not force:
|
|
396
|
+
existing.append("README.md")
|
|
397
|
+
if existing:
|
|
398
|
+
click.echo(
|
|
399
|
+
f"✗ {', '.join(existing)} already exist(s). Use --force to overwrite.",
|
|
400
|
+
err=True,
|
|
401
|
+
)
|
|
402
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
403
|
+
|
|
404
|
+
prompts_file.write_text(_ANGULAR_PROMPTS_MD, encoding="utf-8")
|
|
405
|
+
readme_file.write_text(_ANGULAR_README_MD, encoding="utf-8")
|
|
406
|
+
|
|
407
|
+
format_success(
|
|
408
|
+
"Scaffolded Nimble Angular project",
|
|
409
|
+
{
|
|
410
|
+
"Directory": str(directory),
|
|
411
|
+
"Next steps": (
|
|
412
|
+
"1. cd " + str(directory) + "\n"
|
|
413
|
+
" 2. ng new <app-name> --no-standalone\n"
|
|
414
|
+
" 3. cd <app-name>\n"
|
|
415
|
+
" 4. npm install @ni/nimble-angular "
|
|
416
|
+
"@ni/systemlink-clients-ts\n"
|
|
417
|
+
" 5. Install AI skills: slcli skill install --client all\n"
|
|
418
|
+
" 6. Open PROMPTS.md and start building with AI"
|
|
419
|
+
),
|
|
420
|
+
},
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def register_webapp_commands(cli: Any) -> None:
|
|
425
|
+
"""Register CLI commands for SystemLink webapps."""
|
|
426
|
+
|
|
427
|
+
@cli.group()
|
|
428
|
+
def webapp() -> None: # pragma: no cover - Click wiring
|
|
429
|
+
"""Manage web applications (init/pack locally, publish/CRUD remotely)."""
|
|
430
|
+
|
|
431
|
+
@webapp.command(name="init")
|
|
432
|
+
@click.option(
|
|
433
|
+
"--directory",
|
|
434
|
+
"directory",
|
|
435
|
+
type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
|
|
436
|
+
default=Path.cwd(),
|
|
437
|
+
show_default="CWD",
|
|
438
|
+
help="Target directory to create the project in",
|
|
439
|
+
)
|
|
440
|
+
@click.option(
|
|
441
|
+
"--template",
|
|
442
|
+
"template",
|
|
443
|
+
type=click.Choice(["html", "angular"]),
|
|
444
|
+
default="html",
|
|
445
|
+
show_default=True,
|
|
446
|
+
help="Project template: html (simple page) or angular (Nimble Angular app)",
|
|
447
|
+
)
|
|
448
|
+
@click.option("--force", is_flag=True, help="Overwrite existing files")
|
|
449
|
+
def init_webapp(directory: Path, template: str, force: bool) -> None:
|
|
450
|
+
"""Scaffold a sample webapp project.
|
|
451
|
+
|
|
452
|
+
Use --template html (default) for a minimal index.html, or
|
|
453
|
+
--template angular for a Nimble Angular project with SystemLink
|
|
454
|
+
TypeScript clients, AI-ready prompts, and deployment configuration.
|
|
455
|
+
"""
|
|
456
|
+
try:
|
|
457
|
+
if template == "angular":
|
|
458
|
+
_init_angular_template(directory, force)
|
|
459
|
+
else:
|
|
460
|
+
_init_html_template(directory, force)
|
|
461
|
+
except SystemExit:
|
|
462
|
+
raise
|
|
463
|
+
except Exception as exc:
|
|
464
|
+
handle_api_error(exc)
|
|
465
|
+
|
|
466
|
+
@webapp.command(name="pack")
|
|
467
|
+
@click.argument("folder", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
|
468
|
+
@click.option(
|
|
469
|
+
"--output",
|
|
470
|
+
"output",
|
|
471
|
+
type=click.Path(file_okay=True, dir_okay=False, path_type=Path),
|
|
472
|
+
default=None,
|
|
473
|
+
help="Output .nipkg file path",
|
|
474
|
+
)
|
|
475
|
+
def pack_cmd(folder: Path, output: Optional[Path]) -> None:
|
|
476
|
+
"""Pack a folder into a .nipkg."""
|
|
477
|
+
try:
|
|
478
|
+
out = Path(output) if output else None
|
|
479
|
+
result = _pack_folder_to_nipkg(folder, out)
|
|
480
|
+
format_success("Packed folder", {"Path": str(result)})
|
|
481
|
+
except SystemExit:
|
|
482
|
+
raise
|
|
483
|
+
except Exception as exc:
|
|
484
|
+
handle_api_error(exc)
|
|
485
|
+
|
|
486
|
+
@webapp.command(name="list")
|
|
487
|
+
@click.option(
|
|
488
|
+
"--workspace", "-w", "workspace", default="", help="Filter by workspace name or ID"
|
|
489
|
+
)
|
|
490
|
+
@click.option(
|
|
491
|
+
"--filter",
|
|
492
|
+
"filter_text",
|
|
493
|
+
default="",
|
|
494
|
+
help="Case-insensitive substring match on name",
|
|
495
|
+
)
|
|
496
|
+
@click.option("--take", "take", type=int, default=25, show_default=True, help="Max rows/page")
|
|
497
|
+
@click.option(
|
|
498
|
+
"--format",
|
|
499
|
+
"format_output",
|
|
500
|
+
type=click.Choice(["table", "json"]),
|
|
501
|
+
default="table",
|
|
502
|
+
show_default=True,
|
|
503
|
+
help="Output format",
|
|
504
|
+
)
|
|
505
|
+
def list_webapps(workspace: str, filter_text: str, take: int, format_output: str) -> None:
|
|
506
|
+
"""List webapps."""
|
|
507
|
+
try:
|
|
508
|
+
|
|
509
|
+
# Validate and normalize format option
|
|
510
|
+
format_output = validate_output_format(format_output)
|
|
511
|
+
|
|
512
|
+
# Determine how many items to request from the API
|
|
513
|
+
if format_output.lower() == "json":
|
|
514
|
+
# For JSON output we want to return all matching items (no
|
|
515
|
+
# interactive pagination). Use a falsy api_take (0) to indicate
|
|
516
|
+
# "fetch all" to the helper.
|
|
517
|
+
api_take = 0
|
|
518
|
+
else:
|
|
519
|
+
api_take = take if take != 25 else 1000
|
|
520
|
+
# Use server-side query to only retrieve WebVI documents
|
|
521
|
+
base_filter = 'type == "WebVI"'
|
|
522
|
+
workspace = get_effective_workspace(workspace) or workspace
|
|
523
|
+
if workspace:
|
|
524
|
+
ws_id = get_workspace_id_with_fallback(workspace)
|
|
525
|
+
# add workspace constraint to filter
|
|
526
|
+
base_filter = f'{base_filter} and workspace == "{ws_id}"'
|
|
527
|
+
|
|
528
|
+
if filter_text:
|
|
529
|
+
# Avoid ToLower() due to backend limitations; match common case variants.
|
|
530
|
+
# Apply case transformations first, then escape each variant.
|
|
531
|
+
def _esc(s: str) -> str:
|
|
532
|
+
return s.replace("\\", "\\\\").replace('"', '\\"')
|
|
533
|
+
|
|
534
|
+
original_raw = filter_text
|
|
535
|
+
lower_raw = original_raw.lower()
|
|
536
|
+
upper_raw = original_raw.upper()
|
|
537
|
+
title_raw = original_raw.title()
|
|
538
|
+
variants = [
|
|
539
|
+
f'name.Contains("{_esc(original_raw)}")',
|
|
540
|
+
f'name.Contains("{_esc(lower_raw)}")',
|
|
541
|
+
f'name.Contains("{_esc(upper_raw)}")',
|
|
542
|
+
f'name.Contains("{_esc(title_raw)}")',
|
|
543
|
+
]
|
|
544
|
+
name_clause = f"({' or '.join(variants)})"
|
|
545
|
+
base_filter = f"({base_filter}) and ({name_clause})"
|
|
546
|
+
|
|
547
|
+
# If the user requested JSON output or did not request a specific take,
|
|
548
|
+
# fetch all matching items (using server-side paging). Otherwise, if
|
|
549
|
+
# the user specified a take and wants table output, perform interactive
|
|
550
|
+
# server-side paging: fetch a page, show total (if available), and offer
|
|
551
|
+
# to fetch the next page(s).
|
|
552
|
+
webapps: List[Dict[str, Any]] = []
|
|
553
|
+
if format_output.lower() == "json" or take == 0:
|
|
554
|
+
webapps = _query_webapps_http(base_filter, max_items=api_take)
|
|
555
|
+
else:
|
|
556
|
+
# Interactive server-side paging: show each fetched page immediately
|
|
557
|
+
# using the same display formatting so the user sees the first page
|
|
558
|
+
# before being prompted to fetch the next one.
|
|
559
|
+
cont: Optional[str] = None
|
|
560
|
+
first_page = True
|
|
561
|
+
|
|
562
|
+
# Prepare workspace map once for display name resolution
|
|
563
|
+
try:
|
|
564
|
+
workspace_map = get_workspace_map()
|
|
565
|
+
except Exception:
|
|
566
|
+
workspace_map = {}
|
|
567
|
+
|
|
568
|
+
from .universal_handlers import FilteredResponse
|
|
569
|
+
|
|
570
|
+
def _format_page_items(raw_items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
571
|
+
page_items: List[Dict[str, Any]] = []
|
|
572
|
+
for wa in raw_items:
|
|
573
|
+
if wa.get("type", "") != "WebVI":
|
|
574
|
+
continue
|
|
575
|
+
ws_name = get_workspace_display_name(wa.get("workspace", ""), workspace_map)
|
|
576
|
+
page_items.append(
|
|
577
|
+
{
|
|
578
|
+
"id": wa.get("id", ""),
|
|
579
|
+
"name": wa.get("name", ""),
|
|
580
|
+
"workspace": ws_name,
|
|
581
|
+
"type": wa.get("type", ""),
|
|
582
|
+
}
|
|
583
|
+
)
|
|
584
|
+
return page_items
|
|
585
|
+
|
|
586
|
+
while True:
|
|
587
|
+
raw_page, cont, total = _fetch_webapps_page(base_filter, take, cont)
|
|
588
|
+
|
|
589
|
+
# Format and display this page immediately
|
|
590
|
+
page_display_items = _format_page_items(raw_page)
|
|
591
|
+
|
|
592
|
+
# Track how many items we've displayed so far and, if the
|
|
593
|
+
# server provided a total, show a concise summary like:
|
|
594
|
+
# "Showing 25 of 556 webapp(s). 531 more available."
|
|
595
|
+
if "shown_count" not in locals():
|
|
596
|
+
shown_count = 0
|
|
597
|
+
shown_count += len(page_display_items)
|
|
598
|
+
|
|
599
|
+
# We now delegate printing the "Showing X of Y..." summary
|
|
600
|
+
# to the UniversalResponseHandler so behavior matches other
|
|
601
|
+
# list commands (e.g., notebooks). Supply total_count and
|
|
602
|
+
# shown_count to enable that summary.
|
|
603
|
+
first_page = False
|
|
604
|
+
|
|
605
|
+
# Use the UniversalResponseHandler to display this page (no internal pagination)
|
|
606
|
+
def formatter(item: Dict[str, Any]) -> List[str]:
|
|
607
|
+
return [
|
|
608
|
+
item.get("name", ""),
|
|
609
|
+
item.get("workspace", ""),
|
|
610
|
+
item.get("id", ""),
|
|
611
|
+
item.get("type", ""),
|
|
612
|
+
]
|
|
613
|
+
|
|
614
|
+
UniversalResponseHandler.handle_list_response(
|
|
615
|
+
resp=FilteredResponse({"webapps": page_display_items}),
|
|
616
|
+
data_key="webapps",
|
|
617
|
+
item_name="webapp",
|
|
618
|
+
format_output=format_output,
|
|
619
|
+
formatter_func=formatter,
|
|
620
|
+
headers=["Name", "Workspace", "ID", "Type"],
|
|
621
|
+
column_widths=[40, 30, 36, 16],
|
|
622
|
+
empty_message="No webapps found.",
|
|
623
|
+
enable_pagination=False,
|
|
624
|
+
page_size=take,
|
|
625
|
+
total_count=total,
|
|
626
|
+
shown_count=shown_count,
|
|
627
|
+
)
|
|
628
|
+
# Flush stdout so that the rendered table is visible before prompting
|
|
629
|
+
try:
|
|
630
|
+
sys.stdout.flush()
|
|
631
|
+
except Exception:
|
|
632
|
+
pass
|
|
633
|
+
|
|
634
|
+
# Accumulate raw items so callers that expect an aggregated
|
|
635
|
+
# list (or further processing) can see all fetched pages.
|
|
636
|
+
webapps.extend(raw_page)
|
|
637
|
+
|
|
638
|
+
# If there's no continuation token, we're done
|
|
639
|
+
if not cont:
|
|
640
|
+
break
|
|
641
|
+
|
|
642
|
+
# Ask the user if they want to fetch the next set
|
|
643
|
+
if not questionary.confirm("Show next set of results?", default=True).ask():
|
|
644
|
+
break
|
|
645
|
+
|
|
646
|
+
# We've already displayed each page interactively above; avoid
|
|
647
|
+
# rendering a second, aggregated table below. Return early.
|
|
648
|
+
return
|
|
649
|
+
|
|
650
|
+
# Map workspace ids
|
|
651
|
+
try:
|
|
652
|
+
workspace_map = get_workspace_map()
|
|
653
|
+
except Exception:
|
|
654
|
+
workspace_map = {}
|
|
655
|
+
|
|
656
|
+
items: List[Dict[str, Any]] = []
|
|
657
|
+
for wa in webapps:
|
|
658
|
+
# Only include WebVI documents
|
|
659
|
+
if wa.get("type", "") != "WebVI":
|
|
660
|
+
continue
|
|
661
|
+
ws_name = get_workspace_display_name(wa.get("workspace", ""), workspace_map)
|
|
662
|
+
items.append(
|
|
663
|
+
{
|
|
664
|
+
"id": wa.get("id", ""),
|
|
665
|
+
"name": wa.get("name", ""),
|
|
666
|
+
"workspace": ws_name,
|
|
667
|
+
"type": wa.get("type", ""),
|
|
668
|
+
}
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
from .universal_handlers import FilteredResponse
|
|
672
|
+
|
|
673
|
+
def formatter(item: Dict[str, Any]) -> List[str]:
|
|
674
|
+
return [
|
|
675
|
+
item.get("name", ""),
|
|
676
|
+
item.get("workspace", ""),
|
|
677
|
+
item.get("id", ""),
|
|
678
|
+
item.get("type", ""),
|
|
679
|
+
]
|
|
680
|
+
|
|
681
|
+
UniversalResponseHandler.handle_list_response(
|
|
682
|
+
resp=FilteredResponse({"webapps": items}),
|
|
683
|
+
data_key="webapps",
|
|
684
|
+
item_name="webapp",
|
|
685
|
+
format_output=format_output,
|
|
686
|
+
formatter_func=formatter,
|
|
687
|
+
headers=["Name", "Workspace", "ID", "Type"],
|
|
688
|
+
column_widths=[40, 30, 36, 16],
|
|
689
|
+
empty_message="No webapps found.",
|
|
690
|
+
enable_pagination=True,
|
|
691
|
+
page_size=take,
|
|
692
|
+
)
|
|
693
|
+
except Exception as exc:
|
|
694
|
+
handle_api_error(exc)
|
|
695
|
+
|
|
696
|
+
@webapp.command(name="get")
|
|
697
|
+
@click.option("--id", "-i", "webapp_id", required=True, help="Webapp ID to retrieve")
|
|
698
|
+
@click.option(
|
|
699
|
+
"--format",
|
|
700
|
+
"format_output",
|
|
701
|
+
type=click.Choice(["table", "json"]),
|
|
702
|
+
default="table",
|
|
703
|
+
show_default=True,
|
|
704
|
+
)
|
|
705
|
+
def get_webapp(webapp_id: str, format_output: str) -> None:
|
|
706
|
+
"""Show webapp metadata."""
|
|
707
|
+
try:
|
|
708
|
+
base = _get_webapp_base_url()
|
|
709
|
+
resp = requests.get(
|
|
710
|
+
f"{base}/webapps/{webapp_id}",
|
|
711
|
+
headers=get_headers("application/json"),
|
|
712
|
+
verify=get_ssl_verify(),
|
|
713
|
+
)
|
|
714
|
+
resp.raise_for_status()
|
|
715
|
+
data = resp.json()
|
|
716
|
+
if data.get("type", "") != "WebVI":
|
|
717
|
+
click.echo("✗ Webapp is not a WebVI document.", err=True)
|
|
718
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
719
|
+
UniversalResponseHandler.handle_get_response(resp, "webapp", format_output)
|
|
720
|
+
except Exception as exc:
|
|
721
|
+
handle_api_error(exc)
|
|
722
|
+
|
|
723
|
+
@webapp.command(name="delete")
|
|
724
|
+
@click.option("--id", "-i", "webapp_id", required=True, help="Webapp ID to delete")
|
|
725
|
+
@click.confirmation_option(prompt="Are you sure you want to delete this webapp?")
|
|
726
|
+
def delete_webapp(webapp_id: str) -> None:
|
|
727
|
+
"""Delete a webapp."""
|
|
728
|
+
from .utils import check_readonly_mode
|
|
729
|
+
|
|
730
|
+
check_readonly_mode("delete a webapp")
|
|
731
|
+
|
|
732
|
+
try:
|
|
733
|
+
base = _get_webapp_base_url()
|
|
734
|
+
resp = requests.delete(
|
|
735
|
+
f"{base}/webapps/{webapp_id}", headers=get_headers(), verify=get_ssl_verify()
|
|
736
|
+
)
|
|
737
|
+
# Validate response and type if possible
|
|
738
|
+
try:
|
|
739
|
+
data = resp.json()
|
|
740
|
+
if data.get("type", "") != "WebVI":
|
|
741
|
+
click.echo("✗ Webapp is not a WebVI document.", err=True)
|
|
742
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
743
|
+
except Exception:
|
|
744
|
+
# If no JSON, continue and let handler report success/failure
|
|
745
|
+
pass
|
|
746
|
+
|
|
747
|
+
# Use UniversalResponseHandler to print a friendly message
|
|
748
|
+
UniversalResponseHandler.handle_delete_response(resp, "webapp", item_count=1)
|
|
749
|
+
except Exception as exc:
|
|
750
|
+
handle_api_error(exc)
|
|
751
|
+
|
|
752
|
+
@webapp.command(name="open")
|
|
753
|
+
@click.option("--id", "-i", "webapp_id", required=True, help="Webapp ID to open in browser")
|
|
754
|
+
def open_webapp(webapp_id: str) -> None:
|
|
755
|
+
"""Open a webapp in the browser."""
|
|
756
|
+
import webbrowser
|
|
757
|
+
from urllib.parse import quote
|
|
758
|
+
|
|
759
|
+
try:
|
|
760
|
+
base = _get_webapp_base_url()
|
|
761
|
+
resp = requests.get(
|
|
762
|
+
f"{base}/webapps/{webapp_id}",
|
|
763
|
+
headers=get_headers("application/json"),
|
|
764
|
+
verify=get_ssl_verify(),
|
|
765
|
+
)
|
|
766
|
+
resp.raise_for_status()
|
|
767
|
+
data = resp.json()
|
|
768
|
+
# Try to construct the public webapps URL which looks like:
|
|
769
|
+
# https://<host>/webapps/app/<WorkspaceName>/<Name>
|
|
770
|
+
name = data.get("name")
|
|
771
|
+
workspace_id = data.get("workspace")
|
|
772
|
+
|
|
773
|
+
# Resolve workspace display name
|
|
774
|
+
try:
|
|
775
|
+
workspace_map = get_workspace_map()
|
|
776
|
+
except Exception:
|
|
777
|
+
workspace_map = {}
|
|
778
|
+
|
|
779
|
+
workspace_name = get_workspace_display_name(workspace_id or "", workspace_map)
|
|
780
|
+
|
|
781
|
+
# If we have both a workspace name and webapp name, build the friendly URL
|
|
782
|
+
if workspace_name and name:
|
|
783
|
+
# Prefer explicit web UI URL, otherwise derive from API URL
|
|
784
|
+
web_base = get_web_url()
|
|
785
|
+
# Ensure no trailing slash on base
|
|
786
|
+
web_base = web_base.rstrip("/")
|
|
787
|
+
|
|
788
|
+
app_path = f"/webapps/app/{quote(workspace_name)}/{quote(name)}"
|
|
789
|
+
app_url = f"{web_base}{app_path}"
|
|
790
|
+
webbrowser.open(app_url)
|
|
791
|
+
click.echo(f"✓ Opening: {app_url}")
|
|
792
|
+
return
|
|
793
|
+
|
|
794
|
+
# Fallback: try any embed/url/interface property
|
|
795
|
+
props = data.get("properties", {}) or {}
|
|
796
|
+
url = props.get("embedLocation") or props.get("url") or props.get("interface")
|
|
797
|
+
if url:
|
|
798
|
+
webbrowser.open(url)
|
|
799
|
+
click.echo(f"✓ Opening: {url}")
|
|
800
|
+
return
|
|
801
|
+
|
|
802
|
+
# Last-resort fallback: open content endpoint (may require auth)
|
|
803
|
+
content_url = f"{base}/webapps/{webapp_id}/content"
|
|
804
|
+
webbrowser.open(content_url)
|
|
805
|
+
click.echo("✓ Opening content endpoint (may require authentication in browser)")
|
|
806
|
+
except Exception as exc:
|
|
807
|
+
handle_api_error(exc)
|
|
808
|
+
|
|
809
|
+
@webapp.command(name="publish")
|
|
810
|
+
@click.argument(
|
|
811
|
+
"source",
|
|
812
|
+
type=click.Path(exists=True, path_type=Path),
|
|
813
|
+
)
|
|
814
|
+
@click.option(
|
|
815
|
+
"--id",
|
|
816
|
+
"-i",
|
|
817
|
+
"webapp_id",
|
|
818
|
+
default="",
|
|
819
|
+
help="Existing webapp ID to upload content to",
|
|
820
|
+
)
|
|
821
|
+
@click.option(
|
|
822
|
+
"--name",
|
|
823
|
+
"-n",
|
|
824
|
+
"name",
|
|
825
|
+
default="",
|
|
826
|
+
help="Create a new webapp with this name before publishing",
|
|
827
|
+
)
|
|
828
|
+
@click.option(
|
|
829
|
+
"--workspace",
|
|
830
|
+
"-w",
|
|
831
|
+
"workspace",
|
|
832
|
+
default="Default",
|
|
833
|
+
help="Workspace name or ID for new webapp",
|
|
834
|
+
)
|
|
835
|
+
def publish(source: Path, webapp_id: str, name: str, workspace: str) -> None:
|
|
836
|
+
"""Publish a .nipkg (or folder) to the WebApp service.
|
|
837
|
+
|
|
838
|
+
SOURCE may be a .nipkg file or a folder. If a folder is provided it will be
|
|
839
|
+
packed into a .nipkg archive prior to upload.
|
|
840
|
+
"""
|
|
841
|
+
from .utils import check_readonly_mode
|
|
842
|
+
|
|
843
|
+
check_readonly_mode("publish a web application")
|
|
844
|
+
|
|
845
|
+
tmp_file: Optional[Path] = None
|
|
846
|
+
try:
|
|
847
|
+
# If folder, pack it first using a context-managed TemporaryDirectory
|
|
848
|
+
if source.is_dir():
|
|
849
|
+
click.echo("Packing folder into .nipkg...")
|
|
850
|
+
# Keep the TemporaryDirectory alive for the duration of the
|
|
851
|
+
# metadata creation and upload so the packaged file remains
|
|
852
|
+
# available. The context manager will ensure cleanup afterwards.
|
|
853
|
+
with tempfile.TemporaryDirectory() as _tmp_dir:
|
|
854
|
+
tmp_dir = Path(_tmp_dir)
|
|
855
|
+
suggested = tmp_dir / (sanitize_filename(source.name) + ".nipkg")
|
|
856
|
+
packaged = _pack_folder_to_nipkg(source, suggested)
|
|
857
|
+
tmp_file = packaged
|
|
858
|
+
|
|
859
|
+
# If no webapp id provided create webapp metadata using name
|
|
860
|
+
base = _get_webapp_base_url()
|
|
861
|
+
if not webapp_id:
|
|
862
|
+
if not name:
|
|
863
|
+
click.echo("✗ Must provide --id or --name to publish.", err=True)
|
|
864
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
865
|
+
ws_id = get_workspace_id_with_fallback(
|
|
866
|
+
get_effective_workspace(workspace) or workspace
|
|
867
|
+
)
|
|
868
|
+
payload = {
|
|
869
|
+
"name": name,
|
|
870
|
+
"type": "WebVI",
|
|
871
|
+
"workspace": ws_id,
|
|
872
|
+
"policyIds": [],
|
|
873
|
+
"properties": {},
|
|
874
|
+
}
|
|
875
|
+
resp_create = requests.post(
|
|
876
|
+
f"{base}/webapps",
|
|
877
|
+
headers=get_headers("application/json"),
|
|
878
|
+
json=payload,
|
|
879
|
+
verify=get_ssl_verify(),
|
|
880
|
+
)
|
|
881
|
+
resp_create.raise_for_status()
|
|
882
|
+
created = resp_create.json()
|
|
883
|
+
webapp_id = created.get("id")
|
|
884
|
+
if not webapp_id:
|
|
885
|
+
click.echo("✗ Failed to create webapp metadata.", err=True)
|
|
886
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
887
|
+
click.echo(f"✓ Created webapp metadata: {webapp_id}")
|
|
888
|
+
|
|
889
|
+
# Upload content (binary). Use requests.put because content may be binary.
|
|
890
|
+
with open(packaged, "rb") as f: # type: ignore[arg-type]
|
|
891
|
+
data = f.read()
|
|
892
|
+
|
|
893
|
+
upload_headers = get_headers("application/octet-stream")
|
|
894
|
+
url = f"{base}/webapps/{webapp_id}/content"
|
|
895
|
+
resp = requests.put(
|
|
896
|
+
url, headers=upload_headers, data=data, verify=get_ssl_verify()
|
|
897
|
+
)
|
|
898
|
+
if resp.status_code in (200, 201, 204):
|
|
899
|
+
format_success(
|
|
900
|
+
"Published webapp content",
|
|
901
|
+
{"Webapp ID": webapp_id, "Source": str(packaged)},
|
|
902
|
+
)
|
|
903
|
+
else:
|
|
904
|
+
# Try to show body message
|
|
905
|
+
try:
|
|
906
|
+
click.echo(resp.text, err=True)
|
|
907
|
+
except Exception:
|
|
908
|
+
pass
|
|
909
|
+
click.echo("✗ Failed to upload content.", err=True)
|
|
910
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
911
|
+
else:
|
|
912
|
+
packaged = source
|
|
913
|
+
|
|
914
|
+
# If no webapp id provided create webapp metadata using name
|
|
915
|
+
base = _get_webapp_base_url()
|
|
916
|
+
if not webapp_id:
|
|
917
|
+
if not name:
|
|
918
|
+
click.echo("✗ Must provide --id or --name to publish.", err=True)
|
|
919
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
920
|
+
ws_id = get_workspace_id_with_fallback(
|
|
921
|
+
get_effective_workspace(workspace) or workspace
|
|
922
|
+
)
|
|
923
|
+
payload = {
|
|
924
|
+
"name": name,
|
|
925
|
+
"type": "WebVI",
|
|
926
|
+
"workspace": ws_id,
|
|
927
|
+
"policyIds": [],
|
|
928
|
+
"properties": {},
|
|
929
|
+
}
|
|
930
|
+
resp_create = requests.post(
|
|
931
|
+
f"{base}/webapps",
|
|
932
|
+
headers=get_headers("application/json"),
|
|
933
|
+
json=payload,
|
|
934
|
+
verify=get_ssl_verify(),
|
|
935
|
+
)
|
|
936
|
+
resp_create.raise_for_status()
|
|
937
|
+
created = resp_create.json()
|
|
938
|
+
webapp_id = created.get("id")
|
|
939
|
+
if not webapp_id:
|
|
940
|
+
click.echo("✗ Failed to create webapp metadata.", err=True)
|
|
941
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
942
|
+
click.echo(f"✓ Created webapp metadata: {webapp_id}")
|
|
943
|
+
|
|
944
|
+
# Upload content (binary). Use requests.put because content may be binary.
|
|
945
|
+
with open(packaged, "rb") as f: # type: ignore[arg-type]
|
|
946
|
+
data = f.read()
|
|
947
|
+
|
|
948
|
+
upload_headers = get_headers("application/octet-stream")
|
|
949
|
+
url = f"{base}/webapps/{webapp_id}/content"
|
|
950
|
+
resp = requests.put(url, headers=upload_headers, data=data, verify=get_ssl_verify())
|
|
951
|
+
if resp.status_code in (200, 201, 204):
|
|
952
|
+
format_success(
|
|
953
|
+
"Published webapp content",
|
|
954
|
+
{"Webapp ID": webapp_id, "Source": str(packaged)},
|
|
955
|
+
)
|
|
956
|
+
else:
|
|
957
|
+
# Try to show body message
|
|
958
|
+
try:
|
|
959
|
+
click.echo(resp.text, err=True)
|
|
960
|
+
except Exception:
|
|
961
|
+
pass
|
|
962
|
+
click.echo("✗ Failed to upload content.", err=True)
|
|
963
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
964
|
+
|
|
965
|
+
# No further action here; upload is handled in the branches above
|
|
966
|
+
# (inside the TemporaryDirectory for folders, or in the file branch).
|
|
967
|
+
|
|
968
|
+
except SystemExit:
|
|
969
|
+
raise
|
|
970
|
+
except Exception as exc:
|
|
971
|
+
handle_api_error(exc)
|
|
972
|
+
finally:
|
|
973
|
+
# Cleanup temporary packaged file if we created one and it still exists.
|
|
974
|
+
# In the common case the TemporaryDirectory context manager removes the
|
|
975
|
+
# file for us when it exits; however, if something unusual happened and
|
|
976
|
+
# the file remains, attempt to remove it here.
|
|
977
|
+
try:
|
|
978
|
+
if tmp_file and tmp_file.exists():
|
|
979
|
+
tmp_file.unlink()
|
|
980
|
+
except Exception:
|
|
981
|
+
pass
|