portacode 1.4.14__py3-none-any.whl → 1.4.15__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.
portacode/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '1.4.14'
32
- __version_tuple__ = version_tuple = (1, 4, 14)
31
+ __version__ = version = '1.4.15'
32
+ __version_tuple__ = version_tuple = (1, 4, 15)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -27,6 +27,10 @@ The Portacode server acts as a **routing middleman** between client sessions and
27
27
 
28
28
  - **`source_client_session`** (Server → Device): Server **adds this** when forwarding client commands to devices (so device knows which client sent the command and can target responses back). Clients never include this field.
29
29
 
30
+ ### Proxying infrastructure updates
31
+
32
+ Portacode infrastructure devices (like the proxmox host) can send events on behalf of the LXC Devices they manage. Such messages include the optional `on_behalf_of_device` field and the server silently replaces `device_id` with that child device before routing. The gateway enforces that the sender is the child’s `proxmox_parent` (via `Device.proxmox_parent`) so only the infrastructure owner can impersonate a child device. Messages that fail this check are dropped.
33
+
30
34
  This document describes the complete protocol for communicating with devices through the server, guiding app developers on how to get their client sessions to communicate with devices.
31
35
 
32
36
  ## Table of Contents
@@ -361,7 +365,7 @@ Creates a Portacode-managed LXC container, starts it, and bootstraps the Portaco
361
365
  * `username` (string, optional): OS user to provision (defaults to `svcuser`).
362
366
  * `password` (string, optional): Password for the user (used only during provisioning).
363
367
  * `ssh_key` (string, optional): SSH public key to add to the user.
364
- * `device_id` (string, optional): ID of the Device record that already exists on the dashboard.
368
+ * `device_id` (string, required): ID of the dashboard Device record that represents the container. The handler persists this value in the host metadata file so related events can always be correlated back to that Device.
365
369
  * `device_public_key` (string, optional): PEM-encoded Portacode public key. When supplied together with `device_private_key` the handler injects the keypair, records the device metadata, and runs `portacode service install` automatically.
366
370
  * `device_private_key` (string, optional): PEM-encoded private key that pairs with `device_public_key`. Both key fields must be present for the automatic service-install mode.
367
371
 
@@ -421,7 +425,8 @@ Emitted after a successful `create_proxmox_container` action. Contains the new c
421
425
  * `public_key` (string): Portacode public auth key created inside the new container.
422
426
  * `container` (object): Metadata such as `vmid`, `hostname`, `template`, `storage`, `disk_gib`, `ram_mib`, and `cpus`.
423
427
  * `setup_steps` (array[object]): Detailed bootstrap step results (name, stdout/stderr, elapsed time, and status).
424
- * `device_id` (string, optional): Mirrors the `device_id` supplied with `create_proxmox_container`, if any.
428
+ * `device_id` (string): Mirrors the dashboard Device ID supplied with `create_proxmox_container`. The handler records this value in the container metadata file so subsequent events can reference the same Device.
429
+ * `on_behalf_of_device` (string): Same value as `device_id` when the container host is reporting progress for the child device; only proxmox parents may include this field.
425
430
  * `service_installed` (boolean): True when the handler already ran `portacode service install` (with a provided keypair); otherwise it remains False and the dashboard can call `start_portacode_service`.
426
431
 
427
432
  ### `proxmox_container_progress`
@@ -439,6 +444,7 @@ Sent intermittently while `create_proxmox_container` is executing so callers can
439
444
  * `message` (string): Short description of what is happening or why a failure occurred.
440
445
  * `details` (object, optional): Contains `attempt` (if retries were needed) and `error_summary` when a step fails.
441
446
  * `request_id` (string, optional): Mirrors the request ID from the incoming `create_proxmox_container` payload when available.
447
+ * `on_behalf_of_device` (string, optional): When present the proxmox device is reporting progress for the referenced dashboard device; the gateway verifies the proxmox node is the child’s `proxmox_parent` before routing the event.
442
448
 
443
449
  ### `start_portacode_service`
444
450
 
@@ -1177,6 +1183,8 @@ Emitted after a successful `create_proxmox_container` action to report the newly
1177
1183
  * `public_key` (string): Portacode public auth key discovered inside the container.
1178
1184
  * `container` (object): Metadata such as `vmid`, `hostname`, `template`, `storage`, `disk_gib`, `ram_mib`, and `cpus`.
1179
1185
  * `setup_steps` (array[object]): Detailed bootstrap step reports including stdout/stderr, elapsed time, and pass/fail status.
1186
+ * `device_id` (string): Mirrors the device ID supplied with `create_proxmox_container` and persisted inside the host metadata file for this CT.
1187
+ * `on_behalf_of_device` (string): Same value as `device_id` when the container host is reporting progress for the child device.
1180
1188
 
1181
1189
  ### `proxmox_container_progress`
1182
1190
 
@@ -1193,6 +1201,7 @@ Sent continuously while `create_proxmox_container` runs so dashboards can show a
1193
1201
  * `message` (string): Short human-readable description of the action or failure.
1194
1202
  * `details` (object, optional): Contains `attempt` (when retries are used) and `error_summary` on failure.
1195
1203
  * `request_id` (string, optional): Mirrors the `create_proxmox_container` request when provided.
1204
+ * `on_behalf_of_device` (string, optional): Mirrors the child device ID when a proxmox host reports progress for that child; only proxmox parents can supply this field.
1196
1205
 
1197
1206
  ### `proxmox_container_action`
1198
1207
 
@@ -21,6 +21,24 @@ from .git_manager import GitManager
21
21
  from .file_system_watcher import FileSystemWatcher
22
22
  from ....logging_categories import get_categorized_logger, LogCategory
23
23
 
24
+ def _deterministic_file_tab_id(file_path: str) -> str:
25
+ try:
26
+ resolved = os.path.abspath(file_path)
27
+ mtime = int(Path(resolved).stat().st_mtime)
28
+ except OSError:
29
+ mtime = "unknown"
30
+ resolved = os.path.abspath(file_path)
31
+ return f"{resolved}:{mtime}"
32
+
33
+ def _deterministic_diff_tab_id(
34
+ file_path: str,
35
+ from_ref: str,
36
+ to_ref: str,
37
+ from_hash: Optional[str],
38
+ to_hash: Optional[str]
39
+ ) -> str:
40
+ return f"diff:{os.path.abspath(file_path)}:{from_ref}:{to_ref}:{from_hash or ''}:{to_hash or ''}"
41
+
24
42
  logger = get_categorized_logger(__name__)
25
43
 
26
44
  # Global singleton instance
@@ -657,10 +675,10 @@ class ProjectStateManager:
657
675
  # Create new file tab using tab factory
658
676
  from ..tab_factory import get_tab_factory
659
677
  tab_factory = get_tab_factory()
660
-
678
+
661
679
  try:
662
680
  logger.info(f"About to create tab for file: {file_path}")
