sima-cli 2.1.12__tar.gz → 2.1.13__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. {sima_cli-2.1.12/sima_cli.egg-info → sima_cli-2.1.13}/PKG-INFO +1 -1
  2. {sima_cli-2.1.12 → sima_cli-2.1.13}/pyproject.toml +1 -1
  3. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/__version__.py +1 -1
  4. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/sdk/cmdexec.py +16 -4
  5. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/sdk/commands.py +78 -5
  6. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/sdk/install.py +10 -0
  7. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/sdk/neat.py +128 -0
  8. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/sdk/utils.py +4 -2
  9. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/utils/docker.py +68 -39
  10. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/vulcan/artifacts.py +35 -12
  11. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/vulcan/commands.py +6 -2
  12. {sima_cli-2.1.12 → sima_cli-2.1.13/sima_cli.egg-info}/PKG-INFO +1 -1
  13. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_docker_utils.py +49 -61
  14. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_sdk_image_detection.py +251 -4
  15. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_vulcan.py +108 -4
  16. {sima_cli-2.1.12 → sima_cli-2.1.13}/LICENSE +0 -0
  17. {sima_cli-2.1.12 → sima_cli-2.1.13}/MANIFEST.in +0 -0
  18. {sima_cli-2.1.12 → sima_cli-2.1.13}/README.md +0 -0
  19. {sima_cli-2.1.12 → sima_cli-2.1.13}/requirements.txt +0 -0
  20. {sima_cli-2.1.12 → sima_cli-2.1.13}/setup.cfg +0 -0
  21. {sima_cli-2.1.12 → sima_cli-2.1.13}/setup.py +0 -0
  22. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/__init__.py +0 -0
  23. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/__main__.py +0 -0
  24. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/app_zoo/__init__.py +0 -0
  25. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/app_zoo/app.py +0 -0
  26. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/app_zoo/commands.py +0 -0
  27. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/auth/__init__.py +0 -0
  28. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/auth/auth0.py +0 -0
  29. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/auth/devportal.py +0 -0
  30. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/auth/login.py +0 -0
  31. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/cli.py +0 -0
  32. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/data/__init__.py +0 -0
  33. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/data/resources_internal.yaml +0 -0
  34. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/data/resources_public.yaml +0 -0
  35. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/deploy_only/__init__.py +0 -0
  36. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/deploy_only/device/__init__.py +0 -0
  37. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/deploy_only/device/commands.py +0 -0
  38. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/deploy_only/mpk/__init__.py +0 -0
  39. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/deploy_only/mpk/commands.py +0 -0
  40. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/discover/__init__.py +0 -0
  41. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/discover/discover.py +0 -0
  42. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/discover/linuxll.py +0 -0
  43. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/download/__init__.py +0 -0
  44. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/download/downloader.py +0 -0
  45. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/install/__init__.py +0 -0
  46. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/install/compatibility.py +0 -0
  47. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/install/github_assets.py +0 -0
  48. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/install/hostdriver.py +0 -0
  49. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/install/metadata_info.py +0 -0
  50. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/install/metadata_installer.py +0 -0
  51. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/install/metadata_validator.py +0 -0
  52. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/install/optiview.py +0 -0
  53. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/install/package_builder.py +0 -0
  54. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/install/palette.py +0 -0
  55. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/install/registry.py +0 -0
  56. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/mla/__init__.py +0 -0
  57. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/mla/meminfo.py +0 -0
  58. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/model_zoo/__init__.py +0 -0
  59. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/model_zoo/model.py +0 -0
  60. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/network/__init__.py +0 -0
  61. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/network/network.py +0 -0
  62. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/playbooks/__init__.py +0 -0
  63. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/playbooks/commands.py +0 -0
  64. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/playbooks/manager.py +0 -0
  65. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/sdk/__init__.py +0 -0
  66. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/sdk/config.py +0 -0
  67. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/sdk/linux_devkit_network.py +0 -0
  68. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/sdk/linux_shared_network.py +0 -0
  69. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/sdk/network_doctor.py +0 -0
  70. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/sdk/preinstall.py +0 -0
  71. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/sdk/requirements.json +0 -0
  72. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/sdk/script.py +0 -0
  73. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/sdk/stop.py +0 -0
  74. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/sdk/uninstall.py +0 -0
  75. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/serial/__init__.py +0 -0
  76. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/serial/serial.py +0 -0
  77. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/storage/__init__.py +0 -0
  78. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/storage/nvme.py +0 -0
  79. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/storage/sdcard.py +0 -0
  80. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/update/__init__.py +0 -0
  81. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/update/bmaptool.py +0 -0
  82. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/update/bootimg.py +0 -0
  83. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/update/cleanlog.py +0 -0
  84. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/update/elxr.py +0 -0
  85. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/update/local.py +0 -0
  86. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/update/netboot.py +0 -0
  87. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/update/query.py +0 -0
  88. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/update/remote.py +0 -0
  89. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/update/updater.py +0 -0
  90. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/upgrade/__init__.py +0 -0
  91. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/upgrade/selfupdate.py +0 -0
  92. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/utils/__init__.py +0 -0
  93. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/utils/api_common.py +0 -0
  94. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/utils/artifactory.py +0 -0
  95. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/utils/common.py +0 -0
  96. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/utils/config.py +0 -0
  97. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/utils/config_loader.py +0 -0
  98. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/utils/container_registries.py +0 -0
  99. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/utils/deprecation.py +0 -0
  100. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/utils/device_api.py +0 -0
  101. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/utils/disk.py +0 -0
  102. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/utils/env.py +0 -0
  103. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/utils/errors.py +0 -0
  104. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/utils/mpk_api.py +0 -0
  105. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/utils/net.py +0 -0
  106. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/utils/network.py +0 -0
  107. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/utils/pcie.py +0 -0
  108. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/utils/pkg_update_check.py +0 -0
  109. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/utils/serializers.py +0 -0
  110. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/utils/services.py +0 -0
  111. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/utils/tag.py +0 -0
  112. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli/vulcan/__init__.py +0 -0
  113. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli.egg-info/SOURCES.txt +0 -0
  114. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli.egg-info/dependency_links.txt +0 -0
  115. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli.egg-info/entry_points.txt +0 -0
  116. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli.egg-info/requires.txt +0 -0
  117. {sima_cli-2.1.12 → sima_cli-2.1.13}/sima_cli.egg-info/top_level.txt +0 -0
  118. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/__init__.py +0 -0
  119. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/e2e/__init__.py +0 -0
  120. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/__init__.py +0 -0
  121. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_app_zoo.py +0 -0
  122. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_appzoo_commands.py +0 -0
  123. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_auth.py +0 -0
  124. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_auth0.py +0 -0
  125. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_cli.py +0 -0
  126. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_cli_stdio.py +0 -0
  127. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_cli_update.py +0 -0
  128. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_cli_update_rerun.py +0 -0
  129. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_deprecation.py +0 -0
  130. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_download.py +0 -0
  131. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_elxr_update.py +0 -0
  132. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_firmware.py +0 -0
  133. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_generate_cli_markdown_docs.py +0 -0
  134. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_install_stub.py +0 -0
  135. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_metadata_installer.py +0 -0
  136. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_metadata_validator.py +0 -0
  137. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_model_zoo.py +0 -0
  138. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_netboot.py +0 -0
  139. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_package_builder.py +0 -0
  140. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_pkg_update_check.py +0 -0
  141. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_sdk_linux_shared_network.py +0 -0
  142. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_sdk_network_doctor.py +0 -0
  143. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_sdk_preinstall.py +0 -0
  144. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_sdk_uninstall.py +0 -0
  145. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_selfupdate.py +0 -0
  146. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_skills_commands.py +0 -0
  147. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_skills_manager.py +0 -0
  148. {sima_cli-2.1.12 → sima_cli-2.1.13}/tests/unit/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sima-cli
