devs-common 3.3.0__tar.gz → 3.3.2__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 (43) hide show
  1. {devs_common-3.3.0 → devs_common-3.3.2}/PKG-INFO +1 -1
  2. devs_common-3.3.2/devs_common/core/__init__.py +22 -0
  3. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/core/container.py +248 -118
  4. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common.egg-info/PKG-INFO +1 -1
  5. {devs_common-3.3.0 → devs_common-3.3.2}/pyproject.toml +1 -1
  6. devs_common-3.3.0/devs_common/core/__init__.py +0 -13
  7. {devs_common-3.3.0 → devs_common-3.3.2}/LICENSE +0 -0
  8. {devs_common-3.3.0 → devs_common-3.3.2}/README.md +0 -0
  9. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/__init__.py +0 -0
  10. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/config.py +0 -0
  11. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/core/project.py +0 -0
  12. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/core/workspace.py +0 -0
  13. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/devs_config.py +0 -0
  14. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/exceptions.py +0 -0
  15. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/templates/Dockerfile +0 -0
  16. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/templates/devcontainer.json +0 -0
  17. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/templates/scripts/copy-ssh.sh +0 -0
  18. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/templates/scripts/post-create-wrapper.sh +0 -0
  19. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/templates/scripts/python-venv-integration.sh +0 -0
  20. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/templates/scripts/screenshot-webapp.sh +0 -0
  21. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/templates/scripts/setup-workspace.sh +0 -0
  22. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/templates/scripts/start-tunnel.sh +0 -0
  23. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/templates/scripts/tunnel-status.sh +0 -0
  24. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/templates/scripts/webapp-screenshot.py +0 -0
  25. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/templates/sudo-scripts/init-firewall.sh +0 -0
  26. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/templates/sudo-scripts/setup-devs-env.sh +0 -0
  27. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/templates/sudo-scripts/setup-vscode-cache.sh +0 -0
  28. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/templates/sudo-scripts/start-services.sh +0 -0
  29. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/templates/vscode-settings-reference.md +0 -0
  30. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/utils/__init__.py +0 -0
  31. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/utils/config_hash.py +0 -0
  32. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/utils/console.py +0 -0
  33. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/utils/devcontainer.py +0 -0
  34. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/utils/devcontainer_template.py +0 -0
  35. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/utils/docker_client.py +0 -0
  36. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/utils/file_utils.py +0 -0
  37. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/utils/git_utils.py +0 -0
  38. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common/utils/repo_cache.py +0 -0
  39. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common.egg-info/SOURCES.txt +0 -0
  40. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common.egg-info/dependency_links.txt +0 -0
  41. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common.egg-info/requires.txt +0 -0
  42. {devs_common-3.3.0 → devs_common-3.3.2}/devs_common.egg-info/top_level.txt +0 -0
  43. {devs_common-3.3.0 → devs_common-3.3.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devs-common
3
- Version: 3.3.0
3
+ Version: 3.3.2
4
4
  Summary: Shared utilities for devs package ecosystem
5
5
  Author: Dan Lester
6
6
  License-Expression: MIT
@@ -0,0 +1,22 @@
1
+ """Core classes for devs ecosystem."""
2
+
3
+ from .project import Project, ProjectInfo
4
+ from .workspace import WorkspaceManager
5
+ from .container import (
6
+ ContainerManager,
7
+ ContainerInfo,
8
+ make_tunnel_name,
9
+ get_container_workspace_dir,
10
+ kill_tunnel_processes,
11
+ )
12
+
13
+ __all__ = [
14
+ "Project",
15
+ "ProjectInfo",
16
+ "WorkspaceManager",
17
+ "ContainerManager",
18
+ "ContainerInfo",
19
+ "make_tunnel_name",
20
+ "get_container_workspace_dir",
21
+ "kill_tunnel_processes",
22
+ ]
@@ -47,6 +47,55 @@ from .project import Project
47
47
  console = get_console()
48
48
 
49
49
 
50
+ def make_tunnel_name(container_name: str) -> str:
51
+ """Derive a VS Code tunnel name from a container name (max 20 chars).
52
+
53
+ VS Code tunnel names have a 20 character limit. We keep the dev name
54
+ suffix and shorten the project portion if needed.
55
+
56
+ Container names are like dev-ideonate-devs-sally.
57
+ """
58
+ name = container_name.replace(".", "-").replace("_", "-")
59
+ if len(name) <= 20:
60
+ return name
61
+ # Keep the last segment (dev name) and truncate the rest
62
+ parts = name.rsplit("-", 1)
63
+ if len(parts) == 2:
64
+ suffix = f"-{parts[1]}"
65
+ budget = 20 - len(suffix)
66
+ if budget >= 3:
67
+ return parts[0][:budget].rstrip("-") + suffix
68
+ return name[:20]
69
+
70
+
71
+ def get_container_workspace_dir(container_name: str) -> str:
72
+ """Derive the workspace path inside a container from its name.
73
+
74
+ Container name 'dev-ideonate-devs-bob' -> '/workspaces/ideonate-devs-bob'.
75
+ """
76
+ if container_name.startswith("dev-"):
77
+ workspace_name = container_name[4:]
78
+ else:
79
+ workspace_name = container_name
80
+ return f"/workspaces/{workspace_name}"
81
+
82
+
83
+ def kill_tunnel_processes(container_name: str) -> None:
84
+ """Kill all tunnel processes in a container.
85
+
86
+ Uses both 'code tunnel kill' (service-level) and pkill (process-level)
87
+ to ensure detached shell wrappers and tunnel processes are fully terminated.
88
+ """
89
+ subprocess.run(
90
+ ['docker', 'exec', container_name, '/usr/local/bin/code', 'tunnel', 'kill'],
91
+ capture_output=True
92
+ )
93
+ subprocess.run(
94
+ ['docker', 'exec', container_name, 'pkill', '-f', 'code tunnel'],
95
+ capture_output=True
96
+ )
97
+
98
+
50
99
  class ContainerInfo:
51
100
  """Information about a devcontainer."""
52
101
 
@@ -852,27 +901,11 @@ class ContainerManager:
852
901
  )