663
- new_tab = await tab_factory.create_file_tab(file_path)
681
+ new_tab = await tab_factory.create_file_tab(file_path, tab_id=_deterministic_file_tab_id(file_path))
664
682
  logger.info(f"Tab created successfully, adding to project state")
665
683
  project_state.open_tabs[tab_key] = new_tab
666
684
  if set_active:
@@ -842,7 +860,11 @@ class ProjectStateManager:
842
860
  diff_title = f"{os.path.basename(file_path)} ({' '.join(title_parts)})"
843
861
 
844
862
  diff_tab = await tab_factory.create_diff_tab_with_title(
845
- file_path, original_content, modified_content, diff_title,
863
+ file_path,
864
+ original_content,
865
+ modified_content,
866
+ diff_title,
867
+ tab_id=_deterministic_diff_tab_id(file_path, from_ref, to_ref, from_hash, to_hash),
846
868
  diff_details=diff_details
847
869
  )
848
870
 
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import json
7
7
  import logging
8
+ import math
8
9
  import os
9
10
  import secrets
10
11
  import shlex
@@ -15,7 +16,7 @@ import sys
15
16
  import tempfile
16
17
  import time
17
18
  import threading
18
- from datetime import datetime
19
+ from datetime import datetime, timezone
19
20
  from pathlib import Path
20
21
  from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
21
22
 
@@ -46,6 +47,7 @@ UNIT_DIR = Path("/etc/systemd/system")
46
47
  _MANAGED_CONTAINERS_CACHE_TTL_S = 30.0
47
48
  _MANAGED_CONTAINERS_CACHE: Dict[str, Any] = {"timestamp": 0.0, "summary": None}
48
49
  _MANAGED_CONTAINERS_CACHE_LOCK = threading.Lock()
50
+ TEMPLATES_REFRESH_INTERVAL_S = 300
49
51
 
50
52
  ProgressCallback = Callable[[int, int, Dict[str, Any], str, Optional[Dict[str, Any]]], None]
51
53
 
@@ -62,6 +64,7 @@ def _emit_progress_event(
62
64
  phase: str,
63
65
  request_id: Optional[str],
64
66
  details: Optional[Dict[str, Any]] = None,
67
+ on_behalf_of_device: Optional[str] = None,
65
68
  ) -> None:
66
69
  loop = handler.context.get("event_loop")
67
70
  if not loop or loop.is_closed():
