sima-cli 2.1.13__tar.gz → 2.1.14__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.13/sima_cli.egg-info → sima_cli-2.1.14}/PKG-INFO +1 -1
  2. {sima_cli-2.1.13 → sima_cli-2.1.14}/pyproject.toml +1 -1
  3. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/__version__.py +1 -1
  4. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/install/package_builder.py +11 -1
  5. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/sdk/install.py +25 -5
  6. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/sdk/neat.py +272 -42
  7. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/sdk/network_doctor.py +1 -1
  8. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/sdk/utils.py +209 -1
  9. {sima_cli-2.1.13 → sima_cli-2.1.14/sima_cli.egg-info}/PKG-INFO +1 -1
  10. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_package_builder.py +91 -0
  11. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_sdk_image_detection.py +403 -11
  12. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_sdk_network_doctor.py +4 -0
  13. {sima_cli-2.1.13 → sima_cli-2.1.14}/LICENSE +0 -0
  14. {sima_cli-2.1.13 → sima_cli-2.1.14}/MANIFEST.in +0 -0
  15. {sima_cli-2.1.13 → sima_cli-2.1.14}/README.md +0 -0
  16. {sima_cli-2.1.13 → sima_cli-2.1.14}/requirements.txt +0 -0
  17. {sima_cli-2.1.13 → sima_cli-2.1.14}/setup.cfg +0 -0
  18. {sima_cli-2.1.13 → sima_cli-2.1.14}/setup.py +0 -0
  19. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/__init__.py +0 -0
  20. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/__main__.py +0 -0
  21. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/app_zoo/__init__.py +0 -0
  22. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/app_zoo/app.py +0 -0
  23. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/app_zoo/commands.py +0 -0
  24. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/auth/__init__.py +0 -0
  25. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/auth/auth0.py +0 -0
  26. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/auth/devportal.py +0 -0
  27. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/auth/login.py +0 -0
  28. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/cli.py +0 -0
  29. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/data/__init__.py +0 -0
  30. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/data/resources_internal.yaml +0 -0
  31. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/data/resources_public.yaml +0 -0
  32. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/deploy_only/__init__.py +0 -0
  33. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/deploy_only/device/__init__.py +0 -0
  34. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/deploy_only/device/commands.py +0 -0
  35. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/deploy_only/mpk/__init__.py +0 -0
  36. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/deploy_only/mpk/commands.py +0 -0
  37. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/discover/__init__.py +0 -0
  38. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/discover/discover.py +0 -0
  39. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/discover/linuxll.py +0 -0
  40. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/download/__init__.py +0 -0
  41. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/download/downloader.py +0 -0
  42. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/install/__init__.py +0 -0
  43. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/install/compatibility.py +0 -0
  44. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/install/github_assets.py +0 -0
  45. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/install/hostdriver.py +0 -0
  46. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/install/metadata_info.py +0 -0
  47. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/install/metadata_installer.py +0 -0
  48. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/install/metadata_validator.py +0 -0
  49. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/install/optiview.py +0 -0
  50. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/install/palette.py +0 -0
  51. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/install/registry.py +0 -0
  52. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/mla/__init__.py +0 -0
  53. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/mla/meminfo.py +0 -0
  54. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/model_zoo/__init__.py +0 -0
  55. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/model_zoo/model.py +0 -0
  56. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/network/__init__.py +0 -0
  57. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/network/network.py +0 -0
  58. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/playbooks/__init__.py +0 -0
  59. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/playbooks/commands.py +0 -0
  60. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/playbooks/manager.py +0 -0
  61. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/sdk/__init__.py +0 -0
  62. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/sdk/cmdexec.py +0 -0
  63. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/sdk/commands.py +0 -0
  64. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/sdk/config.py +0 -0
  65. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/sdk/linux_devkit_network.py +0 -0
  66. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/sdk/linux_shared_network.py +0 -0
  67. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/sdk/preinstall.py +0 -0
  68. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/sdk/requirements.json +0 -0
  69. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/sdk/script.py +0 -0
  70. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/sdk/stop.py +0 -0
  71. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/sdk/uninstall.py +0 -0
  72. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/serial/__init__.py +0 -0
  73. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/serial/serial.py +0 -0
  74. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/storage/__init__.py +0 -0
  75. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/storage/nvme.py +0 -0
  76. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/storage/sdcard.py +0 -0
  77. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/update/__init__.py +0 -0
  78. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/update/bmaptool.py +0 -0
  79. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/update/bootimg.py +0 -0
  80. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/update/cleanlog.py +0 -0
  81. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/update/elxr.py +0 -0
  82. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/update/local.py +0 -0
  83. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/update/netboot.py +0 -0
  84. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/update/query.py +0 -0
  85. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/update/remote.py +0 -0
  86. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/update/updater.py +0 -0
  87. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/upgrade/__init__.py +0 -0
  88. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/upgrade/selfupdate.py +0 -0
  89. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/utils/__init__.py +0 -0
  90. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/utils/api_common.py +0 -0
  91. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/utils/artifactory.py +0 -0
  92. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/utils/common.py +0 -0
  93. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/utils/config.py +0 -0
  94. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/utils/config_loader.py +0 -0
  95. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/utils/container_registries.py +0 -0
  96. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/utils/deprecation.py +0 -0
  97. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/utils/device_api.py +0 -0
  98. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/utils/disk.py +0 -0
  99. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/utils/docker.py +0 -0
  100. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/utils/env.py +0 -0
  101. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/utils/errors.py +0 -0
  102. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/utils/mpk_api.py +0 -0
  103. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/utils/net.py +0 -0
  104. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/utils/network.py +0 -0
  105. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/utils/pcie.py +0 -0
  106. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/utils/pkg_update_check.py +0 -0
  107. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/utils/serializers.py +0 -0
  108. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/utils/services.py +0 -0
  109. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/utils/tag.py +0 -0
  110. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/vulcan/__init__.py +0 -0
  111. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/vulcan/artifacts.py +0 -0
  112. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli/vulcan/commands.py +0 -0
  113. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli.egg-info/SOURCES.txt +0 -0
  114. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli.egg-info/dependency_links.txt +0 -0
  115. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli.egg-info/entry_points.txt +0 -0
  116. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli.egg-info/requires.txt +0 -0
  117. {sima_cli-2.1.13 → sima_cli-2.1.14}/sima_cli.egg-info/top_level.txt +0 -0
  118. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/__init__.py +0 -0
  119. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/e2e/__init__.py +0 -0
  120. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/__init__.py +0 -0
  121. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_app_zoo.py +0 -0
  122. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_appzoo_commands.py +0 -0
  123. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_auth.py +0 -0
  124. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_auth0.py +0 -0
  125. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_cli.py +0 -0
  126. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_cli_stdio.py +0 -0
  127. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_cli_update.py +0 -0
  128. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_cli_update_rerun.py +0 -0
  129. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_deprecation.py +0 -0
  130. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_docker_utils.py +0 -0
  131. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_download.py +0 -0
  132. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_elxr_update.py +0 -0
  133. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_firmware.py +0 -0
  134. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_generate_cli_markdown_docs.py +0 -0
  135. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_install_stub.py +0 -0
  136. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_metadata_installer.py +0 -0
  137. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_metadata_validator.py +0 -0
  138. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_model_zoo.py +0 -0
  139. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_netboot.py +0 -0
  140. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_pkg_update_check.py +0 -0
  141. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_sdk_linux_shared_network.py +0 -0
  142. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_sdk_preinstall.py +0 -0
  143. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_sdk_uninstall.py +0 -0
  144. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_selfupdate.py +0 -0
  145. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_skills_commands.py +0 -0
  146. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_skills_manager.py +0 -0
  147. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_utils.py +0 -0
  148. {sima_cli-2.1.13 → sima_cli-2.1.14}/tests/unit/test_vulcan.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sima-cli