853
902
 
854
903
  def _get_tunnel_name(self, dev_name: str) -> str:
855
- """Generate a VS Code tunnel name, truncated to 20 chars.
856
-
857
- VS Code tunnel names have a 20 character limit. We keep the dev name
858
- suffix and shorten the project portion if needed.
859
- """
860
- project_prefix = self.config.project_prefix if self.config else "dev"
861
- tunnel_name = f"{project_prefix}-{self.project.info.name}-{dev_name}"
862
- # Sanitize: tunnel names can only contain alphanumeric, dash, underscore
863
- tunnel_name = tunnel_name.replace(".", "-").replace("_", "-")
864
-
865
- if len(tunnel_name) > 20:
866
- suffix = f"-{dev_name}"
867
- prefix_budget = 20 - len(suffix)
868
- if prefix_budget >= 3:
869
- project_part = f"{project_prefix}-{self.project.info.name}"
870
- project_part = project_part[:prefix_budget].rstrip("-")
871
- tunnel_name = f"{project_part}{suffix}"
872
- else:
873
- tunnel_name = tunnel_name[:20]
874
-
875
- return tunnel_name
904
+ """Generate a VS Code tunnel name for this project/dev combo."""
905
+ container_name = self.project.get_container_name(
906
+ dev_name, self.config.project_prefix if self.config else "dev"
907
+ )
908
+ return make_tunnel_name(container_name)
876
909
 
877
910
  def _run_tunnel_auth(self, container_name: str, debug: bool = False) -> bool:
878
911
  """Run interactive VS Code tunnel authentication in a container.
@@ -908,20 +941,31 @@ class ContainerManager:
908
941
  console.print("[yellow]Authentication was cancelled or failed.[/yellow]")
909
942
  return False
910
943
 
911
- def _start_and_poll_tunnel(self, container_name: str, dev_name: str, tunnel_cmd: str,
912
- log_file: str, vscode_cmd: str, web_url: str, debug: bool = False) -> bool:
944
+ def _kill_tunnel_processes(self, container_name: str) -> None:
945
+ """Kill all tunnel processes in the container."""
946
+ kill_tunnel_processes(container_name)
947
+
948
+ def _start_and_poll_tunnel(self, container_name: str, tunnel_cmd: str,
949
+ log_file: str, debug: bool = False) -> dict:
913
950
  """Start a detached tunnel and poll the log for startup confirmation.