@@ -86,6 +89,8 @@ def _emit_progress_event(
86
89
  payload["request_id"] = request_id
87
90
  if details:
88
91
  payload["details"] = details
92
+ if on_behalf_of_device:
93
+ payload["on_behalf_of_device"] = str(on_behalf_of_device)
89
94
 
90
95
  future = asyncio.run_coroutine_threadsafe(handler.send_response(payload), loop)
91
96
  future.add_done_callback(
@@ -175,6 +180,64 @@ def _list_templates(client: Any, node: str, storages: Iterable[Dict[str, Any]])
175
180
  return templates
176
181
 
177
182
 
183
+ def _build_proxmox_client_from_config(config: Dict[str, Any]):
184
+ user = config.get("user")
185
+ token_name = config.get("token_name")
186
+ token_value = config.get("token_value")
187
+ if not user or not token_name or not token_value:
188
+ raise RuntimeError("Proxmox API credentials are missing")
189
+ ProxmoxAPI = _ensure_proxmoxer()
190
+ return ProxmoxAPI(
191
+ config.get("host", DEFAULT_HOST),
192
+ user=user,
193
+ token_name=token_name,
194
+ token_value=token_value,
195
+ verify_ssl=config.get("verify_ssl", False),
196
+ timeout=30,
197
+ )
198
+
199
+
200
+ def _current_time_iso() -> str:
201
+ return datetime.now(timezone.utc).isoformat()
202
+
203
+
204
+ def _parse_iso_timestamp(value: str) -> Optional[datetime]:
205
+ if not value:
206
+ return None
207
+ text = value
208
+ if text.endswith("Z"):
209
+ text = text[:-1] + "+00:00"
210
+ try:
211
+ return datetime.fromisoformat(text)
212
+ except ValueError:
213
+ return None
214
+
215
+
216
+ def _templates_need_refresh(config: Dict[str, Any]) -> bool:
217
+ if not config or not config.get("token_value"):
218
+ return False
219
+ last = _parse_iso_timestamp(config.get("templates_last_refreshed") or "")
220
+ if not last:
221
+ return True
222
+ return (datetime.now(timezone.utc) - last).total_seconds() >= TEMPLATES_REFRESH_INTERVAL_S
223
+
224
+
225
+ def _ensure_templates_refreshed_on_startup(config: Dict[str, Any]) -> None:
226
+ if not _templates_need_refresh(config):
227
+ return
228
+ try:
229
+ client = _build_proxmox_client_from_config(config)
230
+ node = config.get("node") or _pick_node(client)
231
+ storages = client.nodes(node).storage.get()
232
+ templates = _list_templates(client, node, storages)
233
+ if templates:
234
+ config["templates"] = templates
235
+ config["templates_last_refreshed"] = _current_time_iso()
236
+ _save_config(config)
237
+ except Exception as exc:
238
+ logger.warning("Unable to refresh Proxmox templates on startup: %s", exc)
239
+
240
+
178
241
  def _pick_storage(storages: Iterable[Dict[str, Any]]) -> str:
179
242
  candidates = [s for s in storages if "rootdir" in s.get("content", "") and s.get("avail", 0) > 0]
180
243
  if not candidates:
@@ -433,48 +496,171 @@ def _friendly_step_label(step_name: str) -> str:
433
496
  return normalized.capitalize()
434
497
 
435
498
 
499
+ _NETWORK_WAIT_CMD = (
500
+ "count=0; "
501
+ "while [ \"$count\" -lt 20 ]; do "
502
+ " if command -v ip >/dev/null 2>&1 && ip route get 1.1.1.1 >/dev/null 2>&1; then break; fi; "
503
+ " if [ -f /proc/net/route ] && grep -q '^00000000' /proc/net/route >/dev/null 2>&1; then break; fi; "
504
+ " sleep 1; "
505
+ " count=$((count+1)); "
506
+ "done"
507
+ )
508
+
509
+ _PACKAGE_MANAGER_PROFILES: Dict[str, Dict[str, Any]] = {
510
+ "apt": {
511
+ "update_cmd": "apt-get update -y",
512
+ "update_step_name": "apt_update",
513
+ "install_cmd": "apt-get install -y python3 python3-pip sudo --fix-missing",
514
+ "install_step_name": "install_deps",
515
+ "update_retries": 4,
516
+ "install_retries": 5,
517
+ },
518
+ "dnf": {
519
+ "update_cmd": "dnf check-update || true",
520
+ "update_step_name": "dnf_update",
521
+ "install_cmd": "dnf install -y python3 python3-pip sudo",
522
+ "install_step_name": "install_deps",
523
+ "update_retries": 3,
524
+ "install_retries": 5,
525
+ },
526
+ "yum": {
527
+ "update_cmd": "yum makecache",
528
+ "update_step_name": "yum_update",
529
+ "install_cmd": "yum install -y python3 python3-pip sudo",
530
+ "install_step_name": "install_deps",
531
+ "update_retries": 3,
532
+ "install_retries": 5,
533
+ },
534
+ "apk": {
535
+ "update_cmd": "apk update",
536
+ "update_step_name": "apk_update",
537
+ "install_cmd": "apk add --no-cache python3 py3-pip sudo shadow",
538
+ "install_step_name": "install_deps",
539
+ "update_retries": 3,
540
+ "install_retries": 5,
541
+ },
542
+ "pacman": {
543
+ "update_cmd": "pacman -Sy --noconfirm",
544
+ "update_step_name": "pacman_update",
545
+ "install_cmd": "pacman -S --noconfirm python python-pip sudo",
546
+ "install_step_name": "install_deps",
547
+ "update_retries": 3,
548
+ "install_retries": 5,
549
+ },
550
+ "zypper": {
551
+ "update_cmd": "zypper refresh",
552
+ "update_step_name": "zypper_update",
553
+ "install_cmd": "zypper install -y python3 python3-pip sudo",
554
+ "install_step_name": "install_deps",
555
+ "update_retries": 3,
556
+ "install_retries": 5,
557
+ },
558
+ }
559
+
560
+ _UPDATE_RETRY_ON = [
561
+ "Temporary failure resolving",
562
+ "Could not resolve",
563
+ "Failed to fetch",
564
+ ]
565
+
566
+ _INSTALL_RETRY_ON = [
567
+ "lock-frontend",
568
+ "Unable to acquire the dpkg frontend lock",
569
+ "Temporary failure resolving",
570
+ "Could not resolve",
571
+ "Failed to fetch",
572
+ ]
573
+
574
+
436
575
  def _build_bootstrap_steps(
437
576
  user: str,
438
577
  password: str,
439
578
  ssh_key: str,
440
579
  include_portacode_connect: bool = True,
580
+ package_manager: str = "apt",
441
581
  ) -> List[Dict[str, Any]]:
442
- steps = [
443
- {
444
- "name": "apt_update",
445
- "cmd": "apt-get update -y",
446
- "retries": 4,
447
- "retry_delay_s": 5,
448
- "retry_on": [
449
- "Temporary failure resolving",
450
- "Could not resolve",
451
- "Failed to fetch",
452
- ],
453
- },
582
+ profile = _PACKAGE_MANAGER_PROFILES.get(package_manager, _PACKAGE_MANAGER_PROFILES["apt"])
583
+ steps: List[Dict[str, Any]] = [
584
+ {"name": "wait_for_network", "cmd": _NETWORK_WAIT_CMD, "retries": 0},
585
+ ]
586
+ update_cmd = profile.get("update_cmd")
587
+ if update_cmd:
588
+ steps.append(
589
+ {
590
+ "name": profile.get("update_step_name", "package_update"),
591
+ "cmd": update_cmd,
592
+ "retries": profile.get("update_retries", 3),
593
+ "retry_delay_s": 5,
594
+ "retry_on": _UPDATE_RETRY_ON,
595
+ }
596
+ )
597
+ install_cmd = profile.get("install_cmd")
598
+ if install_cmd:
599
+ steps.append(
600
+ {
601
+ "name": profile.get("install_step_name", "install_deps"),
602
+ "cmd": install_cmd,
603
+ "retries": profile.get("install_retries", 5),
604
+ "retry_delay_s": 5,
605
+ "retry_on": _INSTALL_RETRY_ON,
606
+ }
607
+ )
608
+ steps.extend(
609
+ [
610
+ {
611
+ "name": "user_exists",
612
+ "cmd": (
613
+ f"id -u {user} >/dev/null 2>&1 || "
614
+ f"(if command -v adduser >/dev/null 2>&1 && adduser --disabled-password --help >/dev/null 2>&1; then "
615
+ f" adduser --disabled-password --gecos '' {user}; "
616
+ "else "
617
+ f" useradd -m -s /bin/sh {user}; "
618
+ "fi)"
619
+ ),
620
+ "retries": 0,
621
+ },
622
+ {
623
+ "name": "add_sudo",
624
+ "cmd": (
625
+ f"if command -v usermod >/dev/null 2>&1; then "
626
+ " if ! getent group sudo >/dev/null 2>&1; then "
627
+ " if command -v groupadd >/dev/null 2>&1; then "
628
+ " groupadd sudo >/dev/null 2>&1 || true; "
629
+ " fi; "
630
+ " fi; "
631
+ f" usermod -aG sudo {user}; "
632
+ "else "
633
+ " for grp in wheel sudo; do "
634
+ " if ! getent group \"$grp\" >/dev/null 2>&1 && command -v groupadd >/dev/null 2>&1; then "
635
+ " groupadd \"$grp\" >/dev/null 2>&1 || true; "
636
+ " fi; "
637
+ " addgroup \"$grp\" >/dev/null 2>&1 || true; "
638
+ f" addgroup {user} \"$grp\" >/dev/null 2>&1 || true; "
639
+ " done; "
640
+ "fi"
641
+ ),
642
+ "retries": 0,
643
+ },
454
644
  {
455
- "name": "install_deps",
456
- "cmd": "apt-get install -y python3 python3-pip sudo --fix-missing",
457
- "retries": 5,
458
- "retry_delay_s": 5,
459
- "retry_on": [
460
- "lock-frontend",
461
- "Unable to acquire the dpkg frontend lock",
462
- "Temporary failure resolving",
463
- "Could not resolve",
464
- "Failed to fetch",
465
- ],
645
+ "name": "add_sudoers",
646
+ "cmd": (
647
+ f"printf '%s ALL=(ALL) NOPASSWD:ALL\\n' {shlex.quote(user)} >/etc/sudoers.d/portacode && "
648
+ "chmod 0440 /etc/sudoers.d/portacode"
649
+ ),
650
+ "retries": 0,
466
651
  },
467
- {"name": "user_exists", "cmd": f"id -u {user} >/dev/null 2>&1 || adduser --disabled-password --gecos '' {user}", "retries": 0},
468
- {"name": "add_sudo", "cmd": f"usermod -aG sudo {user}", "retries": 0},
469
- ]
652
+ ]
653
+ )
470
654
  if password:
471
655
  steps.append({"name": "set_password", "cmd": f"echo '{user}:{password}' | chpasswd", "retries": 0})
472
656
  if ssh_key:
473
- steps.append({
474
- "name": "add_ssh_key",
475
- "cmd": f"install -d -m 700 /home/{user}/.ssh && echo '{ssh_key}' >> /home/{user}/.ssh/authorized_keys && chown -R {user}:{user} /home/{user}/.ssh",
476
- "retries": 0,
477
- })
657
+ steps.append(
658
+ {
659
+ "name": "add_ssh_key",
660
+ "cmd": f"install -d -m 700 /home/{user}/.ssh && echo '{ssh_key}' >> /home/{user}/.ssh/authorized_keys && chown -R {user}:{user} /home/{user}/.ssh",
661
+ "retries": 0,
662
+ }
663
+ )
478
664
  steps.extend(
479
665
  [
480
666
  {"name": "pip_upgrade", "cmd": "python3 -m pip install --upgrade pip", "retries": 0},
@@ -486,6 +672,45 @@ def _build_bootstrap_steps(
486
672
  return steps
487
673
 
488
674
 
675
+ def _guess_package_manager_from_template(template: str) -> str:
676
+ normalized = (template or "").lower()
677
+ if "alpine" in normalized:
678
+ return "apk"
679
+ if "archlinux" in normalized:
680
+ return "pacman"
681
+ if "centos-7" in normalized:
682
+ return "yum"
683
+ if any(keyword in normalized for keyword in ("centos-8", "centos-9", "centos-9-stream", "centos-8-stream")):
684
+ return "dnf"
685
+ if any(keyword in normalized for keyword in ("rockylinux", "almalinux", "fedora")):
686
+ return "dnf"
687
+ if "opensuse" in normalized or "suse" in normalized:
688
+ return "zypper"
689
+ if any(keyword in normalized for keyword in ("debian", "ubuntu", "devuan", "turnkeylinux")):
690
+ return "apt"
691
+ if normalized.startswith("system/") and "linux" in normalized:
692
+ return "apt"
693
+ return "apt"
694
+
695
+
696
+ def _detect_package_manager(vmid: int) -> str:
697
+ candidates = [
698
+ ("apt", "apt-get"),
699
+ ("dnf", "dnf"),
700
+ ("yum", "yum"),
701
+ ("apk", "apk"),
702
+ ("pacman", "pacman"),
703
+ ("zypper", "zypper"),
704
+ ]
705
+ for name, binary in candidates:
706
+ res = _run_pct(vmid, f"command -v {binary} >/dev/null 2>&1")
707
+ if res.get("returncode") == 0:
708
+ logger.debug("Detected package manager %s inside container %s", name, vmid)
709
+ return name
710
+ logger.warning("Unable to detect package manager inside container %s; defaulting to apt", vmid)
711
+ return "apt"
712
+
713
+
489
714
  def _get_storage_type(storages: Iterable[Dict[str, Any]], storage_name: str) -> str:
490
715
  for entry in storages:
491
716
  if entry.get("storage") == storage_name:
@@ -673,7 +898,8 @@ def _connect_proxmox(config: Dict[str, Any]) -> Any:
673
898
 
674
899
 
675
900
  def _run_pct(vmid: int, cmd: str, input_text: Optional[str] = None) -> Dict[str, Any]:
676
- full = ["pct", "exec", str(vmid), "--", "bash", "-lc", cmd]
901
+ shell = "/bin/sh"
902
+ full = ["pct", "exec", str(vmid), "--", shell, "-c", cmd]
677
903
  start = time.time()
678
904
  proc = subprocess.run(full, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, input=input_text)
679
905
  return {
@@ -685,6 +911,10 @@ def _run_pct(vmid: int, cmd: str, input_text: Optional[str] = None) -> Dict[str,
685
911
  }
686
912
 
687
913
 
914
+ def _su_command(user: str, command: str) -> str:
915
+ return f"su - {user} -s /bin/sh -c {shlex.quote(command)}"
916
+
917
+
688
918
  def _run_pct_check(vmid: int, cmd: str) -> Dict[str, Any]:
689
919
  res = _run_pct(vmid, cmd)
690
920
  if res["returncode"] != 0:
@@ -744,7 +974,7 @@ def _push_bytes_to_container(
744
974
 
745
975
 
746
976
  def _resolve_portacode_key_dir(vmid: int, user: str) -> str:
747
- data_dir_cmd = f"su - {user} -c 'echo -n ${{XDG_DATA_HOME:-$HOME/.local/share}}'"
977
+ data_dir_cmd = _su_command(user, "echo -n ${XDG_DATA_HOME:-$HOME/.local/share}")
748
978
  data_home = _run_pct_check(vmid, data_dir_cmd)["stdout"].strip()
749
979
  portacode_dir = f"{data_home}/portacode"
750
980
  _run_pct_exec_check(vmid, ["mkdir", "-p", portacode_dir])
@@ -761,18 +991,19 @@ def _deploy_device_keypair(vmid: int, user: str, private_key: str, public_key: s
761
991
 
762
992
 
763
993
  def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -> Dict[str, Any]:
764
- cmd = ["pct", "exec", str(vmid), "--", "bash", "-lc", f"su - {user} -c 'portacode connect'"]
994
+ su_connect_cmd = _su_command(user, "portacode connect")
995
+ cmd = ["pct", "exec", str(vmid), "--", "/bin/sh", "-c", su_connect_cmd]
765
996
  proc = subprocess.Popen(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
766
997
  start = time.time()
767
998
 
768
- data_dir_cmd = f"su - {user} -c 'echo -n ${{XDG_DATA_HOME:-$HOME/.local/share}}'"
999
+ data_dir_cmd = _su_command(user, "echo -n ${XDG_DATA_HOME:-$HOME/.local/share}")
769
1000
  data_dir = _run_pct_check(vmid, data_dir_cmd)["stdout"].strip()
770
1001
  key_dir = f"{data_dir}/portacode/keys"
771
1002
  pub_path = f"{key_dir}/id_portacode.pub"
772
1003
  priv_path = f"{key_dir}/id_portacode"
773
1004
 
774
1005
  def file_size(path: str) -> Optional[int]:
775
- stat_cmd = f"su - {user} -c 'test -s {path} && stat -c %s {path}'"
1006
+ stat_cmd = _su_command(user, f"test -s {path} && stat -c %s {path}")
776
1007
  res = _run_pct(vmid, stat_cmd)
777
1008
  if res["returncode"] != 0:
778
1009
  return None
@@ -830,7 +1061,7 @@ def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -
830
1061
  final_pub = file_size(pub_path)
831
1062
  final_priv = file_size(priv_path)
832
1063
  if final_pub and final_priv:
833
- key_res = _run_pct(vmid, f"su - {user} -c 'cat {pub_path}'")
1064
+ key_res = _run_pct(vmid, _su_command(user, f"cat {pub_path}"))
834
1065
  if not process_exited:
835
1066
  proc.terminate()
836
1067
  try:
@@ -870,7 +1101,7 @@ def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -
870
1101
  except subprocess.TimeoutExpired:
871
1102
  proc.kill()
872
1103
 
873
- key_res = _run_pct(vmid, f"su - {user} -c 'cat {pub_path}'")
1104
+ key_res = _run_pct(vmid, _su_command(user, f"cat {pub_path}"))
874
1105
  return {
875
1106
  "ok": True,
876
1107
  "public_key": key_res["stdout"].strip(),
@@ -963,7 +1194,16 @@ def _bootstrap_portacode(
963
1194
  total_steps: Optional[int] = None,
964
1195
  default_public_key: Optional[str] = None,
965
1196
  ) -> Tuple[str, List[Dict[str, Any]]]:
966
- actual_steps = steps if steps is not None else _build_bootstrap_steps(user, password, ssh_key)
1197
+ if steps is not None:
1198
+ actual_steps = steps
1199
+ else:
1200
+ detected_manager = _detect_package_manager(vmid)
1201
+ actual_steps = _build_bootstrap_steps(
1202
+ user,
1203
+ password,
1204
+ ssh_key,
1205
+ package_manager=detected_manager,
1206
+ )
967
1207
  results, ok = _run_setup_steps(
968
1208
  vmid,
969
1209
  actual_steps,
@@ -987,6 +1227,15 @@ def _bootstrap_portacode(
987
1227
  else:
988
1228
  command_text = str(command)
989
1229
  command_suffix = f" command={command_text}" if command_text else ""
1230
+ stdout = details.get("stdout")
1231
+ stderr = details.get("stderr")
1232
+ if stdout or stderr:
1233
+ logger.debug(
1234
+ "Bootstrap command output%s%s%s",
1235
+ f" stdout={stdout!r}" if stdout else "",
1236
+ " " if stdout and stderr else "",
1237
+ f"stderr={stderr!r}" if stderr else "",
1238
+ )
990
1239
  if summary:
991
1240
  logger.warning(
992
1241
  "Portacode bootstrap failure summary=%s%s%s",
@@ -994,10 +1243,15 @@ def _bootstrap_portacode(
994
1243
  f" history_len={len(history)}" if history else "",
995
1244
  f" command={command_text}" if command_text else "",
996
1245
  )
997
- raise RuntimeError(
998
- f"Portacode bootstrap steps failed: {summary}{history_snippet}{command_suffix}"
999
- )
1000
- raise RuntimeError("Portacode bootstrap steps failed.")
1246
+ logger.error(
1247
+ "Portacode bootstrap command failed%s%s%s",
1248
+ f" command={command_text}" if command_text else "",
1249
+ f" stdout={stdout!r}" if stdout else "",
1250
+ f" stderr={stderr!r}" if stderr else "",
1251
+ )
1252
+ raise RuntimeError(
1253
+ f"Portacode bootstrap steps failed: {summary}{history_snippet}{command_suffix}"
1254
+ )
1001
1255
  key_step = next((entry for entry in results if entry.get("name") == "portacode_connect"), None)
1002
1256
  public_key = key_step.get("public_key") if key_step else default_public_key
1003
1257
  if not public_key:
@@ -1014,6 +1268,7 @@ def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
1014
1268
  }
1015
1269
  if not config:
1016
1270
  return {"configured": False, "network": base_network}
1271
+ _ensure_templates_refreshed_on_startup(config)
1017
1272
  return {
1018
1273
  "configured": True,
1019
1274
  "host": config.get("host"),
@@ -1063,8 +1318,9 @@ def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl
1063
1318
  "token_value": token_value,
1064
1319
  "verify_ssl": verify_ssl,
1065
1320
  "default_storage": default_storage,
1066
- "templates": templates,
1067
1321
  "last_verified": datetime.utcnow().isoformat() + "Z",
1322
+ "templates": templates,
1323
+ "templates_last_refreshed": _current_time_iso(),
1068
1324
  "network": network,
1069
1325
  "node_status": status,
1070
1326
  }
@@ -1118,7 +1374,7 @@ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) ->
1118
1374
  memory=int(payload["ram_mib"]),
1119
1375
  swap=int(payload.get("swap_mb", 0)),
1120
1376
  cores=max(int(payload.get("cores", 1)), 1),
1121
- cpuunits=int(payload.get("cpuunits", 256)),
1377
+ cpulimit=float(payload.get("cpulimit", payload.get("cpus", 1))),
1122
1378
  net0=payload["net0"],
1123
1379
  unprivileged=int(payload.get("unprivileged", 1)),
1124
1380
  description=payload.get("description", MANAGED_MARKER),
@@ -1126,6 +1382,13 @@ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) ->
1126
1382
  ssh_public_keys=payload.get("ssh_public_key") or None,
1127
1383
  )
1128
1384
  status, elapsed = _wait_for_task(proxmox, node, upid)
1385
+ exitstatus = (status or {}).get("exitstatus")
1386
+ if exitstatus and exitstatus.upper() != "OK":
1387
+ msg = status.get("status") or "unknown error"
1388
+ details = status.get("error") or status.get("errmsg") or status.get("description") or status
1389
+ raise RuntimeError(
1390
+ f"Container creation task failed ({exitstatus}): {msg} details={details}"
1391
+ )
1129
1392
  return vmid, elapsed
1130
1393
  except ResourceException as exc:
1131
1394
  raise RuntimeError(f"Failed to create container: {exc}") from exc
@@ -1141,16 +1404,24 @@ class CreateProxmoxContainerHandler(SyncHandler):
1141
1404
  def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1142
1405
  logger.info("create_proxmox_container command received")
1143
1406
  request_id = message.get("request_id")
1144
- device_id = message.get("device_id")
1407
+ raw_device_id = message.get("device_id")
1408
+ device_id = str(raw_device_id or "").strip()
1409
+ if not device_id:
1410
+ raise ValueError("device_id is required to create a container")
1145
1411
  device_public_key = (message.get("device_public_key") or "").strip()
1146
1412
  device_private_key = (message.get("device_private_key") or "").strip()
1147
1413
  has_device_keypair = bool(device_public_key and device_private_key)
1414
+ config_guess = _load_config()
1415
+ template_candidates = config_guess.get("templates") or []
1416
+ template_hint = (message.get("template") or (template_candidates[0] if template_candidates else "")).strip()
1417
+ package_manager = _guess_package_manager_from_template(template_hint)
1148
1418
  bootstrap_user, bootstrap_password, bootstrap_ssh_key = _get_provisioning_user_info(message)
1149
1419
  bootstrap_steps = _build_bootstrap_steps(
1150
1420
  bootstrap_user,
1151
1421
  bootstrap_password,
1152
1422
  bootstrap_ssh_key,
1153
1423
  include_portacode_connect=not has_device_keypair,
1424
+ package_manager=package_manager,
1154
1425
  )
1155
1426
  total_steps = 3 + len(bootstrap_steps) + 2
1156
1427
  current_step_index = 1
@@ -1173,6 +1444,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1173
1444
  message=start_message,
1174
1445
  phase="lifecycle",
1175
1446
  request_id=request_id,
1447
+ on_behalf_of_device=device_id,
1176
1448
  )
1177
1449
  try:
1178
1450
  result = action()
@@ -1188,6 +1460,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1188
1460
  phase="lifecycle",
1189
1461
  request_id=request_id,
1190
1462
  details={"error": str(exc)},
1463
+ on_behalf_of_device=device_id,
1191
1464
  )
1192
1465
  raise
1193
1466
  _emit_progress_event(
@@ -1200,6 +1473,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1200
1473
  message=success_message,
1201
1474
  phase="lifecycle",
1202
1475
  request_id=request_id,
1476
+ on_behalf_of_device=device_id,
1203
1477
  )
1204
1478
  current_step_index += 1
1205
1479
  return result
@@ -1226,7 +1500,8 @@ class CreateProxmoxContainerHandler(SyncHandler):
1226
1500
  proxmox = _connect_proxmox(config)
1227
1501
  node = config.get("node") or DEFAULT_NODE_NAME
1228
1502
  payload = _build_container_payload(message, config)
1229
- payload["cpuunits"] = max(int(payload["cpus"] * 1024), 10)
1503
+ payload["cpulimit"] = float(payload["cpus"])
1504
+ payload["cores"] = int(max(math.ceil(payload["cpus"]), 1))
1230
1505
  payload["memory"] = int(payload["ram_mib"])
1231
1506
  payload["node"] = node
1232
1507
  logger.debug(
@@ -1241,6 +1516,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1241
1516
  payload["vmid"] = vmid
1242
1517
  payload["created_at"] = datetime.utcnow().isoformat() + "Z"
1243
1518
  payload["status"] = "creating"
1519
+ payload["device_id"] = device_id
1244
1520
  _write_container_record(vmid, payload)
1245
1521
  return proxmox, node, vmid, payload
1246
1522
 
@@ -1301,6 +1577,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1301
1577
  phase="bootstrap",
1302
1578
  request_id=request_id,
1303
1579
  details=details or None,
1580
+ on_behalf_of_device=device_id,
1304
1581
  )
1305
1582
 
1306
1583
  public_key, steps = _bootstrap_portacode(
@@ -1344,6 +1621,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1344
1621
  message="Notifying the server of the new device…",
1345
1622
  phase="service",
1346
1623
  request_id=request_id,
1624
+ on_behalf_of_device=device_id,
1347
1625
  )
1348
1626
  _emit_progress_event(
1349
1627
  self,
@@ -1355,6 +1633,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1355
1633
  message="Authentication metadata recorded.",
1356
1634
  phase="service",
1357
1635
  request_id=request_id,
1636
+ on_behalf_of_device=device_id,
1358
1637
  )
1359
1638
 
1360
1639
  install_step = service_start_index + 1
@@ -1369,9 +1648,10 @@ class CreateProxmoxContainerHandler(SyncHandler):
1369
1648
  message="Running sudo portacode service install…",
1370
1649
  phase="service",
1371
1650
  request_id=request_id,
1651
+ on_behalf_of_device=device_id,
1372
1652
  )
1373
1653
 
1374
- cmd = f"su - {payload['username']} -c 'sudo -S portacode service install'"
1654
+ cmd = _su_command(payload["username"], "sudo -S portacode service install")
1375
1655
  res = _run_pct(vmid, cmd, input_text=payload["password"] + "\n")
1376
1656
 
1377
1657
  if res["returncode"] != 0:
@@ -1389,6 +1669,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1389
1669
  "stderr": res.get("stderr"),
1390
1670
  "stdout": res.get("stdout"),
1391
1671
  },
1672
+ on_behalf_of_device=device_id,
1392
1673
  )
1393
1674
  raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
1394
1675
 
@@ -1402,6 +1683,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1402
1683
  message="Portacode service install finished.",
1403
1684
  phase="service",
1404
1685
  request_id=request_id,
1686
+ on_behalf_of_device=device_id,
1405
1687
  )
1406
1688
 
1407
1689
  logger.info("create_proxmox_container: portacode service install completed inside ct %s", vmid)
@@ -1425,6 +1707,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1425
1707
  },
1426
1708
  "setup_steps": steps,
1427
1709
  "device_id": device_id,
1710
+ "on_behalf_of_device": device_id,
1428
1711
  "service_installed": service_installed,
1429
1712
  }
1430
1713
 
@@ -1450,6 +1733,9 @@ class StartPortacodeServiceHandler(SyncHandler):
1450
1733
  password = record.get("password")
1451
1734
  if not user or not password:
1452
1735
  raise RuntimeError("Container credentials unavailable")
1736
+ on_behalf_of_device = record.get("device_id")
1737
+ if on_behalf_of_device:
1738
+ on_behalf_of_device = str(on_behalf_of_device)
1453
1739
 
1454
1740
  start_index = int(message.get("step_index", 1))
1455
1741
  total_steps = int(message.get("total_steps", start_index + 2))
@@ -1467,6 +1753,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1467
1753
  message="Notifying the server of the new device…",
1468
1754
  phase="service",
1469
1755
  request_id=request_id,
1756
+ on_behalf_of_device=on_behalf_of_device,
1470
1757
  )
