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.
Files changed (74) hide show
  1. slcli/__init__.py +1 -0
  2. slcli/__main__.py +23 -0
  3. slcli/_version.py +4 -0
  4. slcli/asset_click.py +1289 -0
  5. slcli/cli_formatters.py +218 -0
  6. slcli/cli_utils.py +504 -0
  7. slcli/comment_click.py +602 -0
  8. slcli/completion_click.py +418 -0
  9. slcli/config.py +81 -0
  10. slcli/config_click.py +498 -0
  11. slcli/dff_click.py +979 -0
  12. slcli/dff_decorators.py +24 -0
  13. slcli/example_click.py +404 -0
  14. slcli/example_loader.py +274 -0
  15. slcli/example_provisioner.py +2777 -0
  16. slcli/examples/README.md +134 -0
  17. slcli/examples/_schema/schema-v1.0.json +169 -0
  18. slcli/examples/demo-complete-workflow/README.md +323 -0
  19. slcli/examples/demo-complete-workflow/config.yaml +638 -0
  20. slcli/examples/demo-test-plans/README.md +132 -0
  21. slcli/examples/demo-test-plans/config.yaml +154 -0
  22. slcli/examples/exercise-5-1-parametric-insights/README.md +101 -0
  23. slcli/examples/exercise-5-1-parametric-insights/config.yaml +1589 -0
  24. slcli/examples/exercise-7-1-test-plans/README.md +93 -0
  25. slcli/examples/exercise-7-1-test-plans/config.yaml +323 -0
  26. slcli/examples/spec-compliance-notebooks/README.md +140 -0
  27. slcli/examples/spec-compliance-notebooks/config.yaml +112 -0
  28. slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +1553 -0
  29. slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +1577 -0
  30. slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +912 -0
  31. slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
  32. slcli/feed_click.py +892 -0
  33. slcli/file_click.py +932 -0
  34. slcli/function_click.py +1400 -0
  35. slcli/function_templates.py +85 -0
  36. slcli/main.py +406 -0
  37. slcli/mcp_click.py +269 -0
  38. slcli/mcp_server.py +748 -0
  39. slcli/notebook_click.py +1770 -0
  40. slcli/platform.py +345 -0
  41. slcli/policy_click.py +679 -0
  42. slcli/policy_utils.py +411 -0
  43. slcli/profiles.py +411 -0
  44. slcli/response_handlers.py +359 -0
  45. slcli/routine_click.py +763 -0
  46. slcli/skill_click.py +253 -0
  47. slcli/skills/slcli/SKILL.md +713 -0
  48. slcli/skills/slcli/references/analysis-recipes.md +474 -0
  49. slcli/skills/slcli/references/filtering.md +236 -0
  50. slcli/skills/systemlink-webapp/SKILL.md +744 -0
  51. slcli/skills/systemlink-webapp/references/deployment.md +123 -0
  52. slcli/skills/systemlink-webapp/references/nimble-angular.md +380 -0
  53. slcli/skills/systemlink-webapp/references/systemlink-services.md +192 -0
  54. slcli/ssl_trust.py +93 -0
  55. slcli/system_click.py +2216 -0
  56. slcli/table_utils.py +124 -0
  57. slcli/tag_click.py +794 -0
  58. slcli/templates_click.py +599 -0
  59. slcli/testmonitor_click.py +1667 -0
  60. slcli/universal_handlers.py +305 -0
  61. slcli/user_click.py +1218 -0
  62. slcli/utils.py +832 -0
  63. slcli/web_editor.py +295 -0
  64. slcli/webapp_click.py +981 -0
  65. slcli/workflow_preview.py +287 -0
  66. slcli/workflows_click.py +988 -0
  67. slcli/workitem_click.py +2258 -0
  68. slcli/workspace_click.py +576 -0
  69. slcli/workspace_utils.py +206 -0
  70. systemlink_cli-1.3.1.dist-info/METADATA +20 -0
  71. systemlink_cli-1.3.1.dist-info/RECORD +74 -0
  72. systemlink_cli-1.3.1.dist-info/WHEEL +4 -0
  73. systemlink_cli-1.3.1.dist-info/entry_points.txt +7 -0
  74. 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