914
951
 
915
952
  Returns:
916
- True if tunnel started (or timed out), False if auth is needed.
953
+ Dict with 'status' key: 'running', 'auth_required', or 'starting'.
917
954
  """
955
+ # Kill any stale tunnel processes and clear the log before starting
956
+ self._kill_tunnel_processes(container_name)
957
+ subprocess.run(
958
+ ['docker', 'exec', container_name, '/bin/sh', '-c', f'> {log_file}'],
959
+ capture_output=True
960
+ )
961
+ time.sleep(1)
962
+
918
963
  subprocess.run(
919
964
  ['docker', 'exec', '-d', container_name,
920
965
  '/bin/sh', '-c', f'{tunnel_cmd} > {log_file} 2>&1'],
921
966
  check=True
922
967
  )
923
968
 
924
- console.print("[dim]Waiting for tunnel to start...[/dim]")
925
969
  for i in range(15):
926
970
  time.sleep(1)
927
971
  result = subprocess.run(
@@ -931,22 +975,11 @@ class ContainerManager:
931
975
  output = result.stdout
932
976
 
933
977
  if 'Open this link' in output or 'vscode.dev/tunnel' in output:
934
- console.print(f"[bold green]Tunnel running![/bold green]")
935
- console.print("")
936
- console.print("[bold]Connect:[/bold]")
937
- console.print(f" Desktop: {vscode_cmd}")
938
- console.print(f" Browser: {web_url}")
939
- console.print("")
940
- console.print(f"[dim]Use 'devs tunnel {dev_name} --status' to check status[/dim]")
941
- console.print(f"[dim]Use 'devs tunnel {dev_name} --kill' to stop[/dim]")
942
- return True
978
+ return {'status': 'running'}
943
979
 
944
980
  if 'log in' in output.lower() or 'device' in output.lower() or 'invalid' in output.lower():
945
- subprocess.run(
946
- ['docker', 'exec', container_name, '/usr/local/bin/code', 'tunnel', 'kill'],
947
- capture_output=True
948
- )
949
- return False
981
+ self._kill_tunnel_processes(container_name)
982
+ return {'status': 'auth_required'}
950
983
 
951
984
  if debug and output.strip():
952
985
  console.print(f"[dim]Log ({i+1}s): {output.strip()[-100:]}[/dim]")
@@ -956,16 +989,30 @@ class ContainerManager:
956
989
  ['docker', 'exec', container_name, 'cat', log_file],
957
990
  capture_output=True, text=True
958
991
  )
959
- if result.stdout.strip():
960
- console.print("[yellow]Tunnel may still be starting. Log output:[/yellow]")
961
- console.print(result.stdout.strip())
962
- else:
963
- console.print("[yellow]Tunnel started but no output yet. Check status with:[/yellow]")
964
- console.print(f" devs tunnel {dev_name} --status")
965
- return True
992
+ return {'status': 'starting', 'log': result.stdout.strip()}
993
+
994
+ def get_tunnel_info(self, dev_name: str, workspace_dir: Path, debug: bool = False,
995
+ live: bool = False, extra_env: Optional[Dict[str, str]] = None) -> dict:
996
+ """Get tunnel connection info (names, paths) without starting anything.
997
+
998
+ Returns:
999
+ Dict with container_name, tunnel_name, container_workspace_dir,
1000
+ vscode_cmd, web_url.
1001
+ """
1002
+ container_name, container_workspace_dir = self._prepare_container_exec(
1003
+ dev_name, workspace_dir, debug=debug, live=live, extra_env=extra_env, reuse_existing=True
1004
+ )
1005
+ tunnel_name = self._get_tunnel_name(dev_name)
1006
+ return {
1007
+ 'container_name': container_name,
1008
+ 'tunnel_name': tunnel_name,
1009
+ 'container_workspace_dir': container_workspace_dir,
1010
+ 'vscode_cmd': f"code --remote tunnel+{tunnel_name} {container_workspace_dir}",
1011
+ 'web_url': f"https://vscode.dev/tunnel/{tunnel_name}{container_workspace_dir}",
1012
+ }
966
1013
 
967
1014
  def tunnel_auth(self, dev_name: str, workspace_dir: Path, debug: bool = False, live: bool = False, extra_env: Optional[Dict[str, str]] = None) -> None:
968
- """Authenticate VS Code tunnel (interactive, per-container setup).
1015
+ """Authenticate VS Code tunnel (interactive CLI, per-container setup).
969
1016
 
970
1017
  Args:
971
1018
  dev_name: Development environment name
@@ -978,26 +1025,22 @@ class ContainerManager:
978
1025
  ContainerError: If authentication fails
979
1026
  """