1471
1758
  _emit_progress_event(
1472
1759
  self,
@@ -1478,6 +1765,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1478
1765
  message="Authentication metadata recorded.",
1479
1766
  phase="service",
1480
1767
  request_id=request_id,
1768
+ on_behalf_of_device=on_behalf_of_device,
1481
1769
  )
1482
1770
 
1483
1771
  install_step = start_index + 1
@@ -1492,9 +1780,10 @@ class StartPortacodeServiceHandler(SyncHandler):
1492
1780
  message="Running sudo portacode service install…",
1493
1781
  phase="service",
1494
1782
  request_id=request_id,
1783
+ on_behalf_of_device=on_behalf_of_device,
1495
1784
  )
1496
1785
 
1497
- cmd = f"su - {user} -c 'sudo -S portacode service install'"
1786
+ cmd = _su_command(user, "sudo -S portacode service install")
1498
1787
  res = _run_pct(vmid, cmd, input_text=password + "\n")
1499
1788
 
1500
1789
  if res["returncode"] != 0:
@@ -1512,6 +1801,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1512
1801
  "stderr": res.get("stderr"),
1513
1802
  "stdout": res.get("stdout"),
1514
1803
  },
1804
+ on_behalf_of_device=on_behalf_of_device,
1515
1805
  )
