portacode 0.3.19.dev4__py3-none-any.whl → 1.4.11.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of portacode might be problematic. Click here for more details.

Files changed (92) hide show
  1. portacode/_version.py +16 -3
  2. portacode/cli.py +143 -17
  3. portacode/connection/client.py +149 -10
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +824 -21
  5. portacode/connection/handlers/__init__.py +28 -1
  6. portacode/connection/handlers/base.py +78 -16
  7. portacode/connection/handlers/chunked_content.py +244 -0
  8. portacode/connection/handlers/diff_handlers.py +603 -0
  9. portacode/connection/handlers/file_handlers.py +902 -17
  10. portacode/connection/handlers/project_aware_file_handlers.py +226 -0
  11. portacode/connection/handlers/project_state/README.md +312 -0
  12. portacode/connection/handlers/project_state/__init__.py +92 -0
  13. portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
  14. portacode/connection/handlers/project_state/git_manager.py +1502 -0
  15. portacode/connection/handlers/project_state/handlers.py +875 -0
  16. portacode/connection/handlers/project_state/manager.py +1331 -0
  17. portacode/connection/handlers/project_state/models.py +108 -0
  18. portacode/connection/handlers/project_state/utils.py +50 -0
  19. portacode/connection/handlers/project_state_handlers.py +45 -2185
  20. portacode/connection/handlers/proxmox_infra.py +361 -0
  21. portacode/connection/handlers/registry.py +15 -4
  22. portacode/connection/handlers/session.py +483 -32
  23. portacode/connection/handlers/system_handlers.py +147 -8
  24. portacode/connection/handlers/tab_factory.py +53 -46
  25. portacode/connection/handlers/terminal_handlers.py +21 -8
  26. portacode/connection/handlers/update_handler.py +61 -0
  27. portacode/connection/multiplex.py +60 -2
  28. portacode/connection/terminal.py +214 -24
  29. portacode/keypair.py +63 -1
  30. portacode/link_capture/__init__.py +38 -0
  31. portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
  32. portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
  33. portacode/link_capture/bin/elinks +3 -0
  34. portacode/link_capture/bin/gio-open +3 -0
  35. portacode/link_capture/bin/gnome-open +3 -0
  36. portacode/link_capture/bin/gvfs-open +3 -0
  37. portacode/link_capture/bin/kde-open +3 -0
  38. portacode/link_capture/bin/kfmclient +3 -0
  39. portacode/link_capture/bin/link_capture_exec.sh +11 -0
  40. portacode/link_capture/bin/link_capture_wrapper.py +75 -0
  41. portacode/link_capture/bin/links +3 -0
  42. portacode/link_capture/bin/links2 +3 -0
  43. portacode/link_capture/bin/lynx +3 -0
  44. portacode/link_capture/bin/mate-open +3 -0
  45. portacode/link_capture/bin/netsurf +3 -0
  46. portacode/link_capture/bin/sensible-browser +3 -0
  47. portacode/link_capture/bin/w3m +3 -0
  48. portacode/link_capture/bin/x-www-browser +3 -0
  49. portacode/link_capture/bin/xdg-open +3 -0
  50. portacode/logging_categories.py +140 -0
  51. portacode/pairing.py +103 -0
  52. portacode/static/js/test-ntp-clock.html +63 -0
  53. portacode/static/js/utils/ntp-clock.js +232 -0
  54. portacode/utils/NTP_ARCHITECTURE.md +136 -0
  55. portacode/utils/__init__.py +1 -0
  56. portacode/utils/diff_apply.py +456 -0
  57. portacode/utils/diff_renderer.py +371 -0
  58. portacode/utils/ntp_clock.py +65 -0
  59. portacode-1.4.11.dev1.dist-info/METADATA +298 -0
  60. portacode-1.4.11.dev1.dist-info/RECORD +97 -0
  61. {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/WHEEL +1 -1
  62. portacode-1.4.11.dev1.dist-info/top_level.txt +3 -0
  63. test_modules/README.md +296 -0
  64. test_modules/__init__.py +1 -0
  65. test_modules/test_device_online.py +44 -0
  66. test_modules/test_file_operations.py +743 -0
  67. test_modules/test_git_status_ui.py +370 -0
  68. test_modules/test_login_flow.py +50 -0
  69. test_modules/test_navigate_testing_folder.py +361 -0
  70. test_modules/test_play_store_screenshots.py +294 -0
  71. test_modules/test_terminal_buffer_performance.py +261 -0
  72. test_modules/test_terminal_interaction.py +80 -0
  73. test_modules/test_terminal_loading_race_condition.py +95 -0
  74. test_modules/test_terminal_start.py +56 -0
  75. testing_framework/.env.example +21 -0
  76. testing_framework/README.md +334 -0
  77. testing_framework/__init__.py +17 -0
  78. testing_framework/cli.py +326 -0
  79. testing_framework/core/__init__.py +1 -0
  80. testing_framework/core/base_test.py +336 -0
  81. testing_framework/core/cli_manager.py +177 -0
  82. testing_framework/core/hierarchical_runner.py +577 -0
  83. testing_framework/core/playwright_manager.py +520 -0
  84. testing_framework/core/runner.py +447 -0
  85. testing_framework/core/shared_cli_manager.py +234 -0
  86. testing_framework/core/test_discovery.py +112 -0
  87. testing_framework/requirements.txt +12 -0
  88. portacode-0.3.19.dev4.dist-info/METADATA +0 -241
  89. portacode-0.3.19.dev4.dist-info/RECORD +0 -30
  90. portacode-0.3.19.dev4.dist-info/top_level.txt +0 -1
  91. {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/entry_points.txt +0 -0
  92. {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,361 @@
1
+ """Proxmox infrastructure configuration handler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import shutil
9
+ import stat
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Any, Dict, Iterable, List, Tuple
16
+
17
+ import platformdirs
18
+
19
+ from .base import SyncHandler
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ CONFIG_DIR = Path(platformdirs.user_config_dir("portacode"))
24
+ CONFIG_PATH = CONFIG_DIR / "proxmox_infra.json"
25
+ REPO_ROOT = Path(__file__).resolve().parents[3]
26
+ NET_SETUP_SCRIPT = REPO_ROOT / "proxmox_management" / "net_setup.py"
27
+
28
+ DEFAULT_HOST = "localhost"
29
+ DEFAULT_NODE_NAME = os.uname().nodename.split(".", 1)[0]
30
+ DEFAULT_BRIDGE = "vmbr1"
31
+ SUBNET_CIDR = "10.10.0.1/24"
32
+ BRIDGE_IP = SUBNET_CIDR.split("/", 1)[0]
33
+ DHCP_START = "10.10.0.100"
34
+ DHCP_END = "10.10.0.200"
35
+ DNS_SERVER = "1.1.1.1"
36
+ IFACES_PATH = Path("/etc/network/interfaces")
37
+ SYSCTL_PATH = Path("/etc/sysctl.d/99-portacode-forward.conf")
38
+ UNIT_DIR = Path("/etc/systemd/system")
39
+
40
+
41
+ def _call_subprocess(cmd: List[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
42
+ env = os.environ.copy()
43
+ env.setdefault("DEBIAN_FRONTEND", "noninteractive")
44
+ return subprocess.run(cmd, env=env, text=True, capture_output=True, **kwargs)
45
+
46
+
47
+ def _ensure_proxmoxer() -> Any:
48
+ try:
49
+ from proxmoxer import ProxmoxAPI # noqa: F401
50
+ except ModuleNotFoundError as exc:
51
+ python = sys.executable
52
+ logger.info("Proxmoxer missing; installing via pip")
53
+ try:
54
+ _call_subprocess([python, "-m", "pip", "install", "proxmoxer"], check=True)
55
+ except subprocess.CalledProcessError as pip_exc:
56
+ msg = pip_exc.stderr or pip_exc.stdout or str(pip_exc)
57
+ raise RuntimeError(f"Failed to install proxmoxer: {msg}") from pip_exc
58
+ from proxmoxer import ProxmoxAPI # noqa: F401
59
+ from proxmoxer import ProxmoxAPI
60
+ return ProxmoxAPI
61
+
62
+
63
+ def _parse_token(token_identifier: str) -> Tuple[str, str]:
64
+ identifier = token_identifier.strip()
65
+ if "!" not in identifier or "@" not in identifier:
66
+ raise ValueError("Expected API token in the form user@realm!tokenid")
67
+ user_part, token_name = identifier.split("!", 1)
68
+ user = user_part.strip()
69
+ token_name = token_name.strip()
70
+ if "@" not in user:
71
+ raise ValueError("API token missing user realm (user@realm)")
72
+ if not token_name:
73
+ raise ValueError("Token identifier missing token name")
74
+ return user, token_name
75
+
76
+
77
+ def _save_config(data: Dict[str, Any]) -> None:
78
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
79
+ tmp_path = CONFIG_PATH.with_suffix(".tmp")
80
+ tmp_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
81
+ os.replace(tmp_path, CONFIG_PATH)
82
+ os.chmod(CONFIG_PATH, stat.S_IRUSR | stat.S_IWUSR)
83
+
84
+
85
+ def _load_config() -> Dict[str, Any]:
86
+ if not CONFIG_PATH.exists():
87
+ return {}
88
+ try:
89
+ return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
90
+ except json.JSONDecodeError as exc:
91
+ logger.warning("Failed to parse Proxmox infra config: %s", exc)
92
+ return {}
93
+
94
+
95
+ def _pick_node(client: Any) -> str:
96
+ nodes = client.nodes().get()
97
+ for node in nodes:
98
+ if node.get("node") == DEFAULT_NODE_NAME:
99
+ return DEFAULT_NODE_NAME
100
+ return nodes[0].get("node") if nodes else DEFAULT_NODE_NAME
101
+
102
+
103
+ def _list_templates(client: Any, node: str, storages: Iterable[Dict[str, Any]]) -> List[str]:
104
+ templates: List[str] = []
105
+ for storage in storages:
106
+ storage_name = storage.get("storage")
107
+ if not storage_name:
108
+ continue
109
+ try:
110
+ items = client.nodes(node).storage(storage_name).content.get()
111
+ except Exception:
112
+ continue
113
+ for item in items:
114
+ if item.get("content") == "vztmpl" and item.get("volid"):
115
+ templates.append(item["volid"])
116
+ return templates
117
+
118
+
119
+ def _pick_storage(storages: Iterable[Dict[str, Any]]) -> str:
120
+ candidates = [s for s in storages if "rootdir" in s.get("content", "") and s.get("avail", 0) > 0]
121
+ if not candidates:
122
+ candidates = [s for s in storages if "rootdir" in s.get("content", "")]
123
+ if not candidates:
124
+ return ""
125
+ candidates.sort(key=lambda entry: entry.get("avail", 0), reverse=True)
126
+ return candidates[0].get("storage", "")
127
+
128
+
129
+ def _write_bridge_config(bridge: str) -> None:
130
+ begin = f"# Portacode INFRA BEGIN {bridge}"
131
+ end = f"# Portacode INFRA END {bridge}"
132
+ current = IFACES_PATH.read_text(encoding="utf-8") if IFACES_PATH.exists() else ""
133
+ if begin in current:
134
+ return
135
+ block = f"""
136
+ {begin}
137
+ auto {bridge}
138
+ iface {bridge} inet static
139
+ address {SUBNET_CIDR}
140
+ bridge-ports none
141
+ bridge-stp off
142
+ bridge-fd 0
143
+ {end}
144
+
145
+ """
146
+ mode = "a" if IFACES_PATH.exists() else "w"
147
+ with open(IFACES_PATH, mode, encoding="utf-8") as fh:
148
+ if current and not current.endswith("\n"):
149
+ fh.write("\n")
150
+ fh.write(block)
151
+
152
+
153
+ def _ensure_sysctl() -> None:
154
+ SYSCTL_PATH.write_text("net.ipv4.ip_forward=1\n", encoding="utf-8")
155
+ _call_subprocess(["/sbin/sysctl", "-w", "net.ipv4.ip_forward=1"], check=True)
156
+
157
+
158
+ def _write_units(bridge: str) -> None:
159
+ nat_name = f"portacode-{bridge}-nat.service"
160
+ dns_name = f"portacode-{bridge}-dnsmasq.service"
161
+ nat = UNIT_DIR / nat_name
162
+ dns = UNIT_DIR / dns_name
163
+ nat.write_text(f"""[Unit]
164
+ Description=Portacode NAT for {bridge}
165
+ After=network-online.target
166
+ Wants=network-online.target
167
+
168
+ [Service]
169
+ Type=oneshot
170
+ RemainAfterExit=yes
171
+ ExecStart=/usr/sbin/iptables -t nat -A POSTROUTING -s {BRIDGE_IP}/24 -o vmbr0 -j MASQUERADE
172
+ ExecStart=/usr/sbin/iptables -A FORWARD -i {bridge} -o vmbr0 -j ACCEPT
173
+ ExecStart=/usr/sbin/iptables -A FORWARD -i vmbr0 -o {bridge} -m state --state RELATED,ESTABLISHED -j ACCEPT
174
+ ExecStop=/usr/sbin/iptables -t nat -D POSTROUTING -s {BRIDGE_IP}/24 -o vmbr0 -j MASQUERADE
175
+ ExecStop=/usr/sbin/iptables -D FORWARD -i {bridge} -o vmbr0 -j ACCEPT
176
+ ExecStop=/usr/sbin/iptables -D FORWARD -i vmbr0 -o {bridge} -m state --state RELATED,ESTABLISHED -j ACCEPT
177
+
178
+ [Install]
179
+ WantedBy=multi-user.target
180
+ """, encoding="utf-8")
181
+ dns.write_text(f"""[Unit]
182
+ Description=Portacode dnsmasq for {bridge}
183
+ After=network-online.target
184
+ Wants=network-online.target
185
+
186
+ [Service]
187
+ Type=simple
188
+ ExecStart=/usr/sbin/dnsmasq --keep-in-foreground --interface={bridge} --bind-interfaces --listen-address={BRIDGE_IP} \
189
+ --port=0 --dhcp-range={DHCP_START},{DHCP_END},12h \
190
+ --dhcp-option=option:router,{BRIDGE_IP} \
191
+ --dhcp-option=option:dns-server,{DNS_SERVER} \
192
+ --conf-file=/dev/null --pid-file=/run/portacode_dnsmasq.pid --dhcp-leasefile=/var/lib/misc/portacode_dnsmasq.leases
193
+ Restart=always
194
+
195
+ [Install]
196
+ WantedBy=multi-user.target
197
+ """, encoding="utf-8")
198
+
199
+
200
+ def _ensure_bridge(bridge: str = DEFAULT_BRIDGE) -> Dict[str, Any]:
201
+ if os.geteuid() != 0:
202
+ raise PermissionError("Bridge setup requires root privileges")
203
+ if not shutil.which("dnsmasq"):
204
+ apt = shutil.which("apt-get")
205
+ if not apt:
206
+ raise RuntimeError("dnsmasq is missing and apt-get unavailable to install it")
207
+ _call_subprocess([apt, "update"], check=True)
208
+ _call_subprocess([apt, "install", "-y", "dnsmasq"], check=True)
209
+ _write_bridge_config(bridge)
210
+ _ensure_sysctl()
211
+ _write_units(bridge)
212
+ _call_subprocess(["/bin/systemctl", "daemon-reload"], check=True)
213
+ nat_service = f"portacode-{bridge}-nat.service"
214
+ dns_service = f"portacode-{bridge}-dnsmasq.service"
215
+ _call_subprocess(["/bin/systemctl", "enable", "--now", nat_service, dns_service], check=True)
216
+ _call_subprocess(["/sbin/ifup", bridge], check=False)
217
+ return {"applied": True, "bridge": bridge, "message": f"Bridge {bridge} configured"}
218
+
219
+
220
+ def _verify_connectivity(timeout: float = 5.0) -> bool:
221
+ try:
222
+ _call_subprocess(["/bin/ping", "-c", "2", "1.1.1.1"], check=True, timeout=timeout)
223
+ return True
224
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
225
+ return False
226
+
227
+
228
+ def _revert_bridge() -> None:
229
+ try:
230
+ if NET_SETUP_SCRIPT.exists():
231
+ _call_subprocess([sys.executable, str(NET_SETUP_SCRIPT), "revert"], check=True)
232
+ except Exception as exc:
233
+ logger.warning("Proxmox bridge revert failed: %s", exc)
234
+
235
+
236
+ def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
237
+ network = config.get("network", {})
238
+ base_network = {
239
+ "applied": network.get("applied", False),
240
+ "message": network.get("message"),
241
+ "bridge": network.get("bridge", DEFAULT_BRIDGE),
242
+ }
243
+ if not config:
244
+ return {"configured": False, "network": base_network}
245
+ return {
246
+ "configured": True,
247
+ "host": config.get("host"),
248
+ "node": config.get("node"),
249
+ "user": config.get("user"),
250
+ "token_name": config.get("token_name"),
251
+ "default_storage": config.get("default_storage"),
252
+ "templates": config.get("templates") or [],
253
+ "last_verified": config.get("last_verified"),
254
+ "network": base_network,
255
+ }
256
+
257
+
258
+ def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl: bool = False) -> Dict[str, Any]:
259
+ ProxmoxAPI = _ensure_proxmoxer()
260
+ user, token_name = _parse_token(token_identifier)
261
+ client = ProxmoxAPI(
262
+ DEFAULT_HOST,
263
+ user=user,
264
+ token_name=token_name,
265
+ token_value=token_value,
266
+ verify_ssl=verify_ssl,
267
+ timeout=30,
268
+ )
269
+ node = _pick_node(client)
270
+ status = client.nodes(node).status.get()
271
+ storages = client.nodes(node).storage.get()
272
+ default_storage = _pick_storage(storages)
273
+ templates = _list_templates(client, node, storages)
274
+ network: Dict[str, Any] = {}
275
+ try:
276
+ network = _ensure_bridge()
277
+ # Wait for network convergence before validating connectivity
278
+ time.sleep(2)
279
+ if _verify_connectivity():
280
+ network["health"] = "healthy"
281
+ else:
282
+ network = {"applied": False, "bridge": DEFAULT_BRIDGE, "message": "Connectivity check failed; bridge reverted"}
283
+ _revert_bridge()
284
+ except PermissionError as exc:
285
+ network = {"applied": False, "message": str(exc), "bridge": DEFAULT_BRIDGE}
286
+ logger.warning("Bridge setup skipped: %s", exc)
287
+ except Exception as exc: # pragma: no cover - best effort
288
+ network = {"applied": False, "message": str(exc), "bridge": DEFAULT_BRIDGE}
289
+ logger.warning("Bridge setup failed: %s", exc)
290
+ config = {
291
+ "host": DEFAULT_HOST,
292
+ "node": node,
293
+ "user": user,
294
+ "token_name": token_name,
295
+ "token_value": token_value,
296
+ "verify_ssl": verify_ssl,
297
+ "default_storage": default_storage,
298
+ "templates": templates,
299
+ "last_verified": datetime.utcnow().isoformat() + "Z",
300
+ "network": network,
301
+ "node_status": status,
302
+ }
303
+ _save_config(config)
304
+ snapshot = build_snapshot(config)
305
+ snapshot["node_status"] = status
306
+ return snapshot
307
+
308
+
309
+ def get_infra_snapshot() -> Dict[str, Any]:
310
+ config = _load_config()
311
+ snapshot = build_snapshot(config)
312
+ if config.get("node_status"):
313
+ snapshot["node_status"] = config["node_status"]
314
+ return snapshot
315
+
316
+
317
+ def revert_infrastructure() -> Dict[str, Any]:
318
+ _revert_bridge()
319
+ if CONFIG_PATH.exists():
320
+ CONFIG_PATH.unlink()
321
+ snapshot = build_snapshot({})
322
+ snapshot["network"] = snapshot.get("network", {})
323
+ snapshot["network"]["applied"] = False
324
+ snapshot["network"]["message"] = "Reverted to previous network state"
325
+ snapshot["network"]["bridge"] = DEFAULT_BRIDGE
326
+ return snapshot
327
+
328
+
329
+ class ConfigureProxmoxInfraHandler(SyncHandler):
330
+ @property
331
+ def command_name(self) -> str:
332
+ return "setup_proxmox_infra"
333
+
334
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
335
+ token_identifier = message.get("token_identifier")
336
+ token_value = message.get("token_value")
337
+ verify_ssl = bool(message.get("verify_ssl"))
338
+ if not token_identifier or not token_value:
339
+ raise ValueError("token_identifier and token_value are required")
340
+ snapshot = configure_infrastructure(token_identifier, token_value, verify_ssl=verify_ssl)
341
+ return {
342
+ "event": "proxmox_infra_configured",
343
+ "success": True,
344
+ "message": "Proxmox infrastructure configured",
345
+ "infra": snapshot,
346
+ }
347
+
348
+
349
+ class RevertProxmoxInfraHandler(SyncHandler):
350
+ @property
351
+ def command_name(self) -> str:
352
+ return "revert_proxmox_infra"
353
+
354
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
355
+ snapshot = revert_infrastructure()
356
+ return {
357
+ "event": "proxmox_infra_reverted",
358
+ "success": True,
359
+ "message": "Proxmox infrastructure configuration reverted",
360
+ "infra": snapshot,
361
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import logging
4
4
  from typing import Dict, Type, Any, Optional, List, TYPE_CHECKING
5
+ from portacode.utils.ntp_clock import ntp_clock
5
6
 
6
7
  if TYPE_CHECKING:
7
8
  from ..multiplex import Channel
@@ -72,22 +73,32 @@ class CommandRegistry:
72
73
 
73
74
  async def dispatch(self, command_name: str, message: Dict[str, Any], reply_channel: Optional[str] = None) -> bool:
74
75
  """Dispatch a command to its handler.
75
-
76
+
76
77
  Args:
77
78
  command_name: The command name
78
79
  message: The command message
79
80
  reply_channel: Optional reply channel
80
-
81
+
81
82
  Returns:
82
83
  True if handler was found and executed, False otherwise
83
84
  """
84
85
  logger.info("registry: Dispatching command '%s' with reply_channel=%s", command_name, reply_channel)
85
-
86
+
87
+ # Add handler_receive timestamp if trace present
88
+ if "trace" in message and "request_id" in message:
89
+ handler_receive_time = ntp_clock.now_ms()
90
+ if handler_receive_time is not None:
91
+ message["trace"]["handler_receive"] = handler_receive_time
92
+ # Update ping to show total time from client_send
93
+ if "client_send" in message["trace"]:
94
+ message["trace"]["ping"] = handler_receive_time - message["trace"]["client_send"]
95
+ logger.info(f"🎯 Handler received traced message: {message['request_id']}")
96
+
86
97
  handler = self.get_handler(command_name)
87
98
  if handler is None:
88
99
  logger.warning("registry: No handler found for command: %s", command_name)
89
100
  return False
90
-
101
+
91
102
  try:
92
103
  await handler.handle(message, reply_channel)
93
104
  logger.info("registry: Successfully dispatched command '%s'", command_name)