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/feed_click.py ADDED
@@ -0,0 +1,892 @@
1
+ """CLI commands for managing NI Package Manager feeds.
2
+
3
+ This module provides commands for managing package feeds in SystemLink,
4
+ supporting both SLE (/nifeed/v1) and SLS (/nirepo/v1) APIs.
5
+ """
6
+
7
+ import json
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ import click
14
+ import questionary
15
+ import requests
16
+
17
+ from .cli_utils import validate_output_format
18
+ from .platform import PLATFORM_SLS, get_platform
19
+ from .universal_handlers import FilteredResponse, UniversalResponseHandler
20
+ from .utils import (
21
+ ExitCodes,
22
+ format_success,
23
+ get_base_url,
24
+ get_workspace_id_with_fallback,
25
+ handle_api_error,
26
+ make_api_request,
27
+ )
28
+ from .workspace_utils import get_effective_workspace, get_workspace_display_name, get_workspace_map
29
+
30
+
31
+ class JobPollingError(Exception):
32
+ """Raised when a job fails or cannot be retrieved."""
33
+
34
+
35
+ class JobNotFoundError(Exception):
36
+ """Raised when a job is not found."""
37
+
38
+
39
+ class PackageUploadError(Exception):
40
+ """Raised when a package upload response is missing required identifiers."""
41
+
42
+
43
+ def _get_feed_base_url() -> str:
44
+ """Get the base URL for feed API.
45
+
46
+ Returns platform-specific URL:
47
+ - SLS (SystemLink Server): /nirepo/v1
48
+ - SLE (SystemLink Enterprise): /nifeed/v1
49
+ """
50
+ if get_platform() == PLATFORM_SLS:
51
+ return f"{get_base_url()}/nirepo/v1"
52
+ return f"{get_base_url()}/nifeed/v1"
53
+
54
+
55
+ def _normalize_platform(platform: str) -> str:
56
+ """Normalize platform value based on current SystemLink platform.
57
+
58
+ SLE uses uppercase (WINDOWS, NI_LINUX_RT), SLS uses lowercase (windows, ni-linux-rt).
59
+ CLI accepts either case and normalizes appropriately.
60
+
61
+ Args:
62
+ platform: Platform string (case-insensitive)
63
+
64
+ Returns:
65
+ Normalized platform string for the current API
66
+ """
67
+ platform_lower = platform.lower().replace("_", "-")
68
+ is_sls = get_platform() == PLATFORM_SLS
69
+
70
+ if platform_lower in ("windows", "win"):
71
+ return "windows" if is_sls else "WINDOWS"
72
+ elif platform_lower in ("ni-linux-rt", "ni_linux_rt", "linux-rt", "linux_rt", "linuxrt"):
73
+ return "ni-linux-rt" if is_sls else "NI_LINUX_RT"
74
+ else:
75
+ # Return as-is for unknown platforms
76
+ return platform.lower() if is_sls else platform.upper()
77
+
78
+
79
+ def _get_feed_name_field() -> str:
80
+ """Get the feed name field based on platform.
81
+
82
+ SLS uses 'feedName', SLE uses 'name'.
83
+ """
84
+ return "feedName" if get_platform() == PLATFORM_SLS else "name"
85
+
86
+
87
+ def _extract_feed_name(feed: Dict[str, Any]) -> str:
88
+ """Extract feed name from response, handling both SLE and SLS formats."""
89
+ return feed.get("name") or feed.get("feedName") or "Unknown"
90
+
91
+
92
+ def _wait_for_job(
93
+ job_id: str, timeout: int = 300, poll_interval: int = 2, feed_id: Optional[str] = None
94
+ ) -> Dict[str, Any]:
95
+ """Wait for an async job to complete.
96
+
97
+ Args:
98
+ job_id: The job ID to wait for
99
+ timeout: Maximum time to wait in seconds
100
+ poll_interval: Time between status checks in seconds
101
+ feed_id: Optional feed ID for feed-specific jobs
102
+
103
+ Returns:
104
+ Final job status dictionary
105
+
106
+ Raises:
107
+ TimeoutError: If job doesn't complete within timeout
108
+ Exception: If job fails
109
+ """
110
+ base_url = _get_feed_base_url()
111
+ if feed_id:
112
+ url = f"{base_url}/feeds/{feed_id}/jobs/{job_id}"
113
+ else:
114
+ url = f"{base_url}/jobs/{job_id}"
115
+ start_time = time.time()
116
+
117
+ while True:
118
+ elapsed = time.time() - start_time
119
+ if elapsed > timeout:
120
+ raise TimeoutError(f"Job {job_id} did not complete within {timeout} seconds")
121
+
122
+ try:
123
+ resp = make_api_request("GET", url, handle_errors=False)
124
+ data = resp.json()
125
+
126
+ # Handle both response formats: {job: {...}} or direct {...}
127
+ job = data.get("job", data)
128
+ status = job.get("status", "").upper()
129
+
130
+ if status in ("SUCCESS", "SUCCEEDED", "COMPLETED"):
131
+ return job
132
+ if status in ("FAILED", "ERROR"):
133
+ error = job.get("error", {})
134
+ error_msg = error.get("message", "Unknown error")
135
+ raise JobPollingError(f"Job failed: {error_msg}")
136
+ if status in ("COMPLETED_WITH_ERROR",):
137
+ # Partial success - return but with warning
138
+ click.echo("⚠️ Job completed with errors", err=True)
139
+ return job
140
+
141
+ # Still processing - wait and retry
142
+ time.sleep(poll_interval)
143
+
144
+ except requests.exceptions.HTTPError as exc:
145
+ if exc.response is not None and exc.response.status_code == 404:
146
+ raise JobNotFoundError(f"Job {job_id} not found")
147
+ raise
148
+ except requests.RequestException:
149
+ raise
150
+
151
+
152
+ def _list_feeds(
153
+ platform: Optional[str] = None, workspace_id: Optional[str] = None
154
+ ) -> List[Dict[str, Any]]:
155
+ """List all feeds, optionally filtered by platform and workspace.
156
+
157
+ Args:
158
+ platform: Optional platform filter (windows or ni-linux-rt)
159
+ workspace_id: Optional workspace ID filter
160
+
161
+ Returns:
162
+ List of feed dictionaries
163
+ """
164
+ base_url = _get_feed_base_url()
165
+
166
+ # Build query parameters
167
+ params = []
168
+ if platform:
169
+ normalized_platform = _normalize_platform(platform)
170
+ params.append(f"platform={normalized_platform}")
171
+ if workspace_id:
172
+ params.append(f"workspace={workspace_id}")
173
+
174
+ url = f"{base_url}/feeds"
175
+ if params:
176
+ url += "?" + "&".join(params)
177
+
178
+ try:
179
+ resp = make_api_request("GET", url)
180
+ data = resp.json()
181
+ return data.get("feeds", [])
182
+ except Exception as exc:
183
+ handle_api_error(exc)
184
+ return []
185
+
186
+
187
+ def _get_feed(feed_id: str) -> Dict[str, Any]:
188
+ """Get a single feed by ID.
189
+
190
+ Args:
191
+ feed_id: Feed ID
192
+
193
+ Returns:
194
+ Feed dictionary
195
+ """
196
+ base_url = _get_feed_base_url()
197
+ url = f"{base_url}/feeds/{feed_id}"
198
+
199
+ resp = make_api_request("GET", url)
200
+ data = resp.json()
201
+ # Handle both response formats: {feed: {...}} or direct {...}
202
+ return data.get("feed", data)
203
+
204
+
205
+ def _create_feed(
206
+ name: str, platform: str, description: Optional[str] = None, workspace: Optional[str] = None
207
+ ) -> Dict[str, Any]:
208
+ """Create a new feed.
209
+
210
+ Args:
211
+ name: Feed name
212
+ platform: Target platform (windows or ni-linux-rt)
213
+ description: Optional feed description
214
+ workspace: Optional workspace ID
215
+
216
+ Returns:
217
+ Response dictionary containing job ID or feed details
218
+ """
219
+ base_url = _get_feed_base_url()
220
+ url = f"{base_url}/feeds"
221
+
222
+ # Build payload with platform-specific field names
223
+ name_field = _get_feed_name_field()
224
+ payload: Dict[str, Any] = {
225
+ name_field: name,
226
+ "platform": _normalize_platform(platform),
227
+ }
228
+
229
+ if description:
230
+ payload["description"] = description
231
+ if workspace:
232
+ payload["workspace"] = workspace
233
+
234
+ resp = make_api_request("POST", url, payload=payload)
235
+ return resp.json()
236
+
237
+
238
+ def _delete_feed(feed_id: str) -> str:
239
+ """Delete a feed.
240
+
241
+ Args:
242
+ feed_id: Feed ID to delete
243
+
244
+ Returns:
245
+ Job ID for the async operation
246
+ """
247
+ base_url = _get_feed_base_url()
248
+ url = f"{base_url}/feeds/{feed_id}"
249
+
250
+ resp = make_api_request("DELETE", url)
251
+
252
+ if resp.status_code == 204 or not resp.content:
253
+ return ""
254
+
255
+ data = resp.json()
256
+ return data.get("jobId", data.get("job", {}).get("id", ""))
257
+
258
+
259
+ def _replicate_feed(
260
+ name: str,
261
+ platform: str,
262
+ source_url: str,
263
+ description: Optional[str] = None,
264
+ workspace: Optional[str] = None,
265
+ ) -> Dict[str, Any]:
266
+ """Replicate a feed from an external source.
267
+
268
+ Args:
269
+ name: Name for the new feed
270
+ platform: Target platform
271
+ source_url: URL of the source feed to replicate
272
+ description: Optional description
273
+ workspace: Optional workspace ID
274
+
275
+ Returns:
276
+ Response dictionary containing job ID or feed details
277
+ """
278
+ base_url = _get_feed_base_url()
279
+ url = f"{base_url}/replicate-feed"
280
+
281
+ # Build payload
282
+ name_field = _get_feed_name_field()
283
+ payload: Dict[str, Any] = {
284
+ name_field: name,
285
+ "platform": _normalize_platform(platform),
286
+ "urls": [source_url],
287
+ }
288
+
289
+ if description:
290
+ payload["description"] = description
291
+ if workspace:
292
+ payload["workspace"] = workspace
293
+
294
+ resp = make_api_request("POST", url, payload=payload)
295
+ return resp.json()
296
+
297
+
298
+ def _list_packages(feed_id: str) -> List[Dict[str, Any]]:
299
+ """List all packages in a feed.
300
+
301
+ Args:
302
+ feed_id: Feed ID
303
+
304
+ Returns:
305
+ List of package dictionaries
306
+ """
307
+ base_url = _get_feed_base_url()
308
+ url = f"{base_url}/feeds/{feed_id}/packages"
309
+
310
+ resp = make_api_request("GET", url)
311
+ data = resp.json()
312
+ return data.get("packages", [])
313
+
314
+
315
+ def _upload_package_sle(feed_id: str, file_path: str, overwrite: bool = False) -> Dict[str, Any]:
316
+ """Upload a package to a feed on SLE.
317
+
318
+ SLE uploads directly to the feed.
319
+
320
+ Args:
321
+ feed_id: Feed ID
322
+ file_path: Path to the package file
323
+ overwrite: Whether to overwrite existing package
324
+
325
+ Returns:
326
+ Response dictionary containing job ID or package details
327
+ """
328
+ base_url = _get_feed_base_url()
329
+ url = f"{base_url}/feeds/{feed_id}/packages"
330
+ if overwrite:
331
+ url += "?shouldOverwrite=true"
332
+
333
+ file_name = Path(file_path).name
334
+ with open(file_path, "rb") as f:
335
+ files = {"package": (file_name, f, "application/octet-stream")}
336
+ resp = make_api_request("POST", url, files=files)
337
+
338
+ return resp.json()
339
+
340
+
341
+ def _upload_package_sls(feed_id: str, file_path: str, overwrite: bool = False) -> Dict[str, Any]:
342
+ """Upload a package on SLS.
343
+
344
+ SLS uploads to the package pool first, then adds reference to feed.
345
+
346
+ Note: Even when the CLI command is invoked without ``--wait``, this helper must block on the
347
+ initial upload job to obtain the package ID before the feed association step can be queued.
348
+
349
+ Args:
350
+ feed_id: Feed ID
351
+ file_path: Path to the package file
352
+ overwrite: Whether to overwrite existing package
353
+
354
+ Returns:
355
+ Response dictionary containing job ID and package ID
356
+ """
357
+ base_url = _get_feed_base_url()
358
+
359
+ # Step 1: Upload to package pool
360
+ upload_url = f"{base_url}/upload-packages"
361
+ if overwrite:
362
+ upload_url += "?shouldOverwrite=true"
363
+
364
+ file_name = Path(file_path).name
365
+ with open(file_path, "rb") as f:
366
+ files = {"package": (file_name, f, "application/octet-stream")}
367
+ resp = make_api_request("POST", upload_url, files=files)
368
+
369
+ data = resp.json()
370
+ job_ids = data.get("jobIds", [])
371
+
372
+ if not job_ids:
373
+ raise PackageUploadError("No job ID returned from package upload")
374
+
375
+ # Wait for upload to complete to get package ID
376
+ job = _wait_for_job(job_ids[0], timeout=300)
377
+ package_id = job.get("resourceId")
378
+
379
+ if not package_id:
380
+ raise PackageUploadError("Package upload completed but no package ID returned")
381
+
382
+ # Step 2: Add package reference to feed
383
+ ref_url = f"{base_url}/feeds/{feed_id}/add-package-references"
384
+ ref_payload = {"packageReferences": [package_id]}
385
+ resp = make_api_request("POST", ref_url, payload=ref_payload)
386
+
387
+ data = resp.json()
388
+ # Inject packageId into response for CLI usage
389
+ result = data.copy()
390
+ result["packageId"] = package_id
391
+ return result
392
+
393
+
394
+ def _upload_package(feed_id: str, file_path: str, overwrite: bool = False) -> Dict[str, Any]:
395
+ """Upload a package to a feed.
396
+
397
+ Routes to platform-specific implementation.
398
+
399
+ Args:
400
+ feed_id: Feed ID
401
+ file_path: Path to the package file
402
+ overwrite: Whether to overwrite existing package
403
+
404
+ Returns:
405
+ Response dictionary containing job ID or package details
406
+ """
407
+ if get_platform() == PLATFORM_SLS:
408
+ return _upload_package_sls(feed_id, file_path, overwrite)
409
+ return _upload_package_sle(feed_id, file_path, overwrite)
410
+
411
+
412
+ def _delete_package(package_id: str) -> str:
413
+ """Delete a package.
414
+
415
+ Args:
416
+ package_id: Package ID to delete
417
+
418
+ Returns:
419
+ Job ID for the async operation
420
+ """
421
+ base_url = _get_feed_base_url()
422
+ url = f"{base_url}/delete-packages"
423
+ payload = {"packageIds": [package_id]}
424
+
425
+ resp = make_api_request("POST", url, payload=payload)
426
+
427
+ if resp.status_code == 204 or not resp.content:
428
+ return ""
429
+
430
+ data = resp.json()
431
+ return data.get("jobId", data.get("job", {}).get("id", ""))
432
+
433
+
434
+ def register_feed_commands(cli: Any) -> None:
435
+ """Register the 'feed' command group and its subcommands."""
436
+
437
+ @cli.group()
438
+ def feed() -> None:
439
+ """Manage NI Package Manager feeds and their packages.
440
+
441
+ Feeds are package repositories used by NI Package Manager to install
442
+ software on test systems. Supports Windows (.nipkg) and NI Linux RT
443
+ (.ipk/.deb) platforms.
444
+ """
445
+ pass
446
+
447
+ # -------------------------------------------------------------------------
448
+ # Feed management commands
449
+ # -------------------------------------------------------------------------
450
+
451
+ @feed.command(name="list")
452
+ @click.option(
453
+ "--format",
454
+ "-f",
455
+ "format_",
456
+ type=click.Choice(["table", "json"]),
457
+ default="table",
458
+ help="Output format",
459
+ )
460
+ @click.option("--take", "-t", type=int, default=25, show_default=True, help="Items per page")
461
+ @click.option(
462
+ "--platform",
463
+ "-p",
464
+ type=click.Choice(["windows", "ni-linux-rt"], case_sensitive=False),
465
+ help="Filter by platform",
466
+ )
467
+ @click.option("--workspace", "-w", help="Filter by workspace name or ID")
468
+ def list_feeds(
469
+ format_: str, take: int, platform: Optional[str], workspace: Optional[str]
470
+ ) -> None:
471
+ """List feeds."""
472
+ format_output = validate_output_format(format_)
473
+
474
+ try:
475
+ workspace_id = None
476
+ workspace = get_effective_workspace(workspace)
477
+ if workspace:
478
+ workspace_id = get_workspace_id_with_fallback(workspace)
479
+
480
+ feeds = _list_feeds(platform=platform, workspace_id=workspace_id)
481
+ workspace_map = get_workspace_map()
482
+
483
+ def feed_formatter(f: Dict[str, Any]) -> List[str]:
484
+ ws_name = get_workspace_display_name(f.get("workspace", ""), workspace_map)
485
+ return [
486
+ _extract_feed_name(f),
487
+ f.get("platform", "").lower(),
488
+ f.get("id", ""),
489
+ ws_name,
490
+ ]
491
+
492
+ mock_resp: Any = FilteredResponse({"feeds": feeds})
493
+
494
+ UniversalResponseHandler.handle_list_response(
495
+ resp=mock_resp,
496
+ data_key="feeds",
497
+ item_name="feed",
498
+ format_output=format_output,
499
+ formatter_func=feed_formatter,
500
+ headers=["Name", "Platform", "ID", "Workspace"],
501
+ column_widths=[30, 12, 36, 20],
502
+ empty_message="No feeds found.",
503
+ enable_pagination=True,
504
+ page_size=take,
505
+ )
506
+ except Exception as exc:
507
+ handle_api_error(exc)
508
+
509
+ @feed.command(name="get")
510
+ @click.option("--id", "-i", "feed_id", required=True, help="Feed ID")
511
+ @click.option(
512
+ "--format",
513
+ "-f",
514
+ "format_",
515
+ type=click.Choice(["table", "json"]),
516
+ default="table",
517
+ help="Output format",
518
+ )
519
+ def get_feed(feed_id: str, format_: str) -> None:
520
+ """Show details for a feed."""
521
+ format_output = validate_output_format(format_)
522
+
523
+ try:
524
+ feed_data = _get_feed(feed_id)
525
+
526
+ if format_output == "json":
527
+ click.echo(json.dumps(feed_data, indent=2))
528
+ return
529
+
530
+ workspace_map = get_workspace_map()
531
+ ws_name = get_workspace_display_name(feed_data.get("workspace", ""), workspace_map)
532
+
533
+ click.echo("Feed Details:")
534
+ click.echo("=" * 50)
535
+ click.echo(f"ID: {feed_data.get('id', 'N/A')}")
536
+ click.echo(f"Name: {_extract_feed_name(feed_data)}")
537
+ click.echo(f"Platform: {feed_data.get('platform', 'N/A')}")
538
+ click.echo(f"Workspace: {ws_name}")
539
+ click.echo(f"Description: {feed_data.get('description', 'N/A')}")
540
+ click.echo(f"Directory: {feed_data.get('directoryUri', 'N/A')}")
541
+ click.echo(f"Ready: {feed_data.get('ready', 'N/A')}")
542
+ click.echo(f"Updated: {feed_data.get('lastUpdated', 'N/A')}")
543
+
544
+ # Show package sources if available
545
+ sources = feed_data.get("packageSources", [])
546
+ if sources:
547
+ click.echo(f"\nPackage Sources ({len(sources)}):")
548
+ for src in sources[:5]: # Limit display
549
+ click.echo(f" - {src}")
550
+ if len(sources) > 5:
551
+ click.echo(f" ... and {len(sources) - 5} more")
552
+
553
+ except Exception as exc:
554
+ handle_api_error(exc)
555
+
556
+ @feed.command(name="create")
557
+ @click.option("--name", "-n", required=True, help="Feed name")
558
+ @click.option(
559
+ "--platform",
560
+ "-p",
561
+ required=True,
562
+ type=click.Choice(["windows", "ni-linux-rt"], case_sensitive=False),
563
+ help="Target platform",
564
+ )
565
+ @click.option("--description", "-d", help="Feed description")
566
+ @click.option("--workspace", "-w", help="Workspace name or ID")
567
+ @click.option("--wait", is_flag=True, help="Wait for operation to complete")
568
+ @click.option("--timeout", type=int, default=300, help="Timeout in seconds when using --wait")
569
+ def create_feed(
570
+ name: str,
571
+ platform: str,
572
+ description: Optional[str],
573
+ workspace: Optional[str],
574
+ wait: bool,
575
+ timeout: int,
576
+ ) -> None:
577
+ """Create a feed."""
578
+ from .utils import check_readonly_mode
579
+
580
+ check_readonly_mode("create a feed")
581
+
582
+ try:
583
+ workspace_id = None
584
+ workspace = get_effective_workspace(workspace)
585
+ if workspace:
586
+ workspace_id = get_workspace_id_with_fallback(workspace)
587
+
588
+ result = _create_feed(
589
+ name=name,
590
+ platform=platform,
591
+ description=description,
592
+ workspace=workspace_id,
593
+ )
594
+
595
+ job_id = result.get("jobId", result.get("job", {}).get("id"))
596
+
597
+ if wait:
598
+ if job_id:
599
+ click.echo(f"Creating feed '{name}'... (job: {job_id})")
600
+ job = _wait_for_job(job_id, timeout=timeout)
601
+ feed_id = job.get("resourceId", "")
602
+ else:
603
+ # Synchronous creation
604
+ feed_id = result.get("id", "")
605
+
606
+ format_success("Feed created", {"ID": feed_id, "Name": name})
607
+ else:
608
+ if job_id:
609
+ format_success("Feed creation started", {"Job ID": job_id, "Name": name})
610
+ else:
611
+ feed_id = result.get("id", "")
612
+ format_success("Feed created", {"ID": feed_id, "Name": name})
613
+
614
+ except TimeoutError as exc:
615
+ click.echo(f"✗ {exc}", err=True)
616
+ sys.exit(ExitCodes.GENERAL_ERROR)
617
+ except Exception as exc:
618
+ handle_api_error(exc)
619
+
620
+ @feed.command(name="delete")
621
+ @click.option("--id", "-i", "feed_id", required=True, help="Feed ID to delete")
622
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
623
+ @click.option("--wait", is_flag=True, help="Wait for operation to complete")
624
+ @click.option("--timeout", type=int, default=300, help="Timeout in seconds when using --wait")
625
+ def delete_feed(feed_id: str, yes: bool, wait: bool, timeout: int) -> None:
626
+ """Delete a feed and its packages."""
627
+ from .utils import check_readonly_mode
628
+
629
+ check_readonly_mode("delete a feed")
630
+
631
+ if not yes:
632
+ if not questionary.confirm(
633
+ f"Are you sure you want to delete feed {feed_id}?",
634
+ default=False,
635
+ ).ask():
636
+ click.echo("Cancelled.")
637
+ return
638
+
639
+ try:
640
+ job_id = _delete_feed(feed_id)
641
+
642
+ if not job_id:
643
+ format_success("Feed deleted", {"ID": feed_id})
644
+ return
645
+
646
+ if wait:
647
+ click.echo(f"Deleting feed... (job: {job_id})")
648
+ _wait_for_job(job_id, timeout=timeout)
649
+ format_success("Feed deleted", {"ID": feed_id})
650
+ else:
651
+ format_success("Feed deletion started", {"Job ID": job_id, "Feed ID": feed_id})
652
+
653
+ except TimeoutError as exc:
654
+ click.echo(f"✗ {exc}", err=True)
655
+ sys.exit(ExitCodes.GENERAL_ERROR)
656
+ except Exception as exc:
657
+ handle_api_error(exc)
658
+
659
+ @feed.command(name="replicate")
660
+ @click.option("--name", "-n", required=True, help="Name for the new feed")
661
+ @click.option(
662
+ "--platform",
663
+ "-p",
664
+ required=True,
665
+ type=click.Choice(["windows", "ni-linux-rt"], case_sensitive=False),
666
+ help="Target platform",
667
+ )
668
+ @click.option("--url", "-u", required=True, help="Source feed URL to replicate from")
669
+ @click.option("--description", "-d", help="Feed description")
670
+ @click.option("--workspace", "-w", help="Workspace name or ID")
671
+ @click.option("--wait", is_flag=True, help="Wait for operation to complete")
672
+ @click.option("--timeout", type=int, default=600, help="Timeout in seconds when using --wait")
673
+ def replicate_feed(
674
+ name: str,
675
+ platform: str,
676
+ url: str,
677
+ description: Optional[str],
678
+ workspace: Optional[str],
679
+ wait: bool,
680
+ timeout: int,
681
+ ) -> None:
682
+ """Replicate a feed from an external URL."""
683
+ try:
684
+ workspace_id = None
685
+ workspace = get_effective_workspace(workspace)
686
+ if workspace:
687
+ workspace_id = get_workspace_id_with_fallback(workspace)
688
+
689
+ result = _replicate_feed(
690
+ name=name,
691
+ platform=platform,
692
+ source_url=url,
693
+ description=description,
694
+ workspace=workspace_id,
695
+ )
696
+
697
+ job_id = result.get("jobId", result.get("job", {}).get("id"))
698
+
699
+ if wait:
700
+ if job_id:
701
+ click.echo(f"Replicating feed from {url}... (job: {job_id})")
702
+ click.echo("This may take several minutes for large feeds.")
703
+ job = _wait_for_job(job_id, timeout=timeout)
704
+ feed_id = job.get("resourceId", "")
705
+ else:
706
+ feed_id = result.get("id", "")
707
+
708
+ format_success("Feed replicated", {"ID": feed_id, "Name": name})
709
+ else:
710
+ if job_id:
711
+ format_success(
712
+ "Feed replication started",
713
+ {"Job ID": job_id, "Name": name, "Source": url},
714
+ )
715
+ else:
716
+ feed_id = result.get("id", "")
717
+ format_success("Feed replicated", {"ID": feed_id, "Name": name})
718
+
719
+ except TimeoutError as exc:
720
+ click.echo(f"✗ {exc}", err=True)
721
+ sys.exit(ExitCodes.GENERAL_ERROR)
722
+ except Exception as exc:
723
+ handle_api_error(exc)
724
+
725
+ # -------------------------------------------------------------------------
726
+ # Package subgroup
727
+ # -------------------------------------------------------------------------
728
+
729
+ @feed.group(name="package")
730
+ def package() -> None:
731
+ """Manage packages in a feed."""
732
+ pass
733
+
734
+ @package.command(name="list")
735
+ @click.option("--feed-id", "-f", required=True, help="Feed ID")
736
+ @click.option(
737
+ "--format",
738
+ "format_",
739
+ type=click.Choice(["table", "json"]),
740
+ default="table",
741
+ help="Output format",
742
+ )
743
+ @click.option("--take", "-t", type=int, default=25, show_default=True, help="Items per page")
744
+ def list_packages(feed_id: str, format_: str, take: int) -> None:
745
+ """List packages in a feed."""
746
+ format_output = validate_output_format(format_)
747
+
748
+ try:
749
+ packages = _list_packages(feed_id)
750
+
751
+ def package_formatter(p: Dict[str, Any]) -> List[str]:
752
+ metadata = p.get("metadata", {})
753
+ return [
754
+ metadata.get("packageName", p.get("id", "")[:20]),
755
+ metadata.get("version", "N/A"),
756
+ metadata.get("architecture", "N/A"),
757
+ p.get("id", ""),
758
+ ]
759
+
760
+ mock_resp: Any = FilteredResponse({"packages": packages})
761
+
762
+ UniversalResponseHandler.handle_list_response(
763
+ resp=mock_resp,
764
+ data_key="packages",
765
+ item_name="package",
766
+ format_output=format_output,
767
+ formatter_func=package_formatter,
768
+ headers=["Name", "Version", "Architecture", "ID"],
769
+ column_widths=[30, 20, 15, 36],
770
+ empty_message="No packages found in this feed.",
771
+ enable_pagination=True,
772
+ page_size=take,
773
+ )
774
+ except Exception as exc:
775
+ handle_api_error(exc)
776
+
777
+ @package.command(name="upload")
778
+ @click.option("--feed-id", "-f", required=True, help="Feed ID to upload to")
779
+ @click.option("--file", "-i", "file_path", required=True, help="Path to package file")
780
+ @click.option("--overwrite", is_flag=True, help="Overwrite existing package")
781
+ @click.option(
782
+ "--wait",
783
+ is_flag=True,
784
+ help=(
785
+ "Wait for the final association job to finish (SLS still waits for initial pool upload)."
786
+ ),
787
+ )
788
+ @click.option("--timeout", type=int, default=300, help="Timeout in seconds when using --wait")
789
+ def upload_package(
790
+ feed_id: str, file_path: str, overwrite: bool, wait: bool, timeout: int
791
+ ) -> None:
792
+ """Upload a package to a feed."""
793
+ from .utils import check_readonly_mode
794
+
795
+ check_readonly_mode("upload a feed package")
796
+
797
+ # Validate file exists
798
+ path = Path(file_path)
799
+ if not path.exists():
800
+ click.echo(f"✗ File not found: {file_path}", err=True)
801
+ sys.exit(ExitCodes.INVALID_INPUT)
802
+
803
+ if not path.is_file():
804
+ click.echo(f"✗ Not a file: {file_path}", err=True)
805
+ sys.exit(ExitCodes.INVALID_INPUT)
806
+
807
+ try:
808
+ platform_value = get_platform()
809
+ if platform_value == PLATFORM_SLS and not wait:
810
+ click.echo(
811
+ "Note: SLS uploads wait for the initial package pool upload even without --wait.",
812
+ err=False,
813
+ )
814
+
815
+ result = _upload_package(feed_id, file_path, overwrite)
816
+ job_id = result.get("jobId", result.get("job", {}).get("id", ""))
817
+
818
+ if wait:
819
+ if job_id:
820
+ click.echo(f"Uploading {path.name}... (job: {job_id})")
821
+ try:
822
+ job = _wait_for_job(job_id, timeout=timeout, feed_id=feed_id)
823
+ package_id = job.get("resourceId", "")
824
+ except JobNotFoundError:
825
+ # If job is gone but we have a package ID (e.g. SLS), assume success
826
+ pkg_id_from_start = result.get("packageId")
827
+ if pkg_id_from_start:
828
+ package_id = pkg_id_from_start
829
+ click.echo(
830
+ "⚠️ Job not found, but package ID is known. Assuming success.",
831
+ err=True,
832
+ )
833
+ else:
834
+ raise
835
+ else:
836
+ # Synchronous success
837
+ package_id = result.get("id", result.get("packageId", ""))
838
+
839
+ format_success("Package uploaded", {"ID": package_id, "File": path.name})
840
+ else:
841
+ if job_id:
842
+ format_success("Package upload started", {"Job ID": job_id, "File": path.name})
843
+ else:
844
+ package_id = result.get("id", result.get("packageId", ""))
845
+ format_success("Package uploaded", {"ID": package_id, "File": path.name})
846
+
847
+ except TimeoutError as exc:
848
+ click.echo(f"✗ {exc}", err=True)
849
+ sys.exit(ExitCodes.GENERAL_ERROR)
850
+ except Exception as exc:
851
+ handle_api_error(exc)
852
+
853
+ @package.command(name="delete")
854
+ @click.option("--id", "-i", "package_id", required=True, help="Package ID to delete")
855
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
856
+ @click.option("--wait", is_flag=True, help="Wait for operation to complete")
857
+ @click.option("--timeout", type=int, default=300, help="Timeout in seconds when using --wait")
858
+ def delete_package(package_id: str, yes: bool, wait: bool, timeout: int) -> None:
859
+ """Delete a package from a feed."""
860
+ from .utils import check_readonly_mode
861
+
862
+ check_readonly_mode("delete a package")
863
+
864
+ if not yes:
865
+ if not questionary.confirm(
866
+ f"Are you sure you want to delete package {package_id}?",
867
+ default=False,
868
+ ).ask():
869
+ click.echo("Cancelled.")
870
+ return
871
+
872
+ try:
873
+ job_id = _delete_package(package_id)
874
+
875
+ if wait:
876
+ if job_id:
877
+ click.echo(f"Deleting package... (job: {job_id})")
878
+ _wait_for_job(job_id, timeout=timeout)
879
+ format_success("Package deleted", {"ID": package_id})
880
+ else:
881
+ if job_id:
882
+ format_success(
883
+ "Package deletion started", {"Job ID": job_id, "Package ID": package_id}
884
+ )
885
+ else:
886
+ format_success("Package deleted", {"ID": package_id})
887
+
888
+ except TimeoutError as exc:
889
+ click.echo(f"✗ {exc}", err=True)
890
+ sys.exit(ExitCodes.GENERAL_ERROR)
891
+ except Exception as exc:
892
+ handle_api_error(exc)