3
- Version: 2.1.13
3
+ Version: 2.1.14
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.13"
7
+ version = "2.1.14"
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.13"
2
+ __version__ = "2.1.14"
@@ -136,7 +136,17 @@ def _format_size(total_bytes: int) -> str:
136
136
 
137
137
  def _matches_exclude_pattern(resource: str, patterns: Sequence[str]) -> bool:
138
138
  filename = Path(resource).name
139
- return any(pattern and (pattern in resource or pattern in filename) for pattern in patterns)
139
+ path_parts = resource.split("/")
140
+ for pattern in patterns:
141
+ if not pattern:
142
+ continue
143
+ if pattern.startswith(".") and "/" not in pattern and "\\" not in pattern:
144
+ if filename.endswith(pattern) or pattern in path_parts:
145
+ return True
146
+ continue
147
+ if pattern in resource or pattern in filename:
148
+ return True
149
+ return False
140
150
 
141
151
 
142
152
  def collect_artifact_resources(
@@ -765,6 +765,22 @@ def _format_gb(byte_count: int) -> str:
765
765
  return f"{byte_count / (1024 ** 3):.1f} GB"
766
766
 
767
767
 
768
+ def _ensure_writable_sdk_extensions_dir(path: Path) -> Path:
769
+ resolved = Path(os.path.realpath(str(path)))
770
+ try:
771
+ resolved.mkdir(parents=True, exist_ok=True)
772
+ probe = resolved / ".sima-cli-write-test"
773
+ with open(probe, "w", encoding="utf-8") as handle:
774
+ handle.write("ok\n")
775
+ probe.unlink()
776
+ except OSError as e:
777
+ raise RuntimeError(
778
+ f"SDK extensions directory is not writable: {resolved}. "
779
+ "Choose another directory where your user can create files."
780
+ ) from e
781
+ return resolved
782
+
783
+
768
784
  def _setup_sdk_extensions(
769
785
  selected_images: List[str],
770
786
  noninteractive: bool = False,
@@ -791,12 +807,16 @@ def _setup_sdk_extensions(
791
807
  if noninteractive or yes_to_all:
792
808
  extensions_dir = default_extensions_dir
793
809
  click.echo(f"ℹ️ Using default SDK extensions directory: {extensions_dir}")
810
+ extensions_dir = _ensure_writable_sdk_extensions_dir(extensions_dir)
794
811
  else:
795
- response = input(f"Enter SDK extensions directory [{default_extensions_dir}]: ").strip()
796
- extensions_dir = Path(response).expanduser() if response else default_extensions_dir
797
-
798
- extensions_dir = Path(os.path.realpath(str(extensions_dir)))
799
- extensions_dir.mkdir(parents=True, exist_ok=True)
812
+ while True:
813
+ response = input(f"Enter SDK extensions directory [{default_extensions_dir}]: ").strip()
814
+ extensions_dir = Path(response).expanduser() if response else default_extensions_dir
815
+ try:
816
+ extensions_dir = _ensure_writable_sdk_extensions_dir(extensions_dir)
817
+ break
818
+ except RuntimeError as e:
819
+ click.secho(f"⚠️ {e}", fg="yellow")
800
820
  print(f"✅ SDK extensions directory configured: {extensions_dir}")
801
821
  return str(extensions_dir)
802
822
 
@@ -3,14 +3,19 @@ import json
3
3
  import os
4
4
  import platform
5
5
  import random
6
+ import secrets
6
7
  import shutil
7
8
  import socket
8
9
  import subprocess
10
+ import time
11
+ import webbrowser
9
12
  from dataclasses import dataclass
10
13
  from pathlib import Path
11
14
  from typing import Dict, List, Optional, Tuple
12
15
 
13
16
  import click
17
+ from rich.console import Console
18
+ from rich.panel import Panel
14
19
 
15
20
  from sima_cli.utils.net import get_local_ip_candidates
16
21
 
@@ -23,6 +28,9 @@ NEAT_MEDIAMTX_RTSP_TRANSPORTS = "tcp"
23
28
  NEAT_WEBRTC_UDP_SEARCH_START = 40000
24
29
  NEAT_WEBRTC_UDP_SEARCH_END = 65535
25
30
  NEAT_WEBRTC_UDP_PORT_COUNT = 200
31
+ OPENVSCODE_TOKEN_BYTES = 32
32
+ OPENVSCODE_TOKEN_CACHE_FILE = "sdk-code-ui-tokens.json"
33
+ console = Console()
26
34
 
27
35
 
28
36
  @dataclass
@@ -35,6 +43,8 @@ class NeatRunConfig:
35
43
  cert_file_host_path: str
36
44
  key_file_host_path: str
37
45
  webrtc_host_ip: str = ""
46
+ code_ui_token: str = ""
47
+ code_ui_supported: bool = True
38
48
 
39
49
 
40
50
  def _can_bind_tcp(port: int) -> bool:
@@ -173,6 +183,8 @@ def allocate_neat_ports(
173
183
  web_ssh = _allocate_single_port(8022, "tcp", reserved)
174
184
  rtsp_tcp = _allocate_single_port(8554, "tcp", reserved)
175
185
  main_ui = _allocate_single_port(9900, "tcp", reserved)
186
+ code_ui = _allocate_single_port(9999, "tcp", reserved)
187
+ code_ui_https = _allocate_single_port(10000, "tcp", reserved)
176
188
  video_ui = _allocate_single_port(8081, "tcp", reserved)
177
189
  video_udp_start, video_udp_end = _allocate_port_range(9000, 9079, "udp", reserved)
178
190
  metadata_udp_start, metadata_udp_end = _allocate_port_range(9100, 9179, "udp", reserved)
@@ -188,6 +200,8 @@ def allocate_neat_ports(
188
200
  port_map.update(
189
201
  {
190
202
  "mainUI": {"protocol": "tcp", "host": main_ui, "container": 9900},
203
+ "codeUI": {"protocol": "tcp", "host": code_ui, "container": 9999},
204
+ "codeUIHttps": {"protocol": "tcp", "host": code_ui_https, "container": 10000, "scheme": "https"},
191
205
  "videoUI": {"protocol": "tcp", "host": video_ui, "container": 8081},
192
206
  "webSSH": {"protocol": "tcp", "host": web_ssh, "container": 8022},
193
207
  "rtsp": {
@@ -220,6 +234,8 @@ def allocate_neat_ports(
220
234
  [
221
235
  f"{web_ssh}:8022/tcp",
222
236
  f"{main_ui}:9900/tcp",
237
+ f"{code_ui}:9999/tcp",
238
+ f"{code_ui_https}:10000/tcp",
223
239
  f"{video_ui}:8081/tcp",
224
240
  f"{rtsp_tcp}:8554/tcp",
225
241
  f"{video_udp_start}-{video_udp_end}:9000-9079/udp",
@@ -312,6 +328,30 @@ def _detect_webrtc_host_ip(devkit_env: Optional[dict]) -> str:
312
328
  return physical_ips[0] if physical_ips else ""
313
329
 
314
330
 
331
+ def _detect_browser_host_ip() -> str:
332
+ """Return the host IP to advertise/open for local browser access.
333
+
334
+ DevKit sync may legitimately use a VPN/tunnel IP as the WebRTC/ICE host.
335
+ Browser access to Docker-published SDK ports should prefer a physical host
336
+ address so local setup output is not broken by VPN routing.
337
+ """
338
+ physical_ips = _collect_physical_ipv4s_for_certs()
339
+ if physical_ips:
340
+ return physical_ips[0]
341
+
342
+ try:
343
+ for iface, ip in get_local_ip_candidates():
344
+ name = (iface or "").lower()
345
+ if name.startswith(("lo", "docker", "br-", "veth", "virbr", "tun", "tap", "utun", "wg", "tailscale", "zt", "vmnet", "vboxnet")):
346
+ continue
347
+ if _is_usable_ipv4(ip):
348
+ return ip
349
+ except Exception:
350
+ pass
351
+
352
+ return "localhost"
353
+
354
+
315
355
  def _is_wsl() -> bool:
316
356
  if os.getenv("WSL_DISTRO_NAME"):
317
357
  return True
@@ -486,8 +526,17 @@ def _ensure_certificates(cert_dir: Path, devkit_env: Optional[dict], yes_to_all:
486
526
  cert_dir.mkdir(parents=True, exist_ok=True)
487
527
  cert_file = cert_dir / "neat-sdk.pem"
488
528
  key_file = cert_dir / "neat-sdk-key.pem"
489
- mkcert = _ensure_mkcert(yes_to_all=yes_to_all, noninteractive=noninteractive)
490
529
  hosts = _collect_cert_hosts(devkit_env)
530
+ try:
531
+ mkcert = _ensure_mkcert(yes_to_all=yes_to_all, noninteractive=noninteractive)
532
+ except (RuntimeError, subprocess.CalledProcessError) as e:
533
+ click.secho(
534
+ f"⚠️ mkcert could not be installed: {e}. Falling back to a self-signed certificate.",
535
+ fg="yellow",
536
+ )
537
+ _generate_self_signed_cert(cert_file, key_file, hosts)
538
+ return cert_file, key_file
539
+
491
540
  if _run_mkcert_with_fallback([mkcert, "-install"], "install the local CA into the trust store"):
492
541
  subprocess.run(
493
542
  [mkcert, "-cert-file", str(cert_file), "-key-file", str(key_file), *hosts],
@@ -498,40 +547,66 @@ def _ensure_certificates(cert_dir: Path, devkit_env: Optional[dict], yes_to_all:
498
547
  return cert_file, key_file
499
548
 
500
549
 
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.
550
+ def _cert_san_hosts(cert_file: Path) -> Optional[List[str]]:
551
+ """Return the SAN entries of ``cert_file`` (both ``DNS:`` names and
552
+ ``IP Address:`` addresses), or ``None`` when the cert can't be read (no
553
+ openssl / parse error) so callers treat it as "unknown -> re-issue".
507
554
  """
508
- if not host_ip:
509
- return False
510
555
  try:
511
556
  result = subprocess.run(
512
557
  ["openssl", "x509", "-noout", "-ext", "subjectAltName", "-in", str(cert_file)],
513
558
  capture_output=True, text=True, check=False,
514
559
  )
515
560
  text = result.stdout or ""
516
- if result.returncode != 0 or "IP Address" not in text:
561
+ if result.returncode != 0 or ("IP Address" not in text and "DNS" not in text):
517
562
  result = subprocess.run(
518
563
  ["openssl", "x509", "-noout", "-text", "-in", str(cert_file)],
519
564
  capture_output=True, text=True, check=False,
520
565
  )
521
566
  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
567
  except OSError:
568
+ return None
569
+ # Parse the comma/newline-separated SAN entries as exact tokens (not
570
+ # substrings) so 192.168.1.5 is not treated as covered by
571
+ # IP Address:192.168.1.50.
572
+ hosts = []
573
+ for token in text.replace("\n", ",").split(","):
574
+ token = token.strip()
575
+ if token.startswith("IP Address:"):
576
+ hosts.append(token[len("IP Address:"):].strip())
577
+ elif token.startswith("DNS:"):
578
+ hosts.append(token[len("DNS:"):].strip())
579
+ return hosts
580
+
581
+
582
+ def _cert_covers_host(cert_file: Path, host_ip: str) -> bool:
583
+ """Return True if the cert's SAN lists ``host_ip``. False on any failure
584
+ (no openssl / parse error) so the caller re-issues to be safe."""
585
+ if not host_ip:
586
+ return False
587
+ san = _cert_san_hosts(cert_file)
588
+ return san is not None and host_ip in san
589
+
590
+
591
+ def _cert_covers_hosts(cert_file: Path, hosts: List[str]) -> bool:
592
+ """Return True only if the cert's SAN covers **every** host in ``hosts``.
593
+
594
+ On a re-point the set of host addresses can grow (e.g. a VPN/tunnel IP
595
+ appears) while the primary host IP is unchanged. Checking the full expected
596
+ list -- not just the primary IP -- ensures an older cert missing a newly
597
+ discovered address is re-issued rather than skipped. Returns False on any
598
+ failure so the caller re-issues to be safe; an empty ``hosts`` is treated as
599
+ "nothing to verify -> covered" (the caller's no-host-IP guard already handles
600
+ the do-nothing case).
601
+ """
602
+ wanted = [host for host in hosts if host]
603
+ if not wanted:
604
+ return True
605
+ san = _cert_san_hosts(cert_file)
606
+ if san is None:
534
607
  return False
608
+ san_set = set(san)
609
+ return all(host in san_set for host in wanted)
535
610
 
536
611
 
537
612
  def _container_sdk_cert_dir(container_name: str) -> Optional[Path]:
@@ -578,9 +653,11 @@ def refresh_neat_certificates(
578
653
  to pick up the rewritten file from the bind-mounted cert dir.
579
654
 
580
655
  (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.
656
+ SAN doesn't cover **every** currently expected host -- the primary host IP
657
+ *and* all alternate addresses (e.g. VPN/tunnel IPs) -- so an older cert that
658
+ covers only the primary IP is still refreshed. No-op (returns False) only when
659
+ there is no usable host IP, or a valid cert already covers the full host list.
660
+ Returns True when a cert was generated.
584
661
  """
585
662
  host_ip = (devkit_env or {}).get("host_ip", "")
586
663
  if not host_ip:
@@ -592,7 +669,12 @@ def refresh_neat_certificates(
592
669
  cert_dir = _container_sdk_cert_dir(container_name) \
593
670
  or (Path(workspace) / f".{container_name}" / "sdk-cert")
594
671
  cert_file = cert_dir / "neat-sdk.pem"
595
- if cert_file.exists() and _cert_covers_host(cert_file, host_ip):
672
+ # Re-issue unless the existing cert already covers the full set of hosts we
673
+ # would issue for now (primary host IP + all alternate IPv4s incl. VPN/tunnel),
674
+ # not just the primary host_ip -- otherwise a newly discovered address is
675
+ # silently left out on a re-run.
676
+ expected_hosts = _collect_cert_hosts(devkit_env)
677
+ if cert_file.exists() and _cert_covers_hosts(cert_file, expected_hosts):
596
678
  return False
597
679
  action = "Re-issuing" if cert_file.exists() else "Generating"
598
680
  print(f"🔐 {action} Neat SDK certificate for host IP {host_ip} ...")
@@ -609,6 +691,61 @@ def _write_port_map(config_dir: Path, port_map: Dict) -> Path:
609
691
  return path
610
692
 
611
693
 
694
+ def _generate_code_ui_token() -> str:
695
+ return secrets.token_urlsafe(OPENVSCODE_TOKEN_BYTES)
696
+
697
+
698
+ def _sima_cli_home() -> Path:
699
+ return Path(os.environ.get("SIMA_CLI_HOME", str(Path.home() / ".sima-cli"))).expanduser()
700
+
701
+
702
+ def _code_ui_token_cache_path() -> Path:
703
+ return _sima_cli_home() / OPENVSCODE_TOKEN_CACHE_FILE
704
+
705
+
706
+ def _read_code_ui_token_cache() -> Dict:
707
+ path = _code_ui_token_cache_path()
708
+ if not path.exists():
709
+ return {}
710
+ try:
711
+ data = json.loads(path.read_text(encoding="utf-8"))
712
+ except (OSError, json.JSONDecodeError):
713
+ return {}
714
+ return data if isinstance(data, dict) else {}
715
+
716
+
717
+ def _write_code_ui_token_cache(container_name: str, base_url: str, token: str) -> Path:
718
+ cache_dir = _sima_cli_home()
719
+ cache_dir.mkdir(parents=True, exist_ok=True)
720
+ try:
721
+ cache_dir.chmod(0o700)
722
+ except OSError:
723
+ pass
724
+
725
+ cache = _read_code_ui_token_cache()
726
+ entries = cache.setdefault("containers", {})
727
+ if not isinstance(entries, dict):
728
+ entries = {}
729
+ cache["containers"] = entries
730
+ host_port = int(base_url.rsplit(":", 1)[1])
731
+ entries[container_name] = {
732
+ "codeUI": {
733
+ "host": host_port,
734
+ "token": token,
735
+ "url": f"{base_url}/?tkn={token}&folder=/workspace",
736
+ },
737
+ "updated_at": int(time.time()),
738
+ }
739
+
740
+ path = _code_ui_token_cache_path()
741
+ path.write_text(json.dumps(cache, indent=2, sort_keys=True) + "\n", encoding="utf-8")
742
+ try:
743
+ path.chmod(0o600)
744
+ except OSError:
745
+ pass
746
+ return path
747
+
748
+
612
749
  def reserved_ports_from_neat_port_map(port_map: Dict) -> set:
613
750
  reserved = set()
614
751
  for entry in port_map.values():
@@ -669,6 +806,15 @@ def prepare_neat_container_run(
669
806
  yes_to_all=yes_to_all,
670
807
  noninteractive=noninteractive,
671
808
  )
809
+ code_ui_token = ""
810
+ code_ui = port_map.get("codeUI")
811
+ if isinstance(code_ui, dict):
812
+ code_ui_token = _generate_code_ui_token()
813
+ code_ui_https = port_map.get("codeUIHttps")
814
+ code_ui_url = f"http://localhost:{int(code_ui['host'])}"
815
+ if isinstance(code_ui_https, dict):
816
+ code_ui_url = f"https://localhost:{int(code_ui_https['host'])}"
817
+ _write_code_ui_token_cache(container_name, code_ui_url, code_ui_token)
672
818
  port_map_path = _write_port_map(config_dir, port_map)
673
819
  return NeatRunConfig(
674
820
  port_map=port_map,
@@ -679,6 +825,7 @@ def prepare_neat_container_run(
679
825
  cert_file_host_path=str(cert_file),
680
826
  key_file_host_path=str(key_file),
681
827
  webrtc_host_ip=webrtc_host_ip,
828
+ code_ui_token=code_ui_token,
682
829
  )
683
830
 
684
831
 
@@ -687,6 +834,15 @@ def append_neat_docker_args(docker_cmd: List[str], config: NeatRunConfig) -> Non
687
834
  docker_cmd.extend(["-e", f"MTX_RTSPTRANSPORTS={NEAT_MEDIAMTX_RTSP_TRANSPORTS}"])
688
835
  if config.webrtc_host_ip:
689
836
  docker_cmd.extend(["-e", f"CONTAINER_HOST_IP={config.webrtc_host_ip}"])
837
+ if config.code_ui_token:
838
+ docker_cmd.extend(["-e", f"OPENVSCODE_SERVER_TOKEN={config.code_ui_token}"])
839
+ cert_config = config.port_map.get("cert")
840
+ if config.cert_host_dir and isinstance(cert_config, dict):
841
+ docker_cmd.extend(["-e", f"OPENVSCODE_SERVER_CERT={cert_config['certFile']}"])
842
+ docker_cmd.extend(["-e", f"OPENVSCODE_SERVER_CERT_KEY={cert_config['keyFile']}"])
843
+ code_ui_https = config.port_map.get("codeUIHttps")
844
+ if isinstance(code_ui_https, dict):
845
+ docker_cmd.extend(["-e", f"OPENVSCODE_SERVER_HTTPS_PORT={code_ui_https['container']}"])
690
846
  for mapping in config.port_args:
691
847
  docker_cmd.extend(["-p", mapping])
692
848
  if config.config_host_dir:
@@ -695,50 +851,124 @@ def append_neat_docker_args(docker_cmd: List[str], config: NeatRunConfig) -> Non
695
851
  docker_cmd.extend(["-v", f"{config.cert_host_dir}:/sdk-cert"])
696
852
 
697
853
 
854
+ def _code_ui_url(config: NeatRunConfig, display_host: str) -> str:
855
+ port_map = config.port_map
856
+ if not config.code_ui_supported or "codeUI" not in port_map:
857
+ return ""
858
+
859
+ display_entry = port_map.get("codeUIHttps") if config.cert_host_dir else None
860
+ if not isinstance(display_entry, dict):
861
+ display_entry = port_map["codeUI"]
862
+ scheme = display_entry.get("scheme", "http")
863
+ code_url = f"{scheme}://{display_host}:{display_entry['host']}"
864
+ token = config.code_ui_token or port_map["codeUI"].get("token")
865
+ if token:
866
+ code_url = f"{code_url}/?tkn={token}&folder=/workspace"
867
+ return code_url
868
+
869
+
870
+ def _open_code_ui_if_browser_available(code_url: str) -> bool:
871
+ if not code_url:
872
+ return False
873
+ try:
874
+ webbrowser.get()
875
+ except webbrowser.Error:
876
+ return False
877
+ try:
878
+ return bool(webbrowser.open(code_url))
879
+ except Exception:
880
+ return False
881
+
882
+
883
+ def _print_code_ui_panel(code_url: str, opened: bool) -> None:
884
+ if not code_url:
885
+ return
886
+ status = "Opened in your browser." if opened else "Open this URL in your browser."
887
+ console.print(
888
+ Panel(
889
+ "\n".join(
890
+ [
891
+ f"[bold]{status}[/bold]",
892
+ f"[cyan]{code_url}[/cyan]",
893
+ "",
894
+ "[yellow]Security:[/yellow] Do not share this URL. It contains a Code UI access token.",
895
+ ]
896
+ ),
897
+ title="VS Code Browser Access",
898
+ border_style="cyan",
899
+ expand=False,
900
+ )
901
+ )
902
+
903
+
698
904
  def print_neat_setup_summary(config: NeatRunConfig) -> None:
699
905
  port_map = config.port_map
700
- print("🔌 Neat SDK port map:")
906
+ rows = []
907
+ display_host = _detect_browser_host_ip()
908
+ web_scheme = "https" if config.cert_host_dir else "http"
909
+ code_url = _code_ui_url(config, display_host)
701
910
  if "mainUI" in port_map:
702
- print(f" mainUI: http://localhost:{port_map['mainUI']['host']}")
911
+ rows.append(("mainUI", f"{web_scheme}://{display_host}:{port_map['mainUI']['host']}"))
912
+ if code_url:
913
+ rows.append(("codeUI", code_url))
914
+ if "codeUIHttps" in port_map:
915
+ rows.append(("codeUIHttp", f"http://{display_host}:{port_map['codeUI']['host']}"))
703
916
  if "videoUI" in port_map:
704
- print(f" videoUI: http://localhost:{port_map['videoUI']['host']}")
917
+ rows.append(("videoUI", f"{web_scheme}://{display_host}:{port_map['videoUI']['host']}"))
705
918
  if "webSSH" in port_map:
706
- print(f" webSSH: http://localhost:{port_map['webSSH']['host']}")
919
+ rows.append(("webSSH", f"http://{display_host}:{port_map['webSSH']['host']}"))
707
920
  if "rtsp" in port_map:
708
- print(f" rtsp: rtsp://localhost:{port_map['rtsp']['tcp']['host']}")
921
+ rows.append(("rtsp", f"rtsp://{display_host}:{port_map['rtsp']['tcp']['host']}"))
709
922
  if "videoUDP" in port_map:
710
- print(
711
- " videoUDP: {}-{}/udp -> {}-{}/udp".format(
923
+ rows.append(
924
+ ("videoUDP", "{}-{}/udp -> {}-{}/udp".format(
712
925
  port_map["videoUDP"]["hostStart"],
713
926
  port_map["videoUDP"]["hostEnd"],
714
927
  port_map["videoUDP"]["containerStart"],
715
928
  port_map["videoUDP"]["containerEnd"],
716
- )
929
+ ))
717
930
  )
718
931
  if "metadataUDP" in port_map:
719
- print(
720
- " metadataUDP: {}-{}/udp -> {}-{}/udp".format(
932
+ rows.append(
933
+ ("metadataUDP", "{}-{}/udp -> {}-{}/udp".format(
721
934
  port_map["metadataUDP"]["hostStart"],
722
935
  port_map["metadataUDP"]["hostEnd"],
723
936
  port_map["metadataUDP"]["containerStart"],
724
937
  port_map["metadataUDP"]["containerEnd"],
725
- )
938
+ ))
726
939
  )
727
940
  if "webRTC" in port_map:
728
- print(
729
- " webRTC: {}-{}/udp -> {}-{}/udp".format(
941
+ rows.append(
942
+ ("webRTC", "{}-{}/udp -> {}-{}/udp".format(
730
943
  port_map["webRTC"]["hostStart"],
731
944
  port_map["webRTC"]["hostEnd"],
732
945
  port_map["webRTC"]["containerStart"],
733
946
  port_map["webRTC"]["containerEnd"],
734
- )
947
+ ))
735
948
  )
736
949
  if config.webrtc_host_ip:
737
- print(f" iceHost: {config.webrtc_host_ip}")
950
+ rows.append(("iceHost", config.webrtc_host_ip))
738
951
  if config.port_map_host_path:
739
- print(f" config: {config.port_map_host_path}")
952
+ rows.append(("config", config.port_map_host_path))
740
953
  if config.cert_host_dir:
741
- print(f" certs: {config.cert_host_dir}")
954
+ rows.append(("certs", config.cert_host_dir))
955
+
956
+ print("🔌 Neat SDK port map:")
957
+ if not rows:
958
+ print(" No Neat SDK ports are mapped.")
959
+ return
960
+
961
+ name_width = max(len("Name"), *(len(name) for name, _ in rows))
962
+ print(f" {'Name'.ljust(name_width)} | Endpoint / Value")
963
+ print(f" {'-' * name_width}-+-{'-' * len('Endpoint / Value')}")
964
+ for name, value in rows:
965
+ print(f" {name.ljust(name_width)} | {value}")
966
+ if display_host == "localhost":
967
+ print(" Note: Use localhost for local access, or replace it with this machine's external IP/DNS name for remote access.")
968
+ else:
969
+ print(" Note: Use the shown host IP for remote access, or replace it with localhost/127.0.0.1 for local access.")
970
+ opened = _open_code_ui_if_browser_available(code_url)
971
+ _print_code_ui_panel(code_url, opened)
742
972
 
743
973
 
744
974
  def is_docker_port_collision_error(error_text: str) -> bool:
@@ -324,7 +324,7 @@ def port_specs_from_port_map(port_map: Optional[dict]) -> List[PortSpec]:
324
324
  return []
325
325
 
326
326
  specs: List[PortSpec] = []
327
- for name in ("mainUI", "videoUI", "webSSH"):
327
+ for name in ("mainUI", "codeUI", "codeUIHttps", "videoUI", "webSSH"):
328
328
  entry = port_map.get(name)
329
329
  if not isinstance(entry, dict):
330
330
  continue