1516
1806
  raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
1517
1807
 
@@ -1525,6 +1815,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1525
1815
  message="Portacode service install finished.",
1526
1816
  phase="service",
1527
1817
  request_id=request_id,
1818
+ on_behalf_of_device=on_behalf_of_device,
1528
1819
  )
1529
1820
 
1530
1821
  return {
@@ -685,6 +685,7 @@ class TerminalManager:
685
685
  try:
686
686
  # Initialize project state
687
687
  project_state = await manager.initialize_project_state(session_name, project_folder_path)
688
+ await self._restore_tabs_from_session_metadata(manager, project_state, session)
688
689
 
689
690
  # Send initial project state to the client
690
691
  initial_state_payload = {
@@ -710,6 +711,85 @@ class TerminalManager:
710
711
 
711
712
  except Exception as exc:
712
713
  logger.exception("terminal_manager: Error initializing project states for new sessions: %s", exc)
714
+
715
+ async def _restore_tabs_from_session_metadata(self, manager, project_state, session):
716
+ """Restore open tabs/active tab from client session metadata if available."""
717
+ if not session or not project_state:
718
+ return
719
+
720
+ descriptors = session.get("open_tabs") or []
721
+ if not descriptors:
722
+ return
723
+
724
+ session_id = project_state.client_session_id
725
+ logger.info("terminal_manager: 🧭 Restoring %d tabs from metadata for session %s", len(descriptors), session_id)
726
+
727
+ for descriptor in descriptors:
728
+ parsed = self._parse_tab_descriptor(descriptor)
729
+ if not parsed:
730
+ continue
731
+
732
+ tab_type = parsed.get("tab_type")
733
+ file_path = parsed.get("file_path")
734
+ metadata = parsed.get("metadata", {})
735
+
736
+ if tab_type == "file" and file_path:
737
+ try:
738
+ await manager.open_file(session_id, file_path, set_active=False)
739
+ except Exception as exc:
740
+ logger.warning("terminal_manager: Failed to restore file tab %s for session %s: %s", file_path, session_id, exc)
741
+ continue
742
+
743
+ if tab_type == "diff" and file_path:
744
+ from_ref = metadata.get("from") or metadata.get("from_ref")
745
+ to_ref = metadata.get("to") or metadata.get("to_ref")
746
+ if not from_ref or not to_ref:
747
+ logger.warning("terminal_manager: Skipping diff tab %s for session %s because from/to references are missing", file_path, session_id)
748
+ continue
749
+ from_hash = metadata.get("from_hash") or metadata.get("fromHash")
750
+ to_hash = metadata.get("to_hash") or metadata.get("toHash")
751
+ try:
752
+ await manager.open_diff_tab(session_id, file_path, from_ref, to_ref, from_hash=from_hash, to_hash=to_hash)
753
+ except Exception as exc:
754
+ logger.warning("terminal_manager: Failed to restore diff tab %s for session %s: %s", file_path, session_id, exc)
755
+ continue
756
+
757
+ logger.debug("terminal_manager: Unknown tab descriptor ignored for session %s: %s", session_id, descriptor)
758
+
759
+ active_index = session.get("active_tab")
760
+ try:
761
+ active_index_int = int(active_index) if active_index is not None else None
762
+ except (TypeError, ValueError):
763
+ active_index_int = None
764
+
765
+ if active_index_int is not None and active_index_int >= 0:
766
+ current_tabs = list(project_state.open_tabs.values())
767
+ if 0 <= active_index_int < len(current_tabs):
768
+ try:
769
+ await manager.set_active_tab(session_id, current_tabs[active_index_int].tab_id)
770
+ except Exception as exc:
771
+ logger.warning("terminal_manager: Failed to set active tab for session %s: %s", session_id, exc)
772
+ else:
773
+ logger.debug("terminal_manager: Active tab index %s out of range for session %s", active_index_int, session_id)
774
+
775
+ def _parse_tab_descriptor(self, descriptor: str) -> Optional[Dict[str, Any]]:
776
+ """Parse a URL-friendly tab descriptor string."""
777
+ if not descriptor:
778
+ return None
779
+
780
+ try:
781
+ parts = descriptor.split("|")
782
+ tab_type = parts[0] if parts else None
783
+ file_path = parts[1] if len(parts) > 1 else None
784
+ metadata = {}
785
+ for part in parts[2:]:
786
+ if "=" in part:
787
+ key, value = part.split("=", 1)
788
+ metadata[key] = value
789
+ return {"tab_type": tab_type, "file_path": file_path, "metadata": metadata}
790
+ except Exception as exc:
791
+ logger.warning("terminal_manager: Failed to parse tab descriptor '%s': %s", descriptor, exc)
792
+ return None
713
793
 
714
794
  async def _send_targeted_terminal_list(self, message: Dict[str, Any], target_sessions: List[str]) -> None:
715
795
  """Send terminal_list command to specific client sessions.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: portacode
3
- Version: 1.4.14
3
+ Version: 1.4.15
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
@@ -1,7 +1,7 @@
1
1
  portacode/README.md,sha256=4dKtpvR8LNgZPVz37GmkQCMWIr_u25Ao63iW56s7Ke4,775
2
2
  portacode/__init__.py,sha256=oB3sV1wXr-um-RXio73UG8E5Xx6cF2ZVJveqjNmC-vQ,1086
3
3
  portacode/__main__.py,sha256=jmHTGC1hzmo9iKJLv-SSYe9BSIbPPZ2IOpecI03PlTs,296
4
- portacode/_version.py,sha256=kpfD1bHnQkFa0-DFuQayl0P6Mvgylvye8ENBeTdr_CY,706
4
+ portacode/_version.py,sha256=etxcB5M9Bu6fDP8xB_u0QGAYtqm9iTbzbI4qxCPucxs,706
5
5
  portacode/cli.py,sha256=mGLKoZ-T2FBF7IA9wUq0zyG0X9__-A1ao7gajjcVRH8,21828
6
6
  portacode/data.py,sha256=5-s291bv8J354myaHm1Y7CQZTZyRzMU3TGe5U4hb-FA,1591
7
7
  portacode/keypair.py,sha256=0OO4vHDcF1XMxCDqce61xFTlFwlTcmqe5HyGsXFEt7s,5838
@@ -12,9 +12,9 @@ portacode/connection/README.md,sha256=f9rbuIEKa7cTm9C98rCiBbEtbiIXQU11esGSNhSMiJ
12
12
  portacode/connection/__init__.py,sha256=atqcVGkViIEd7pRa6cP2do07RJOM0UWpbnz5zXjGktU,250
13
13
  portacode/connection/client.py,sha256=jtLb9_YufqPkzi9t8VQH3iz_JEMisbtY6a8L9U5weiU,14181
14
14
  portacode/connection/multiplex.py,sha256=L-TxqJ_ZEbfNEfu1cwxgJ5vUdyRzZjsMy2Kx1diiZys,5237
15
- portacode/connection/terminal.py,sha256=oyLPOVLPlUuN_eRvHPGazB51yi8W8JEF3oOEYxucGTE,45069
15
+ portacode/connection/terminal.py,sha256=n1Uu92JacV5K6d1Qwx94Tw9OB2Tpke5HqsW2NDn76Ls,49032
16
16
  portacode/connection/handlers/README.md,sha256=HsLZG1QK1JNm67HsgL6WoDg9nxzKXxwkc5fJPFJdX5g,12169
17
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=--NNK-qrqQhZ9O4RHuAQfDQb6QxKtl_No101c1Sy_cQ,98994
17
+ portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=OtObmoVAXlf0uU1HidTWNmyJYBS1Yl6Rpgyh6TOTjUQ,100590
18
18
  portacode/connection/handlers/__init__.py,sha256=WSeBmi65GWFQPYt9M3E10rn0uZ_EPCJzNJOzSf2HZyw,2921
19
19
  portacode/connection/handlers/base.py,sha256=oENFb-Fcfzwk99Qx8gJQriEMiwSxwygwjOiuCH36hM4,10231
20
20
  portacode/connection/handlers/chunked_content.py,sha256=h6hXRmxSeOgnIxoU8CkmvEf2Odv-ajPrpHIe_W3GKcA,9251
@@ -22,7 +22,7 @@ portacode/connection/handlers/diff_handlers.py,sha256=iYTIRCcpEQ03vIPKZCsMTE5aZb
22
22
  portacode/connection/handlers/file_handlers.py,sha256=nAJH8nXnX07xxD28ngLpgIUzcTuRwZBNpEGEKdRqohw,39507
23
23
  portacode/connection/handlers/project_aware_file_handlers.py,sha256=AqgMnDqX2893T2NsrvUSCwjN5VKj4Pb2TN0S_SuboOE,9803
24
24
  portacode/connection/handlers/project_state_handlers.py,sha256=v6ZefGW9i7n1aZLq2jOGumJIjYb6aHlPI4m1jkYewm8,1686
25
- portacode/connection/handlers/proxmox_infra.py,sha256=v2IftCXnNO8ta8dWfdXf_eRBAeFO6YxkFTCSuTrp5ss,61134
25
+ portacode/connection/handlers/proxmox_infra.py,sha256=DE2TUPKwW4bZ9L7vxyE_m74-ra0JR9DfUCkUOPg5tpk,71908
26
26
  portacode/connection/handlers/registry.py,sha256=qXGE60sYEWg6ZtVQzFcZ5YI2XWR6lMgw4hAL9x5qR1I,6181
27
27
  portacode/connection/handlers/session.py,sha256=uNGfiO_1B9-_yjJKkpvmbiJhIl6b-UXlT86UTfd6WYE,42219
28
28
  portacode/connection/handlers/system_handlers.py,sha256=fr12QpOr_Z8KYGUU-AYrTQwRPAcrLK85hvj3SEq1Kw8,14757
@@ -35,7 +35,7 @@ portacode/connection/handlers/project_state/__init__.py,sha256=5ucIqk6Iclqg6bKkL
35
35
  portacode/connection/handlers/project_state/file_system_watcher.py,sha256=r9_UKxWTbzum0jGqxIafe68Ced2Y3xOp3ZmkpBOfRpw,8573
36
36
  portacode/connection/handlers/project_state/git_manager.py,sha256=iGQ7LYIA7uHsZHdj3HEc_LYo7S1Lqv6-AeyyMwknBPo,70027
37
37
  portacode/connection/handlers/project_state/handlers.py,sha256=qgOSt26rxAGNxW07AoevTwDPBdxblX4J-dX-EjOKtg4,38232
38
- portacode/connection/handlers/project_state/manager.py,sha256=pRMZqPOTK9YE3abNxiAbnERIJmRys673HFOEIBiKnm4,67184
38
+ portacode/connection/handlers/project_state/manager.py,sha256=ori_QpeoY1sdpY8WDYIx-kl_gNfZ7o8eq84CcOBlvIs,67915
39
39
  portacode/connection/handlers/project_state/models.py,sha256=EZTKvxHKs8QlQUbzI0u2IqfzfRRXZixUIDBwTGCJATI,4313
40
40
  portacode/connection/handlers/project_state/utils.py,sha256=LsbQr9TH9Bz30FqikmtTxco4PlB_n0kUIuPKQ6Fb_mo,1665
41
41
  portacode/link_capture/__init__.py,sha256=93LjyYDqzOimsIDBhsPibTl7tr-8DiIzyDF7JWQkE2A,1231
@@ -65,7 +65,7 @@ portacode/utils/__init__.py,sha256=NgBlWTuNJESfIYJzP_3adI1yJQJR0XJLRpSdVNaBAN0,3
65
65
  portacode/utils/diff_apply.py,sha256=4Oi7ft3VUCKmiUE4VM-OeqO7Gk6H7PF3WnN4WHXtjxI,15157
66
66
  portacode/utils/diff_renderer.py,sha256=S76StnQ2DLfsz4Gg0m07UwPfRp8270PuzbNaQq-rmYk,13850
67
67
  portacode/utils/ntp_clock.py,sha256=VqCnWCTehCufE43W23oB-WUdAZGeCcLxkmIOPwInYHc,2499
68
- portacode-1.4.14.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
68
+ portacode-1.4.15.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
69
69
  test_modules/README.md,sha256=Do_agkm9WhSzueXjRAkV_xEj6Emy5zB3N3VKY5Roce8,9274
70
70
  test_modules/__init__.py,sha256=1LcbHodIHsB0g-g4NGjSn6AMuCoGbymvXPYLOb6Z7F0,53
71
71
  test_modules/test_device_online.py,sha256=QtYq0Dq9vME8Gp2O4fGSheqVf8LUtpsSKosXXk56gGM,1654
@@ -91,8 +91,8 @@ testing_framework/core/playwright_manager.py,sha256=Tw46qwxIhOFkS48C2IWIQHHNpEe-
91
91
  testing_framework/core/runner.py,sha256=j2QwNJmAxVBmJvcbVS7DgPJUKPNzqfLmt_4NNdaKmZU,19297
92
92
  testing_framework/core/shared_cli_manager.py,sha256=BESSNtyQb7BOlaOvZmm04T8Uezjms4KCBs2MzTxvzYQ,8790
93
93
  testing_framework/core/test_discovery.py,sha256=2FZ9fJ8Dp5dloA-fkgXoJ_gCMC_nYPBnA3Hs2xlagzM,4928
94
- portacode-1.4.14.dist-info/METADATA,sha256=NfhCGg_ZISg6wvISMe9SRNlRcIqfKoUvDZt2ac0Dg8c,13046
95
- portacode-1.4.14.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
96
- portacode-1.4.14.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
97
- portacode-1.4.14.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
98
- portacode-1.4.14.dist-info/RECORD,,
94
+ portacode-1.4.15.dist-info/METADATA,sha256=RFm2fBFzusB-xJHakI-C1nr-5_lNUhRwFQea5lg7sX8,13046
95
+ portacode-1.4.15.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
96
+ portacode-1.4.15.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
97
+ portacode-1.4.15.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
98
+ portacode-1.4.15.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.10.1)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5