980
1027
  try:
981
- container_name, container_workspace_dir = self._prepare_container_exec(
982
- dev_name, workspace_dir, debug=debug, live=live, extra_env=extra_env, reuse_existing=True
983
- )
984
-
985
- tunnel_name = self._get_tunnel_name(dev_name)
1028
+ info = self.get_tunnel_info(dev_name, workspace_dir, debug=debug, live=live, extra_env=extra_env)
986
1029
 
987
1030
  console.print("🔐 Setting up VS Code tunnel authentication...")
988
- console.print(f" Container: {container_name}")
1031
+ console.print(f" Container: {info['container_name']}")
989
1032
  console.print("")
990
1033
 
991
- if self._run_tunnel_auth(container_name, debug=debug):
1034
+ if self._run_tunnel_auth(info['container_name'], debug=debug):
992
1035
  console.print("")
993
1036
  console.print(f" Start a tunnel: devs tunnel {dev_name}")
994
- console.print(f" Then connect: code --remote tunnel+{tunnel_name} {container_workspace_dir}")
1037
+ console.print(f" Then connect: {info['vscode_cmd']}")
995
1038
 
996
1039
  except (DockerError, subprocess.SubprocessError) as e:
997
1040
  raise ContainerError(f"Failed to authenticate tunnel in {dev_name}: {e}")
998
1041
 
999
1042
  def start_tunnel(self, dev_name: str, workspace_dir: Path, debug: bool = False, live: bool = False, extra_env: Optional[Dict[str, str]] = None) -> None:
1000
- """Start a VS Code tunnel in the background.
1043
+ """Start a VS Code tunnel in the background (CLI version with interactive auth).
1001
1044
 
1002
1045
  If authentication is needed, prompts interactively then retries.
1003
1046
 
@@ -1012,90 +1055,191 @@ class ContainerManager:
1012
1055
  ContainerError: If tunnel startup fails
