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