3
- Version: 2.1.12
3
+ Version: 2.1.13
4
4
  Summary: CLI tool for SiMa Developer Portal to download models, firmware, and apps.
5
5
  Home-page: https://community.sima.ai/
6
6
  Author: SiMa.ai
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sima-cli"
7
- version = "2.1.12"
7
+ version = "2.1.13"
8
8
  description = "CLI tool for SiMa Developer Portal to download models, firmware, and apps."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -1,2 +1,2 @@
1
1
  # sima_cli/__version__.py
2
- __version__ = "2.1.12"
2
+ __version__ = "2.1.13"
@@ -18,10 +18,15 @@ from sima_cli.sdk.utils import (
18
18
 
19
19
  console = Console()
20
20
 
21
+
22
+ class SdkContainerUnavailable(RuntimeError):
23
+ """Raised when no running SDK container matches a requested tool."""
24
+
25
+
21
26
  # ─────────────────────────────────────────────
22
27
  # Core executer
23
28
  # ─────────────────────────────────────────────
24
- def exec_container_cmd(ctx, keyword: str, cmd: Optional[str] = None):
29
+ def exec_container_cmd(ctx, keyword: str, cmd: Optional[str] = None, raise_on_missing: bool = False):
25
30
  """
26
31
  Find a running container matching the given SDK keyword (e.g. mpk, yocto),
27
32
  optionally filtering by version (from ctx.obj['version_filter']),
@@ -34,6 +39,8 @@ def exec_container_cmd(ctx, keyword: str, cmd: Optional[str] = None):
34
39
  containers = get_all_containers(running_containers_only=True)
35
40
  if not containers:
36
41
  console.print("[yellow]⚠️ No running containers found.[/yellow]")
42
+ if raise_on_missing:
43
+ raise SdkContainerUnavailable("No running containers found.")
37
44
  sys.exit(0)
38
45
 
39
46
  # ──────────────────────────────────────────────
@@ -61,11 +68,16 @@ def exec_container_cmd(ctx, keyword: str, cmd: Optional[str] = None):
61
68
  )
62
69
 
63
70
  if not matches:
64
- console.print(
65
- f"[red]❌ No running containers found for '{keyword}'"
71
+ message = (
72
+ f"No running containers found for '{keyword}'"
66
73
  + (f" with version '{version_filter}'" if version_filter else "")
67
- + ".[/red]"
74
+ + "."
75
+ )
76
+ console.print(
77
+ f"[red]❌ {message}[/red]"
68
78
  )
79
+ if raise_on_missing:
80
+ raise SdkContainerUnavailable(message)
69
81
  sys.exit(1)
70
82
 
71
83
  # ──────────────────────────────────────────────
@@ -24,10 +24,10 @@ from typing import Optional
24
24
  from rich.console import Console
25
25
  from rich.panel import Panel
26
26
  from sima_cli.sdk.install import setup_and_start
27
- from sima_cli.sdk.cmdexec import exec_container_cmd
27
+ from sima_cli.sdk.cmdexec import SdkContainerUnavailable, exec_container_cmd
28
28
  from sima_cli.sdk.uninstall import remove_containers, remove_unused_images
29
29
  from sima_cli.sdk.stop import stop_containers
30
- from sima_cli.sdk.utils import get_all_containers
30
+ from sima_cli.sdk.utils import get_all_containers, container_matches_sdk_keyword
31
31
  from sima_cli.sdk.utils import extract_short_name
32
32
  from sima_cli.discover.discover import discover_and_probe
33
33
  from rich.table import Table
@@ -38,6 +38,7 @@ from sima_cli.sdk.config import IMAGE_CONFIG
38
38
  from sima_cli.sdk.network_doctor import (
39
39
  build_network_doctor_report,
40
40
  collect_network_doctor_bundle,
41
+ ensure_existing_neat_container_startable,
41
42
  print_network_doctor_report,
42
43
  repair_linux_devkit_network,
43
44
  )
@@ -156,7 +157,66 @@ def _resolve_devkit_ipv4(devkit: Optional[str]) -> str:
156
157
  return value
157
158
 
158
159
 
159
- def launch_sdk_tool(tool: str, cmd, ctx):
160
+ def _container_name(container) -> str:
161
+ return container.get("Names") or container.get("Name") or container.get("name") or ""
162
+
163
+
164
+ def _container_is_running(container) -> bool:
165
+ state = (container.get("State") or "").lower().strip()
166
+ status = (container.get("Status") or "").lower().strip()
167
+ return state == "running" or status.startswith("up") or "running" in status
168
+
169
+
170
+ def _version_matches(container, version_filter: str) -> bool:
171
+ if not version_filter:
172
+ return True
173
+ needle = version_filter.lower()
174
+ name = _container_name(container).lower()
175
+ image = (container.get("Image") or container.get("image") or "").lower()
176
+ return needle in name or needle in image
177
+
178
+
179
+ def _start_stopped_neat_containers(ctx) -> None:
180
+ version_filter = None
181
+ if ctx and getattr(ctx, "obj", None):
182
+ version_filter = ctx.obj.get("version_filter")
183
+
184
+ containers = get_all_containers(running_containers_only=False)
185
+ matches = [
186
+ container
187
+ for container in containers
188
+ if container_matches_sdk_keyword(container, "neat")
189
+ and _version_matches(container, version_filter)
190
+ and not _container_is_running(container)
191
+ ]
192
+
193
+ if not matches:
194
+ message = "No stopped Neat SDK containers found."
195
+ if version_filter:
196
+ message = f"No stopped Neat SDK containers found for version '{version_filter}'."
197
+ raise click.ClickException(f"{message} Run: sima-cli sdk setup")
198
+
199
+ for container in matches:
200
+ name = _container_name(container)
201
+ if not name:
202
+ continue
203
+ try:
204
+ ensure_existing_neat_container_startable(name)
205
+ console.print(
206
+ f"[cyan]▶ Starting existing Neat SDK container:[/cyan] [bold]{name}[/bold] "
207
+ "[dim](this may take a moment)[/dim]"
208
+ )
209
+ subprocess.run(["docker", "start", name], check=True, capture_output=True, text=True)
210
+ except subprocess.CalledProcessError as e:
211
+ raise click.ClickException(
212
+ f"Failed to start Neat SDK container '{name}' while running: {' '.join(e.cmd)}"
213
+ ) from e
214
+ except RuntimeError as e:
215
+ raise click.ClickException(str(e)) from e
216
+ console.print(f"[green]✅ Started Neat SDK container:[/green] [bold]{name}[/bold]")
217
+
218
+
219
+ def launch_sdk_tool(tool: str, cmd, ctx, recover_unavailable: bool = False):
160
220
  """
161
221
  Launch a selected SDK tool container, optionally executing a command inside it.
162
222
  If no command is provided, defaults to an interactive bash login shell.
@@ -182,7 +242,17 @@ def launch_sdk_tool(tool: str, cmd, ctx):
182
242
  else:
183
243
  cmd_str = str(cmd)
184
244
 
185
- exec_container_cmd(ctx, tool, cmd_str)
245
+ if not recover_unavailable:
246
+ exec_container_cmd(ctx, tool, cmd_str)
247
+ return
248
+
249
+ try:
250
+ exec_container_cmd(ctx, tool, cmd_str, raise_on_missing=True)
251
+ except SdkContainerUnavailable:
252
+ if tool != "neat":
253
+ raise
254
+ _start_stopped_neat_containers(ctx)
255
+ exec_container_cmd(ctx, tool, cmd_str)
186
256
 
187
257
 
188
258
  # ------------------------------------------------------------
@@ -554,13 +624,16 @@ def neat(ctx, cmd):
554
624
  running container with bash -lc. If CMD is omitted, sima-cli opens an
555
625
  interactive login shell.
556
626
 
627
+ If no matching Neat SDK container is running, existing stopped Neat SDK
628
+ container(s) are started automatically and the command is retried.
629
+
557
630
  \b
558
631
  Examples:
559
632
  sima-cli sdk neat
560
633
  sima-cli sdk neat python --version
561
634
  sima-cli sdk neat "python app.py --config config.json"
562
635
  """
563
- launch_sdk_tool("neat", cmd, ctx)
636
+ launch_sdk_tool("neat", cmd, ctx, recover_unavailable=True)
564
637
 
565
638
 
566
639
  @sdk.command(
@@ -1148,6 +1148,16 @@ def setup_and_start(
1148
1148
  configure_container_user(existing_container, login_name, user_uid, user_gid)
1149
1149
 
1150
1150
  if devkit_env and is_neat_sdk_image(img):
1151
+ if not skip_insight:
1152
+ # Re-issue the mounted HTTPS cert for the (possibly new) host
1153
+ # IP before bootstrap restarts neat-insight, so the browser
1154
+ # doesn't hit a SAN mismatch ("connection is not private")
1155
+ # after a re-point.
1156
+ from sima_cli.sdk.neat import refresh_neat_certificates
1157
+ refresh_neat_certificates(
1158
+ workspace, existing_container, devkit_env,
1159
+ yes_to_all=yes_to_all, noninteractive=noninteractive,
1160
+ )
1151
1161
  bootstrap_devkit_container(existing_container, devkit_env)
1152
1162
 
1153
1163
  if len(selected_images) == 1:
@@ -256,6 +256,28 @@ def _collect_physical_ipv4s_for_certs() -> List[str]:
256
256
  return hosts
257
257
 
258
258
 
259
+ def _collect_all_ipv4s_for_certs() -> List[str]:
260
+ """Every usable IPv4 on the host, INCLUDING VPN/tunnel interfaces.
261
+
262
+ Unlike the advertised-URL selection (which deliberately prefers a physical
263
+ interface), a certificate SAN should be inclusive: list every address the
264
+ browser might connect to so HTTPS is trusted whether or not a VPN is up.
265
+ Without this, a cert minted while a VPN was connected covers only the VPN IP
266
+ (and vice versa), so toggling the VPN flips between "secure" and "not secure".
267
+ """
268
+ hosts = []
269
+ try:
270
+ import psutil
271
+ except Exception:
272
+ return hosts
273
+
274
+ for _iface, addrs in psutil.net_if_addrs().items():
275
+ for addr in addrs:
276
+ if addr.family == socket.AF_INET and _is_usable_ipv4(addr.address):
277
+ hosts.append(addr.address)
278
+ return hosts
279
+
280
+
259
281
  def _collect_cert_hosts(devkit_env: Optional[dict]) -> List[str]:
260
282
  hosts = ["localhost", "127.0.0.1"]
261
283
  if devkit_env and devkit_env.get("host_ip"):
@@ -268,6 +290,8 @@ def _collect_cert_hosts(devkit_env: Optional[dict]) -> List[str]:
268
290
  except Exception:
269
291
  pass
270
292
  hosts.extend(_collect_physical_ipv4s_for_certs())
293
+ # Include VPN/tunnel IPv4s too so the cert stays valid across VPN on/off.
294
+ hosts.extend(_collect_all_ipv4s_for_certs())
271
295
 
272
296
  seen = set()
273
297
  return [host for host in hosts if host and not (host in seen or seen.add(host))]
@@ -474,6 +498,110 @@ def _ensure_certificates(cert_dir: Path, devkit_env: Optional[dict], yes_to_all:
474
498
  return cert_file, key_file
475
499
 
476
500
 
501
+ def _cert_covers_host(cert_file: Path, host_ip: str) -> bool:
502
+ """Return True if the cert's SAN already lists ``host_ip`` as an IP address.
503
+
504
+ Used to avoid needlessly re-issuing (and disrupting) the certificate on a
505
+ re-point when the host IP hasn't changed. On any failure to verify (no
506
+ openssl, parse error) returns False so the caller re-issues to be safe.
507
+ """
508
+ if not host_ip:
509
+ return False
510
+ try:
511
+ result = subprocess.run(
512
+ ["openssl", "x509", "-noout", "-ext", "subjectAltName", "-in", str(cert_file)],
513
+ capture_output=True, text=True, check=False,
514
+ )
515
+ text = result.stdout or ""
516
+ if result.returncode != 0 or "IP Address" not in text:
517
+ result = subprocess.run(
518
+ ["openssl", "x509", "-noout", "-text", "-in", str(cert_file)],
519
+ capture_output=True, text=True, check=False,
520
+ )
521
+ text = result.stdout or ""
522
+ # Parse the comma-separated "IP Address:<ip>" SAN entries and match
523
+ # exactly. A bare substring check would treat a routed IP that is a
524
+ # *prefix* of an existing entry as covered (e.g. 192.168.1.5 vs
525
+ # IP Address:192.168.1.50), wrongly skipping re-issue and leaving the
526
+ # browser with a SAN mismatch after that re-point.
527
+ san_ips = []
528
+ for token in text.replace("\n", ",").split(","):
529
+ token = token.strip()
530
+ if token.startswith("IP Address:"):
531
+ san_ips.append(token[len("IP Address:"):].strip())
532
+ return host_ip in san_ips
533
+ except OSError:
534
+ return False
535
+
536
+
537
+ def _container_sdk_cert_dir(container_name: str) -> Optional[Path]:
538
+ """Return the host directory bind-mounted to ``/sdk-cert`` in the running
539
+ container, or ``None`` if it can't be determined.
540
+
541
+ The ``/sdk-cert`` mount is fixed at ``docker run`` time to the workspace used
542
+ *then*. We must re-issue into that exact directory, not one reconstructed
543
+ from the current invocation's workspace -- otherwise a user re-running setup
544
+ from a different workspace would write the new cert into an unmounted dir
545
+ while the container keeps serving the old mounted one.
546
+ """
547
+ try:
548
+ result = subprocess.run(
549
+ ["docker", "inspect", "-f",
550
+ '{{range .Mounts}}{{if eq .Destination "/sdk-cert"}}{{.Source}}{{end}}{{end}}',
551
+ container_name],
552
+ capture_output=True, text=True, check=False,
553
+ )
554
+ except OSError:
555
+ return None
556
+ if result.returncode != 0:
557
+ return None
558
+ source = (result.stdout or "").strip()
559
+ return Path(source) if source else None
560
+
561
+
562
+ def refresh_neat_certificates(
563
+ workspace: str,
564
+ container_name: str,
565
+ devkit_env: Optional[dict],
566
+ *,
567
+ yes_to_all: bool = False,
568
+ noninteractive: bool = False,
569
+ ) -> bool:
570
+ """Re-issue the mounted ``/sdk-cert`` certificate to cover the current host IP.
571
+
572
+ On a DevKit re-point (or a host network change) the routed host IP changes,
573
+ but the HTTPS cert mounted into the Neat container was signed once at
574
+ container creation with the *old* IP in its SAN -- so the browser shows
575
+ "Your connection is not private" on the new IP. The host owns the trusted
576
+ mkcert CA, so the cert must be reissued *here* (not inside the container) to
577
+ stay trusted; neat-insight is restarted separately (devkit.sh's refresh hook)
578
+ to pick up the rewritten file from the bind-mounted cert dir.
579
+
580
+ (Re)generates when the cert is **missing** (e.g. the user deleted it) or its
581
+ SAN doesn't cover the current host IP. No-op (returns False) only when there
582
+ is no host IP, or a valid cert already covers it. Returns True when a cert was
583
+ generated.
584
+ """
585
+ host_ip = (devkit_env or {}).get("host_ip", "")
586
+ if not host_ip:
587
+ return False
588
+ # Use the directory actually bind-mounted to /sdk-cert in the container, so a
589
+ # re-issue lands where neat-insight reads it even if the current invocation's
590
+ # workspace differs from the one the container was created with. Fall back to
591
+ # the workspace-derived path (e.g. container not running / no mount).
592
+ cert_dir = _container_sdk_cert_dir(container_name) \
593
+ or (Path(workspace) / f".{container_name}" / "sdk-cert")
594
+ cert_file = cert_dir / "neat-sdk.pem"
595
+ if cert_file.exists() and _cert_covers_host(cert_file, host_ip):
596
+ return False
597
+ action = "Re-issuing" if cert_file.exists() else "Generating"
598
+ print(f"🔐 {action} Neat SDK certificate for host IP {host_ip} ...")
599
+ _ensure_certificates(
600
+ cert_dir, devkit_env, yes_to_all=yes_to_all, noninteractive=noninteractive
601
+ )
602
+ return True
603
+
604
+
477
605
  def _write_port_map(config_dir: Path, port_map: Dict) -> Path:
478
606
  config_dir.mkdir(parents=True, exist_ok=True)
479
607
  path = config_dir / "neat-port-map.json"
@@ -873,6 +873,7 @@ def ensure_model_sdk_extension_installed(
873
873
  print("ℹ️ Skipping Model Compiler extension install.")
874
874
  return
875
875
 
876
+ internal_sima_cli_env = "export SIMA_CLI_AUTO_ACCEPT_UPDATE=1; "
876
877
  print("ℹ️ Logging in to sima-cli before installing the Model Compiler extension...")
877
878
  run_command(
878
879
  _docker_exec_interactive_prefix() + [
@@ -881,7 +882,7 @@ def ensure_model_sdk_extension_installed(
881
882
  sdk_container_name,
882
883
  "bash",
883
884
  "-lc",
884
- "sima-cli login",
885
+ f"{internal_sima_cli_env}sima-cli login",
885
886
  ]
886
887
  )
887
888
 
@@ -892,6 +893,7 @@ def ensure_model_sdk_extension_installed(
892
893
  f"export HOME={shlex.quote(home_directory)}; "
893
894
  f"export USER={shlex.quote(login_name)}; "
894
895
  f"export LOGNAME={shlex.quote(login_name)}; "
896
+ f"{internal_sima_cli_env}"
895
897
  "export PATH=\"$HOME/.sima-cli/.venv/bin:$HOME/.local/bin:$PATH\"; "
896
898
  "mkdir -p \"$HOME/extension-installation\"; "
897
899
  "cd \"$HOME/extension-installation\"; "
@@ -1206,7 +1208,7 @@ def install_neat_playbooks(sdk_container_name: str, login_name: str) -> None:
1206
1208
  "-u",
1207
1209
  login_name,
1208
1210
  "-e",
1209
- "SIMA_CLI_CHECK_FOR_UPDATE=0",
1211
+ "SIMA_CLI_AUTO_ACCEPT_UPDATE=1",
1210
1212
  "-e",
1211
1213
  "GITHUB_TOKEN",
1212
1214
  sdk_container_name,
@@ -9,8 +9,6 @@ import getpass
9
9
 
10
10
  from sima_cli.utils.env import get_environment_type
11
11
 
12
- # Sentinel to break out of an infinite re-exec loop if `sg docker -c …`
13
- # somehow lands back here without socket access.
14
12
  _REEXEC_ENV_FLAG = "SIMA_CLI_DOCKER_GROUP_REEXEC"
15
13
 
16
14
 
@@ -74,57 +72,92 @@ def _user_in_docker_group(user: str) -> bool:
74
72
  return user in members
75
73
 
76
74
 
77
- def _reexec_with_docker_group() -> None:
78
- """Re-run the current sima-cli invocation under `sg docker -c …`.
75
+ def _active_user_in_docker_group() -> bool:
76
+ """Check whether this running process has the docker group active."""
77
+ try:
78
+ import grp
79
+
80
+ group_ids = set(os.getgroups())
81
+ group_ids.add(os.getgid())
82
+ group_ids.add(os.getegid())
83
+ active_group_names = {
84
+ grp.getgrgid(group_id).gr_name
85
+ for group_id in group_ids
86
+ }
87
+ except (ImportError, KeyError, OSError):
88
+ return False
89
+ return "docker" in active_group_names
90
+
79
91
 
80
- `sg <group> -c <cmd>` runs <cmd> with that supplementary group active for
81
- the new process, which is what gives access to /var/run/docker.sock right
82
- after `usermod -aG docker` — without forcing the user to open a new shell.
83
- Sets a sentinel env var so a second pass through this code path cannot
84
- loop forever. Returns only if re-exec is not possible (caller should fall
85
- back to the manual-instruction path).
92
+ def _try_reexec_with_docker_group() -> None:
93
+ """Best-effort re-run under `sg docker` when it is known to work.
94
+
95
+ Some systems allow `sg docker -c ...` to activate a newly-added docker
96
+ group immediately, while others reject it or prompt for group auth. Probe
97
+ first so failures can fall back to deterministic user instructions.
86
98
  """
87
99
  if os.environ.get(_REEXEC_ENV_FLAG) == "1":
88
- return # already retried under sg; don't loop
100
+ return
89
101
  if not shutil.which("sg"):
90
- return # `sg` (from util-linux) unavailable; fall back
102
+ return
103
+
104
+ probe_env = os.environ.copy()
105
+ probe_env[_REEXEC_ENV_FLAG] = "1"
106
+ try:
107
+ probe = subprocess.run(
108
+ ["sg", "docker", "-c", "docker info >/dev/null 2>&1"],
109
+ stdin=subprocess.DEVNULL,
110
+ stdout=subprocess.DEVNULL,
111
+ stderr=subprocess.DEVNULL,
112
+ env=probe_env,
113
+ timeout=10,
114
+ check=False,
115
+ )
116
+ except (OSError, subprocess.SubprocessError):
117
+ return
118
+ if probe.returncode != 0:
119
+ return
91
120
 
92
- new_env = os.environ.copy()
93
- new_env[_REEXEC_ENV_FLAG] = "1"
94
121
  cmdline = shlex.join(sys.argv)
95
- print("🔁 Activating 'docker' group for this command (no new shell needed)...")
122
+ print("🔁 Activating 'docker' group for this command...")
96
123
  try:
97
- os.execvpe("sg", ["sg", "docker", "-c", cmdline], new_env)
98
- except OSError as e:
99
- print(f"⚠️ Could not re-exec under 'sg docker': {e}")
100
- return # caller falls back to manual instructions
124
+ os.execvpe("sg", ["sg", "docker", "-c", cmdline], probe_env)
125
+ except OSError:
126
+ return
127
+
128
+
129
+ def _print_docker_group_refresh_instructions() -> None:
130
+ command = shlex.join(sys.argv)
131
+ print("ℹ️ Docker group membership is configured, but this shell session cannot use it yet.")
132
+ print(" Start a shell with the updated group membership, then re-run this command:")
133
+ print(" newgrp docker")
134
+ print(f" {command}")
135
+ print(" If that still does not work, log out and back in before retrying.")
101
136
 
102
137
 
103
138
  def _handle_docker_socket_permission_denied() -> bool:
104
139
  """Linux: daemon is up but the current user can't access the socket.
105
140
 
106
- Offers to add the user to the 'docker' group via sudo and then re-execs
107
- the current command under `sg docker -c …` so it completes in the same
108
- shell session. Falls back to printed manual instructions only when the
109
- re-exec path is unavailable or has already been retried.
141
+ Offers to add the user to the 'docker' group via sudo when needed, then
142
+ exits with clear instructions because newly-added group membership is not
143
+ available to the current shell/process on many Linux systems.
110
144
  """
111
145
  user = _current_user()
112
146
  print("⚠️ Docker daemon is running, but this user lacks permission to access it.")
113
147
  print(f" (Cannot access /var/run/docker.sock as '{user}'.)")
114
148
 
115
- already_retried = os.environ.get(_REEXEC_ENV_FLAG) == "1"
116
-
117
149
  if _user_in_docker_group(user):
118
- if already_retried:
119
- print(f"ℹ️ User '{user}' is already a member of the 'docker' group, but even after")
120
- print(" re-exec'ing under 'sg docker' the socket is still not accessible.")
121
- print(" 👉 Log out and back in, then re-run this command.")
150
+ if _active_user_in_docker_group():
151
+ print(f"ℹ️ User '{user}' already has the 'docker' group active in this session.")
152
+ print(" The socket is still not accessible, so this is likely a Docker daemon")
153
+ print(" or /var/run/docker.sock ownership issue rather than group activation.")
154
+ print(" Check: ls -l /var/run/docker.sock")
155
+ print(" Check: sudo systemctl status docker")
122
156
  sys.exit(1)
123
- print(f"ℹ️ User '{user}' is already a member of the 'docker' group, but the current")
157
+ print(f"ℹ️ User '{user}' is already a member of the 'docker' group, but this")
124
158
  print(" shell session has not picked up that membership yet.")
125
- _reexec_with_docker_group()
126
- # Re-exec failed; fall through to manual instructions.
127
- print(" 👉 Run 'newgrp docker' in this shell (or log out and back in), then re-run this command.")
159
+ _try_reexec_with_docker_group()
160
+ _print_docker_group_refresh_instructions()
128
161
  sys.exit(1)
129
162
 
130
163
  print(f"ℹ️ User '{user}' is not a member of the 'docker' group.")
@@ -143,12 +176,8 @@ def _handle_docker_socket_permission_denied() -> bool:
143
176
  sys.exit(1)
144
177
 
145
178
  print(f"✅ Added '{user}' to the 'docker' group.")
146
- _reexec_with_docker_group()
147
- # If we get here, re-exec was not possible (no `sg`, already retried, or
148
- # OSError) and the original install/setup operation did NOT complete.
149
- # Exit non-zero so parent scripts / CI do not treat this as success.
150
- print("ℹ️ Group membership only takes effect in NEW shell sessions.")
151
- print(" 👉 Run 'newgrp docker' in this shell (or log out and back in), then re-run this command.")
179
+ _try_reexec_with_docker_group()
180
+ _print_docker_group_refresh_instructions()
152
181
  sys.exit(1)
153
182
 
154
183
 
@@ -54,6 +54,7 @@ class InstallMetadataResult:
54
54
  environment: str
55
55
  base_url: str
56
56
  repository: str
57
+ package_path: str
57
58
  ref: str
58
59
  ref_key: str
59
60
  requested_spec: str
@@ -190,22 +191,36 @@ def read_latest_tag(client: ArtifactClient, base_url: str, repository: str, key:
190
191
  return latest_tag
191
192
 
192
193
 
193
- def parse_install_target(target: str) -> Tuple[str, str, str]:
194
+ def _parse_repository_and_package_path(raw: str) -> Tuple[str, str]:
195
+ value = raw.strip().strip("/")
196
+ if not value:
197
+ raise VulcanArtifactError("Install target repository is empty.")
198
+
199
+ parts = [part.strip() for part in value.split("/")]
200
+ if any(not part for part in parts):
201
+ raise VulcanArtifactError("Install target must not contain empty path segments.")
202
+ if any(part == ".." for part in parts):
203
+ raise VulcanArtifactError("Install target package path must not contain '..'.")
204
+
205
+ repository = parts[0]
206
+ package_path = "/".join(parts[1:])
207
+ return repository, package_path
208
+
209
+
210
+ def parse_install_target(target: str) -> Tuple[str, str, str, str]:
194
211
  value = target.strip()
195
212
  if not value:
196
213
  raise VulcanArtifactError("Install target is empty.")
197
214
 
198
- repository, separator, ref_spec = value.partition("@")
199
- repository = repository.strip()
200
- if not repository:
201
- raise VulcanArtifactError("Install target repository is empty.")
215
+ repository_spec, separator, ref_spec = value.partition("@")
216
+ repository, package_path = _parse_repository_and_package_path(repository_spec)
202
217
 
203
218
  if not separator or not ref_spec.strip():
204
- return repository, "main", "latest"
219
+ return repository, package_path, "main", "latest"
205
220
 
206
221
  ref_spec = ref_spec.strip().strip("/")
207
222
  if not ref_spec:
208
- return repository, "main", "latest"
223
+ return repository, package_path, "main", "latest"
209
224
 
210
225
  if ":" in ref_spec:
211
226
  ref, spec = ref_spec.rsplit(":", 1)
@@ -215,12 +230,12 @@ def parse_install_target(target: str) -> Tuple[str, str, str]:
215
230
  raise VulcanArtifactError(
216
231
  "Install target must use repo@branch:spec when specifying both branch and spec."
217
232
  )
218
- return repository, ref, spec
233
+ return repository, package_path, ref, spec
219
234
 
220
235
  if ref_spec == "latest" or _looks_like_commit_spec(ref_spec):
221
- return repository, "main", ref_spec
236
+ return repository, package_path, "main", ref_spec
222
237
 
223
- return repository, ref_spec, "latest"
238
+ return repository, package_path, ref_spec, "latest"
224
239
 
225
240
 
226
241
  def _looks_like_commit_spec(value: str) -> bool:
@@ -250,17 +265,25 @@ def resolve_install_metadata_url(
250
265
  ) -> InstallMetadataResult:
251
266
  client = client or ArtifactClient()
252
267
  resolved_base_url = normalize_base_url(base_url or ENV_BASE_URLS[environment])
253
- repository, ref_name, requested_spec = parse_install_target(target)
268
+ repository, package_path, ref_name, requested_spec = parse_install_target(target)
254
269
  key = ref_key(ref_name)
255
270
  if requested_spec == "latest":
256
271
  resolved_spec = read_latest_tag(client, resolved_base_url, repository, key)
257
272
  else:
258
273
  resolved_spec = requested_spec
259
- metadata_url = join_url(resolved_base_url, repository, key, resolved_spec, metadata_filename(package_type))
274
+ metadata_url = join_url(
275
+ resolved_base_url,
276
+ repository,
277
+ key,
278
+ resolved_spec,
279
+ package_path,
280
+ metadata_filename(package_type),
281
+ )
260
282
  return InstallMetadataResult(
261
283
  environment=environment,
262
284
  base_url=resolved_base_url,
263
285
  repository=repository,
286
+ package_path=package_path,
264
287
  ref=ref_name,
265
288
  ref_key=key,
266
289
  requested_spec=requested_spec,