1013
1056
  """
1014
1057
  try:
1015
- container_name, container_workspace_dir = self._prepare_container_exec(
1016
- dev_name, workspace_dir, debug=debug, live=live, extra_env=extra_env, reuse_existing=True
1017
- )
1018
-
1019
- tunnel_name = self._get_tunnel_name(dev_name)
1058
+ info = self.get_tunnel_info(dev_name, workspace_dir, debug=debug, live=live, extra_env=extra_env)
1059
+ container_name = info['container_name']
1060
+ tunnel_name = info['tunnel_name']
1020
1061
  log_file = '/tmp/vscode-tunnel.log'
1021
1062
  tunnel_cmd = f'/usr/local/bin/code tunnel --accept-server-license-terms --name {tunnel_name}'
1022
- vscode_cmd = f"code --remote tunnel+{tunnel_name} {container_workspace_dir}"
1023
- web_url = f"https://vscode.dev/tunnel/{tunnel_name}{container_workspace_dir}"
1024
1063
 
1025
1064
  console.print(f"[bold cyan]Starting VS Code tunnel for: {dev_name}[/bold cyan]")
1026
1065
  console.print(f" Container: {container_name}")
1027
1066
  console.print(f" Tunnel name: {tunnel_name}")
1028
1067
  console.print("")
1029
1068
 
1030
- if not self._start_and_poll_tunnel(container_name, dev_name, tunnel_cmd,
1031
- log_file, vscode_cmd, web_url, debug=debug):
1032
- # Auth needed
1069
+ result = self._start_and_poll_tunnel(container_name, tunnel_cmd, log_file, debug=debug)
1070
+
1071
+ if result['status'] == 'running':
1072
+ console.print(f"[bold green]Tunnel running![/bold green]")
1073
+ console.print("")
1074
+ console.print("[bold]Connect:[/bold]")
1075
+ console.print(f" Desktop: {info['vscode_cmd']}")
1076
+ console.print(f" Browser: {info['web_url']}")
1077
+ console.print("")
1078
+ console.print(f"[dim]Use 'devs tunnel {dev_name} --status' to check status[/dim]")
1079
+ console.print(f"[dim]Use 'devs tunnel {dev_name} --kill' to stop[/dim]")
1080
+ elif result['status'] == 'auth_required':
1033
1081
  console.print("[yellow]Authentication required. Starting login...[/yellow]")
1034
1082
  console.print("")
1035
1083
  if not self._run_tunnel_auth(container_name, debug=debug):
1036
1084
  return
1037
-
1038
1085
  console.print("")
1039
1086
  console.print("[dim]Restarting tunnel...[/dim]")
1040
1087
  console.print("")
1041
- self._start_and_poll_tunnel(container_name, dev_name, tunnel_cmd,
1042
- log_file, vscode_cmd, web_url, debug=debug)
1088
+ retry = self._start_and_poll_tunnel(container_name, tunnel_cmd, log_file, debug=debug)
1089
+ if retry['status'] == 'running':
1090
+ console.print(f"[bold green]Tunnel running![/bold green]")
1091
+ console.print("")
1092
+ console.print("[bold]Connect:[/bold]")
1093
+ console.print(f" Desktop: {info['vscode_cmd']}")
1094
+ console.print(f" Browser: {info['web_url']}")
1095
+ console.print("")
1096
+ console.print(f"[dim]Use 'devs tunnel {dev_name} --status' to check status[/dim]")
1097
+ console.print(f"[dim]Use 'devs tunnel {dev_name} --kill' to stop[/dim]")
1098
+ else:
1099
+ log = retry.get('log', '')
1100
+ if log:
1101
+ console.print("[yellow]Tunnel may still be starting. Log output:[/yellow]")
1102
+ console.print(log)
1103
+ console.print(f" devs tunnel {dev_name} --status")
1104
+ else:
1105
+ log = result.get('log', '')
1106
+ if log:
1107
+ console.print("[yellow]Tunnel may still be starting. Log output:[/yellow]")
1108
+ console.print(log)
1109
+ else:
1110
+ console.print("[yellow]Tunnel started but no output yet. Check status with:[/yellow]")
1111
+ console.print(f" devs tunnel {dev_name} --status")
1043
1112
 
1044
1113
  except (DockerError, subprocess.SubprocessError) as e:
1045
1114
  raise ContainerError(f"Failed to start tunnel in {dev_name}: {e}")
1046
1115
 
1047
1116
  def get_tunnel_status(self, dev_name: str, workspace_dir: Path, debug: bool = False, live: bool = False, extra_env: Optional[Dict[str, str]] = None) -> tuple[bool, str]:
1048
- """Get VS Code tunnel status in the container.
1117
+ """Get VS Code tunnel status, formatted for CLI display.
1049
1118
 
1050
- Args:
1051
- dev_name: Development environment name
1052
- workspace_dir: Workspace directory path
1053
- debug: Show debug output for devcontainer operations
1054
- live: Whether the container is in live mode
1055
- extra_env: Additional environment variables to pass to container
1119
+ Returns:
1120
+ Tuple of (is_running, formatted_status_message)
1121
+ """
1122
+ data = self.get_tunnel_status_data(dev_name, workspace_dir, debug=debug, live=live, extra_env=extra_env)
1123
+ return data.get('running', False), self._format_tunnel_status(data)
1124
+
1125
+ def get_tunnel_status_data(self, dev_name: str, workspace_dir: Path, debug: bool = False,
1126
+ live: bool = False, extra_env: Optional[Dict[str, str]] = None) -> dict:
1127
+ """Get VS Code tunnel status as structured data.
1056
1128
 
1057
1129
  Returns:
1058
- Tuple of (is_running, status_message)
1130
+ Dict with running, tunnel_name, web_url, vscode_cmd, raw_status, auth_info.
1059
1131
 
1060
1132
  Raises:
1061
1133
  ContainerError: If status check fails
1062
1134
  """
1063
1135
  try:
1064
- # Prepare container for execution
1065
- container_name, container_workspace_dir = self._prepare_container_exec(
1066
- dev_name, workspace_dir, debug=debug, live=live, extra_env=extra_env, reuse_existing=True
1067
- )
1068
-
1069
- # Check tunnel status
1070
- cmd = [
1071
- 'docker', 'exec',
1072
- container_name,
1073
- '/usr/local/bin/code', 'tunnel', 'status'
1074
- ]
1136
+ info = self.get_tunnel_info(dev_name, workspace_dir, debug=debug, live=live, extra_env=extra_env)
1137
+ container_name = info['container_name']
1075
1138
 
1139
+ cmd = ['docker', 'exec', container_name,
1140
+ '/usr/local/bin/code', 'tunnel', 'status']
1076
1141
  if debug:
1077
1142
  console.print(f"[dim]Running: {' '.join(cmd)}[/dim]")
1078
1143
 
1079
1144
  result = subprocess.run(cmd, capture_output=True, text=True)
1080
1145
 
1146
+ # Get auth info
1147
+ user_result = subprocess.run(
1148
+ ['docker', 'exec', container_name,
1149
+ '/usr/local/bin/code', 'tunnel', 'user', 'show'],
1150
+ capture_output=True, text=True
1151
+ )
1152
+ auth_info = user_result.stdout.strip() if user_result.returncode == 0 else None
1153
+
1154
+ import json
1155
+ raw_status = {}
1081
1156
  if result.returncode == 0:
1082
- return True, result.stdout.strip()
1083
- else:
1084
- return False, result.stderr.strip() if result.stderr else "No tunnel running"
1157
+ try:
1158
+ raw_status = json.loads(result.stdout.strip())
1159
+ except json.JSONDecodeError:
1160
+ pass
1161
+
1162
+ tunnel_data = raw_status.get('tunnel') or {}
1163
+ is_running = result.returncode == 0 and tunnel_data.get('tunnel') == 'Connected'
1164
+
1165
+ return {
1166
+ 'running': is_running,
1167
+ 'tunnel_name': info['tunnel_name'],
1168
+ 'web_url': info['web_url'] if is_running else None,
1169
+ 'vscode_cmd': info['vscode_cmd'] if is_running else None,
1170
+ 'auth_info': auth_info,
1171
+ 'raw_status': raw_status,
1172
+ 'container_name': container_name,
1173
+ }
1085
1174
 
1086
1175
  except (DockerError, subprocess.SubprocessError) as e:
1087
1176
  raise ContainerError(f"Failed to get tunnel status in {dev_name}: {e}")
1088
1177
 
1178
+ def _format_tunnel_status(self, data: dict) -> str:
1179
+ """Format tunnel status data into human-readable CLI output."""
1180
+ raw_status = data.get('raw_status', {})
1181
+ tunnel = raw_status.get('tunnel')
1182
+ auth_info = data.get('auth_info')
1183
+
1184
+ if not tunnel:
1185
+ lines = ["[dim]● No tunnel running[/dim]"]
1186
+ if auth_info:
1187
+ lines.append(f" Auth: {auth_info}")
1188
+ return "\n".join(lines)
1189
+
1190
+ name = tunnel.get("name") or "unknown"
1191
+ state = tunnel.get("tunnel", "Unknown")
1192
+ started_at = tunnel.get("started_at")
1193
+ connected_at = tunnel.get("last_connected_at")
1194
+ disconnected_at = tunnel.get("last_disconnected_at")
1195
+ fail_reason = tunnel.get("last_fail_reason")
1196
+
1197
+ lines = []
1198
+
1199
+ if state == "Connected":
1200
+ lines.append(f"[bold green]● Connected[/bold green] ({name})")
1201
+ elif state == "Disconnected":
1202
+ lines.append(f"[bold red]● Disconnected[/bold red] ({name})")
1203
+ else:
1204
+ lines.append(f"[bold yellow]● {state}[/bold yellow] ({name})")
1205
+
1206
+ if started_at:
1207
+ lines.append(f" Started: {self._format_timestamp(started_at)}")
1208
+ if connected_at:
1209
+ lines.append(f" Connected: {self._format_timestamp(connected_at)}")
1210
+ if disconnected_at:
1211
+ lines.append(f" Disconnected: {self._format_timestamp(disconnected_at)}")
1212
+ if fail_reason:
1213
+ lines.append(f" Fail reason: {fail_reason}")
1214
+ if auth_info:
1215
+ lines.append(f" Auth: {auth_info}")
1216
+
1217
+ if state == "Connected" and data.get('vscode_cmd'):
1218
+ lines.append("")
1219
+ lines.append("[bold]Connect:[/bold]")
1220
+ lines.append(f" Desktop: {data['vscode_cmd']}")
1221
+ lines.append(f" Browser: {data['web_url']}")
1222
+
1223
+ return "\n".join(lines)
1224
+
1225
+ @staticmethod
1226
+ def _format_timestamp(ts: str) -> str:
1227
+ """Format an ISO timestamp into a friendly local time string."""
1228
+ from datetime import datetime
1229
+ try:
1230
+ if "." in ts:
1231
+ base, frac = ts.split(".")
1232
+ frac = frac.rstrip("Z")[:6]
1233
+ ts = f"{base}.{frac}Z"
1234
+ dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
1235
+ local_dt = dt.astimezone()
1236
+ return local_dt.strftime("%H:%M:%S %Z")
1237
+ except (ValueError, TypeError):
1238
+ return ts
1239
+
1089
1240
  def kill_tunnel(self, dev_name: str, workspace_dir: Path, debug: bool = False, live: bool = False, extra_env: Optional[Dict[str, str]] = None) -> bool:
1090
1241
  """Kill a running VS Code tunnel in the container.
1091
1242
 
1092
- Args:
1093
- dev_name: Development environment name
1094
- workspace_dir: Workspace directory path
1095
- debug: Show debug output for devcontainer operations
1096
- live: Whether the container is in live mode
1097
- extra_env: Additional environment variables to pass to container
1098
-
1099
1243
  Returns:
1100
1244
  True if tunnel was killed successfully
1101
1245
 
@@ -1103,29 +1247,15 @@ class ContainerManager:
1103
1247
  ContainerError: If tunnel kill fails
1104
1248
  """
1105
1249
  try:
1106
- # Prepare container for execution
1107
- container_name, container_workspace_dir = self._prepare_container_exec(
1108
- dev_name, workspace_dir, debug=debug, live=live, extra_env=extra_env, reuse_existing=True
1109
- )
1110
-
1111
- # Kill the tunnel
1112
- cmd = [
1113
- 'docker', 'exec',
1114
- container_name,
1115
- '/usr/local/bin/code', 'tunnel', 'kill'
1116
- ]
1250
+ info = self.get_tunnel_info(dev_name, workspace_dir, debug=debug, live=live, extra_env=extra_env)
1251
+ container_name = info['container_name']
1117
1252
 
1118
1253
  if debug:
1119
- console.print(f"[dim]Running: {' '.join(cmd)}[/dim]")
1254
+ console.print(f"[dim]Killing tunnel processes in {container_name}[/dim]")
1120
1255
 
1121
- result = subprocess.run(cmd, capture_output=True, text=True)
1122
-
1123
- if result.returncode == 0:
1124
- console.print(f" Tunnel killed in: {dev_name}")
1125
- return True
1126
- else:
1127
- console.print(f" No tunnel to kill in: {dev_name}")
1128
- return False
1256
+ self._kill_tunnel_processes(container_name)
1257
+ console.print(f" Tunnel killed in: {dev_name}")
1258
+ return True
1129
1259
 
1130
1260
  except (DockerError, subprocess.SubprocessError) as e:
1131
1261
  raise ContainerError(f"Failed to kill tunnel in {dev_name}: {e}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devs-common
3
- Version: 3.3.0
3
+ Version: 3.3.2
4
4
  Summary: Shared utilities for devs package ecosystem
5
5
  Author: Dan Lester
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devs-common"
7
- version = "3.3.0"
7
+ version = "3.3.2"
8
8
  description = "Shared utilities for devs package ecosystem"
9
9
  authors = [
10
10
  {name = "Dan Lester"},
@@ -1,13 +0,0 @@
1
- """Core classes for devs ecosystem."""
2
-
3
- from .project import Project, ProjectInfo
4
- from .workspace import WorkspaceManager
5
- from .container import ContainerManager, ContainerInfo
6
-
7
- __all__ = [
8
- "Project",
9
- "ProjectInfo",
10
- "WorkspaceManager",
11
- "ContainerManager",
12
- "ContainerInfo",
13
- ]
File without changes
File without changes
File without changes