portacode 0.3.4.dev0__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 (93) hide show
  1. portacode/_version.py +16 -3
  2. portacode/cli.py +155 -19
  3. portacode/connection/client.py +152 -12
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +1577 -0
  5. portacode/connection/handlers/__init__.py +43 -1
  6. portacode/connection/handlers/base.py +122 -18
  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 -0
  20. portacode/connection/handlers/proxmox_infra.py +307 -0
  21. portacode/connection/handlers/registry.py +53 -10
  22. portacode/connection/handlers/session.py +705 -53
  23. portacode/connection/handlers/system_handlers.py +142 -8
  24. portacode/connection/handlers/tab_factory.py +389 -0
  25. portacode/connection/handlers/terminal_handlers.py +150 -11
  26. portacode/connection/handlers/update_handler.py +61 -0
  27. portacode/connection/multiplex.py +60 -2
  28. portacode/connection/terminal.py +695 -28
  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/service.py +6 -0
  53. portacode/static/js/test-ntp-clock.html +63 -0
  54. portacode/static/js/utils/ntp-clock.js +232 -0
  55. portacode/utils/NTP_ARCHITECTURE.md +136 -0
  56. portacode/utils/__init__.py +1 -0
  57. portacode/utils/diff_apply.py +456 -0
  58. portacode/utils/diff_renderer.py +371 -0
  59. portacode/utils/ntp_clock.py +65 -0
  60. portacode-1.4.11.dev0.dist-info/METADATA +298 -0
  61. portacode-1.4.11.dev0.dist-info/RECORD +97 -0
  62. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/WHEEL +1 -1
  63. portacode-1.4.11.dev0.dist-info/top_level.txt +3 -0
  64. test_modules/README.md +296 -0
  65. test_modules/__init__.py +1 -0
  66. test_modules/test_device_online.py +44 -0
  67. test_modules/test_file_operations.py +743 -0
  68. test_modules/test_git_status_ui.py +370 -0
  69. test_modules/test_login_flow.py +50 -0
  70. test_modules/test_navigate_testing_folder.py +361 -0
  71. test_modules/test_play_store_screenshots.py +294 -0
  72. test_modules/test_terminal_buffer_performance.py +261 -0
  73. test_modules/test_terminal_interaction.py +80 -0
  74. test_modules/test_terminal_loading_race_condition.py +95 -0
  75. test_modules/test_terminal_start.py +56 -0
  76. testing_framework/.env.example +21 -0
  77. testing_framework/README.md +334 -0
  78. testing_framework/__init__.py +17 -0
  79. testing_framework/cli.py +326 -0
  80. testing_framework/core/__init__.py +1 -0
  81. testing_framework/core/base_test.py +336 -0
  82. testing_framework/core/cli_manager.py +177 -0
  83. testing_framework/core/hierarchical_runner.py +577 -0
  84. testing_framework/core/playwright_manager.py +520 -0
  85. testing_framework/core/runner.py +447 -0
  86. testing_framework/core/shared_cli_manager.py +234 -0
  87. testing_framework/core/test_discovery.py +112 -0
  88. testing_framework/requirements.txt +12 -0
  89. portacode-0.3.4.dev0.dist-info/METADATA +0 -236
  90. portacode-0.3.4.dev0.dist-info/RECORD +0 -27
  91. portacode-0.3.4.dev0.dist-info/top_level.txt +0 -1
  92. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/entry_points.txt +0 -0
  93. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info/licenses}/LICENSE +0 -0
portacode/keypair.py CHANGED
@@ -98,4 +98,66 @@ def fingerprint_public_key(pem: bytes) -> str:
98
98
  """Return a short fingerprint for display purposes (SHA-256)."""
99
99
  digest = hashes.Hash(hashes.SHA256())
100
100
  digest.update(pem)
101
- return digest.finalize().hex()[:16]
101
+ return digest.finalize().hex()[:16]
102
+
103
+
104
+ class InMemoryKeyPair:
105
+ """Keypair kept purely in memory until explicitly persisted."""
106
+
107
+ def __init__(self, private_pem: bytes, public_pem: bytes, key_dir: Path):
108
+ self._private_pem = private_pem
109
+ self._public_pem = public_pem
110
+ self._key_dir = key_dir
111
+ self._is_new = True
112
+
113
+ @property
114
+ def private_key_pem(self) -> bytes:
115
+ return self._private_pem
116
+
117
+ @property
118
+ def public_key_pem(self) -> bytes:
119
+ return self._public_pem
120
+
121
+ def sign_challenge(self, challenge: str) -> bytes:
122
+ private_key = serialization.load_pem_private_key(self._private_pem, password=None)
123
+ return private_key.sign(
124
+ challenge.encode(),
125
+ padding.PKCS1v15(),
126
+ hashes.SHA256(),
127
+ )
128
+
129
+ def public_key_der_b64(self) -> str:
130
+ pubkey = serialization.load_pem_public_key(self._public_pem)
131
+ der = pubkey.public_bytes(
132
+ encoding=serialization.Encoding.DER,
133
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
134
+ )
135
+ return base64.b64encode(der).decode()
136
+
137
+ def persist(self) -> KeyPair:
138
+ """Write the keypair to disk and return a regular KeyPair."""
139
+ key_dir = self._key_dir
140
+ key_dir.mkdir(parents=True, exist_ok=True)
141
+ priv_path = key_dir / PRIVATE_KEY_FILE
142
+ pub_path = key_dir / PUBLIC_KEY_FILE
143
+ priv_path.write_bytes(self._private_pem)
144
+ pub_path.write_bytes(self._public_pem)
145
+ keypair = KeyPair(priv_path, pub_path)
146
+ keypair._is_new = True # type: ignore[attr-defined]
147
+ keypair._key_dir = key_dir # type: ignore[attr-defined]
148
+ return keypair
149
+
150
+
151
+ def generate_in_memory_keypair() -> InMemoryKeyPair:
152
+ """Generate a new keypair but keep it in memory until pairing succeeds."""
153
+ private_pem, public_pem = _generate_keypair()
154
+ key_dir = get_key_dir()
155
+ return InMemoryKeyPair(private_pem, public_pem, key_dir)
156
+
157
+
158
+ def keypair_files_exist() -> bool:
159
+ """Return True if the persisted keypair already exists on disk."""
160
+ key_dir = get_key_dir()
161
+ priv_path = key_dir / PRIVATE_KEY_FILE
162
+ pub_path = key_dir / PUBLIC_KEY_FILE
163
+ return priv_path.exists() and pub_path.exists()
@@ -0,0 +1,38 @@
1
+ """Helpers for the link capture wrapper scripts."""
2
+
3
+ import shutil
4
+ import tempfile
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ try:
9
+ # Use the stdlib helpers when they are available (Python ≥3.9).
10
+ from importlib.resources import as_file, files
11
+ except ImportError: # pragma: no cover
12
+ # Fall back to the backport for older Python 3.x runtimes (>=3.6).
13
+ from importlib_resources import as_file, files
14
+
15
+ _LINK_CAPTURE_TEMP_DIR: Optional[Path] = None
16
+
17
+
18
+ def prepare_link_capture_bin() -> Optional[Path]:
19
+ """Extract the packaged link capture wrappers into a temporary dir and return it."""
20
+ global _LINK_CAPTURE_TEMP_DIR
21
+ if _LINK_CAPTURE_TEMP_DIR:
22
+ return _LINK_CAPTURE_TEMP_DIR
23
+
24
+ bin_source = files(__package__) / "bin"
25
+ if not bin_source.is_dir():
26
+ return None
27
+
28
+ temp_dir = Path(tempfile.mkdtemp(prefix="portacode-link-capture-"))
29
+ for entry in bin_source.iterdir():
30
+ if not entry.is_file():
31
+ continue
32
+ with as_file(entry) as file_path:
33
+ dest = temp_dir / entry.name
34
+ shutil.copyfile(file_path, dest)
35
+ dest.chmod(dest.stat().st_mode | 0o111)
36
+
37
+ _LINK_CAPTURE_TEMP_DIR = temp_dir
38
+ return _LINK_CAPTURE_TEMP_DIR
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" elinks "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" gio-open "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" gnome-open "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" gvfs-open "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" kde-open "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" kfmclient "$@"
@@ -0,0 +1,11 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ COMMAND_NAME="$1"
4
+ shift
5
+
6
+ if [ -z "$COMMAND_NAME" ]; then
7
+ echo "link_capture: missing command name" >&2
8
+ exit 1
9
+ fi
10
+
11
+ exec "$SCRIPT_DIR/link_capture_wrapper.py" "$COMMAND_NAME" "$@"
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env python3
2
+ """Simple link capture wrapper that never executes a native browser."""
3
+
4
+ import json
5
+ import os
6
+ import sys
7
+ import time
8
+ import uuid
9
+ from pathlib import Path
10
+ from urllib.parse import urlparse
11
+
12
+ LINK_CHANNEL_ENV = "PORTACODE_LINK_CHANNEL"
13
+ TERMINAL_ID_ENV = "PORTACODE_TERMINAL_ID"
14
+
15
+
16
+ def _find_link_argument(args):
17
+ for arg in args:
18
+ if not isinstance(arg, str):
19
+ continue
20
+ parsed = urlparse(arg)
21
+ if parsed.scheme and parsed.netloc:
22
+ return arg
23
+ if arg.startswith("file://"):
24
+ return arg
25
+ return None
26
+
27
+
28
+ def _write_capture_event(cmd_name, args, link):
29
+ channel = os.environ.get(LINK_CHANNEL_ENV)
30
+ terminal_id = os.environ.get(TERMINAL_ID_ENV)
31
+ if not channel or not link:
32
+ return
33
+ payload = {
34
+ "terminal_id": terminal_id,
35
+ "command": cmd_name,
36
+ "args": args,
37
+ "url": link,
38
+ "timestamp": time.time(),
39
+ }
40
+ directory = Path(channel)
41
+ try:
42
+ directory.mkdir(parents=True, exist_ok=True)
43
+ except Exception:
44
+ return
45
+ temp_file = directory / f".{uuid.uuid4().hex}.tmp"
46
+ term_label = terminal_id or "unknown"
47
+ base_name = f"{int(time.time() * 1000)}-{term_label}"
48
+ final_file = directory / f"{base_name}.json"
49
+ suffix = 0
50
+ while final_file.exists():
51
+ suffix += 1
52
+ final_file = directory / f"{base_name}-{suffix}.json"
53
+ try:
54
+ temp_file.write_text(json.dumps(payload), encoding="utf-8")
55
+ temp_file.replace(final_file)
56
+ except Exception:
57
+ if temp_file.exists():
58
+ temp_file.unlink(missing_ok=True)
59
+
60
+
61
+ def main() -> None:
62
+ if len(sys.argv) < 2:
63
+ sys.stderr.write("link_capture: missing target command name\n")
64
+ sys.exit(1)
65
+ cmd_name = sys.argv[1]
66
+ cmd_args = sys.argv[2:]
67
+ link = _find_link_argument(cmd_args)
68
+ if link:
69
+ _write_capture_event(cmd_name, cmd_args, link)
70
+ # Never run a real browser; capture and exit successfully.
71
+ sys.exit(0)
72
+
73
+
74
+ if __name__ == "__main__":
75
+ main()
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" links "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" links2 "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" lynx "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" mate-open "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" netsurf "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" sensible-browser "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" w3m "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" x-www-browser "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" xdg-open "$@"
@@ -0,0 +1,140 @@
1
+ """
2
+ Categorized logging system for Portacode CLI.
3
+
4
+ This module provides a logging system that allows filtering by categories
5
+ to help developers focus on specific aspects of the application during debugging.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import Set, Optional
12
+ from enum import Enum
13
+
14
+
15
+ class LogCategory(Enum):
16
+ """Available log categories for filtering."""
17
+ CONNECTION = "connection"
18
+ AUTHENTICATION = "auth"
19
+ WEBSOCKET = "websocket"
20
+ TERMINAL = "terminal"
21
+ PROJECT_STATE = "project_state"
22
+ FILE_SYSTEM = "filesystem"
23
+ GIT = "git"
24
+ HANDLERS = "handlers"
25
+ MULTIPLEXER = "mux"
26
+ SYSTEM = "system"
27
+ DEBUG = "debug"
28
+
29
+
30
+ class CategorizedLogger:
31
+ """
32
+ A logger that supports category-based filtering.
33
+
34
+ This wraps the standard Python logger but adds category support
35
+ for fine-grained filtering during debugging.
36
+ """
37
+
38
+ def __init__(self, name: str, enabled_categories: Optional[Set[str]] = None):
39
+ self.logger = logging.getLogger(name)
40
+ self.enabled_categories = enabled_categories or set()
41
+ self._all_enabled = len(self.enabled_categories) == 0 # If no categories specified, show all
42
+
43
+ def _should_log(self, category: LogCategory) -> bool:
44
+ """Check if a log with this category should be output."""
45
+ if self._all_enabled:
46
+ return True
47
+ return category.value in self.enabled_categories
48
+
49
+ def _format_message(self, msg: str, category: LogCategory) -> str:
50
+ """Format message with category prefix."""
51
+ return f"[{category.value.upper()}] {msg}"
52
+
53
+ def _parse_args(self, *args) -> tuple[LogCategory, tuple]:
54
+ """Parse arguments to extract category and remaining args."""
55
+ if args and isinstance(args[0], LogCategory):
56
+ return args[0], args[1:]
57
+ else:
58
+ return LogCategory.DEBUG, args
59
+
60
+ def debug(self, msg: str, *args, **kwargs):
61
+ """Log debug message with optional category."""
62
+ category, remaining_args = self._parse_args(*args)
63
+ if self._should_log(category):
64
+ formatted_msg = self._format_message(msg, category)
65
+ self.logger.debug(formatted_msg, *remaining_args, **kwargs)
66
+
67
+ def info(self, msg: str, *args, **kwargs):
68
+ """Log info message with optional category."""
69
+ category, remaining_args = self._parse_args(*args)
70
+ if self._should_log(category):
71
+ formatted_msg = self._format_message(msg, category)
72
+ self.logger.info(formatted_msg, *remaining_args, **kwargs)
73
+
74
+ def warning(self, msg: str, *args, **kwargs):
75
+ """Log warning message with optional category."""
76
+ category, remaining_args = self._parse_args(*args)
77
+ if self._should_log(category):
78
+ formatted_msg = self._format_message(msg, category)
79
+ self.logger.warning(formatted_msg, *remaining_args, **kwargs)
80
+
81
+ def error(self, msg: str, *args, **kwargs):
82
+ """Log error message with optional category."""
83
+ category, remaining_args = self._parse_args(*args)
84
+ if self._should_log(category):
85
+ formatted_msg = self._format_message(msg, category)
86
+ self.logger.error(formatted_msg, *remaining_args, **kwargs)
87
+
88
+ def exception(self, msg: str, *args, **kwargs):
89
+ """Log exception message with optional category."""
90
+ category, remaining_args = self._parse_args(*args)
91
+ if self._should_log(category):
92
+ formatted_msg = self._format_message(msg, category)
93
+ self.logger.exception(formatted_msg, *remaining_args, **kwargs)
94
+
95
+
96
+ # Global registry of enabled categories
97
+ _enabled_categories: Set[str] = set()
98
+
99
+
100
+ def configure_logging_categories(categories: Set[str]) -> None:
101
+ """Configure which log categories should be enabled globally."""
102
+ global _enabled_categories
103
+ _enabled_categories = categories
104
+
105
+
106
+ def get_categorized_logger(name: str) -> CategorizedLogger:
107
+ """Get a categorized logger instance with current category settings."""
108
+ return CategorizedLogger(name, _enabled_categories)
109
+
110
+
111
+ def list_available_categories() -> list[str]:
112
+ """Return a list of all available log categories."""
113
+ return [category.value for category in LogCategory]
114
+
115
+
116
+ def parse_category_string(category_str: str) -> Set[str]:
117
+ """
118
+ Parse a comma-separated string of categories into a set.
119
+
120
+ Args:
121
+ category_str: Comma-separated category names (e.g., "connection,auth,git")
122
+
123
+ Returns:
124
+ Set of valid category names
125
+
126
+ Raises:
127
+ ValueError: If any category is invalid
128
+ """
129
+ if not category_str.strip():
130
+ return set()
131
+
132
+ categories = {cat.strip().lower() for cat in category_str.split(',')}
133
+ valid_categories = {cat.value for cat in LogCategory}
134
+
135
+ invalid = categories - valid_categories
136
+ if invalid:
137
+ raise ValueError(f"Invalid categories: {', '.join(invalid)}. "
138
+ f"Valid categories: {', '.join(sorted(valid_categories))}")
139
+
140
+ return categories
portacode/pairing.py ADDED
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import socket
7
+ from dataclasses import dataclass
8
+ from typing import Optional
9
+
10
+ import websockets
11
+
12
+ PAIRING_URL_ENV = "PORTACODE_PAIRING_URL"
13
+ DEFAULT_PAIRING_URL = "wss://portacode.com/ws/pairing/device/"
14
+
15
+
16
+ class PairingError(Exception):
17
+ """Raised when pairing fails or is rejected."""
18
+
19
+
20
+ @dataclass
21
+ class PairingResult:
22
+ status: str
23
+ payload: dict
24
+
25
+
26
+ async def _pair_device(
27
+ url: str,
28
+ public_key_b64: str,
29
+ pairing_code: str,
30
+ device_name: str,
31
+ project_paths: list[str] | None = None,
32
+ timeout: float = 300.0,
33
+ ) -> PairingResult:
34
+ async with websockets.connect(url) as ws:
35
+ # Initial ready/event
36
+ try:
37
+ ready = await asyncio.wait_for(ws.recv(), timeout=10)
38
+ except asyncio.TimeoutError as exc:
39
+ raise PairingError("Gateway did not acknowledge pairing request") from exc
40
+
41
+ try:
42
+ data = json.loads(ready)
43
+ if data.get("event") != "pairing_ready":
44
+ raise PairingError(f"Unexpected response from pairing gateway: {ready}")
45
+ except json.JSONDecodeError:
46
+ raise PairingError(f"Unexpected response from pairing gateway: {ready}") from None
47
+
48
+ payload = {
49
+ "code": pairing_code,
50
+ "device_name": device_name,
51
+ "public_key": public_key_b64,
52
+ }
53
+ if project_paths:
54
+ payload["project_paths"] = project_paths
55
+ await ws.send(json.dumps(payload))
56
+
57
+ while True:
58
+ try:
59
+ message = await asyncio.wait_for(ws.recv(), timeout=timeout)
60
+ except asyncio.TimeoutError as exc:
61
+ raise PairingError("Pairing timed out waiting for approval") from exc
62
+
63
+ try:
64
+ data = json.loads(message)
65
+ except json.JSONDecodeError:
66
+ continue
67
+
68
+ if data.get("event") != "pairing_status":
69
+ continue
70
+
71
+ status = data.get("status")
72
+ if status == "pending":
73
+ continue
74
+ if status == "approved":
75
+ return PairingResult(status="approved", payload=data)
76
+ reason = data.get("reason") or status or "unknown_error"
77
+ raise PairingError(reason)
78
+
79
+
80
+ def pair_device_with_code(
81
+ keypair,
82
+ pairing_code: str,
83
+ device_name: Optional[str] = None,
84
+ project_paths: list[str] | None = None,
85
+ *,
86
+ timeout: float = 300.0,
87
+ ) -> PairingResult:
88
+ """Run the pairing workflow synchronously."""
89
+ pairing_url = os.getenv(PAIRING_URL_ENV, DEFAULT_PAIRING_URL)
90
+ normalized_url = pairing_url if pairing_url.startswith("ws") else f"wss://{pairing_url.lstrip('/')}"
91
+ friendly_name = device_name or socket.gethostname() or "Portacode Device"
92
+
93
+ public_key_b64 = keypair.public_key_der_b64()
94
+ return asyncio.run(
95
+ _pair_device(
96
+ normalized_url,
97
+ public_key_b64=public_key_b64,
98
+ pairing_code=pairing_code,
99
+ device_name=friendly_name,
100
+ project_paths=project_paths,
101
+ timeout=timeout,
102
+ )
103
+ )
portacode/service.py CHANGED
@@ -92,6 +92,10 @@ class _SystemdUserService:
92
92
 
93
93
  def install(self) -> None:
94
94
  self.service_path.parent.mkdir(parents=True, exist_ok=True)
95
+
96
+ # Capture current SHELL for the service to prevent using /bin/sh in containers/virtualized environments
97
+ current_shell = os.getenv("SHELL", "/bin/bash")
98
+
95
99
  if self.system_mode:
96
100
  sudo_needed = os.geteuid() != 0
97
101
  prefix = ["sudo"] if sudo_needed else []
@@ -104,6 +108,7 @@ class _SystemdUserService:
104
108
  Type=simple
105
109
  User={self.user}
106
110
  WorkingDirectory={self.home}
111
+ Environment=SHELL={current_shell}
107
112
  ExecStart={self.python} -m portacode connect --non-interactive
108
113
  Restart=on-failure
109
114
  RestartSec=5
@@ -119,6 +124,7 @@ class _SystemdUserService:
119
124
 
120
125
  [Service]
121
126
  Type=simple
127
+ Environment=SHELL={current_shell}
122
128
  ExecStart={self.python} -m portacode.cli connect --non-interactive
123
129
  Restart=on-failure
124
130
  RestartSec=5
@@ -0,0 +1,63 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>NTP Clock Test</title>
5
+ <style>
6
+ body {
7
+ font-family: monospace;
8
+ padding: 20px;
9
+ background: #1e1e1e;
10
+ color: #d4d4d4;
11
+ }
12
+ h1 {
13
+ color: #4ec9b0;
14
+ }
15
+ #status {
16
+ background: #252526;
17
+ padding: 15px;
18
+ border-radius: 5px;
19
+ border: 1px solid #3e3e42;
20
+ }
21
+ .synced {
22
+ color: #4ec9b0;
23
+ }
24
+ .not-synced {
25
+ color: #f48771;
26
+ }
27
+ .label {
28
+ color: #9cdcfe;
29
+ font-weight: bold;
30
+ }
31
+ </style>
32
+ </head>
33
+ <body>
34
+ <h1>NTP Clock Test - time.cloudflare.com</h1>
35
+ <div id="status"></div>
36
+ <script type="module">
37
+ import ntpClock from './utils/ntp-clock.js';
38
+
39
+ function updateDisplay() {
40
+ const status = ntpClock.getStatus();
41
+ const syncClass = status.isSynced ? 'synced' : 'not-synced';
42
+
43
+ document.getElementById('status').innerHTML = `
44
+ <p><span class="label">Sync Status:</span> <span class="${syncClass}">${status.isSynced ? '✅ SYNCED' : '❌ NOT SYNCED'}</span></p>
45
+ <p><span class="label">NTP Server:</span> ${status.server}</p>
46
+ <p><span class="label">NTP Time:</span> ${ntpClock.nowISO() || 'null (not synced)'}</p>
47
+ <p><span class="label">Local Time:</span> ${new Date().toISOString()}</p>
48
+ <p><span class="label">Offset:</span> ${status.offset !== null ? status.offset.toFixed(2) + 'ms' : 'null'}</p>
49
+ <p><span class="label">Last Sync:</span> ${status.lastSync || 'Never'}</p>
50
+ <p><span class="label">Time Since Sync:</span> ${status.timeSinceSync ? (status.timeSinceSync / 1000).toFixed(0) + 's' : 'N/A'}</p>
51
+ `;
52
+ }
53
+
54
+ // Update display every 100ms
55
+ setInterval(updateDisplay, 100);
56
+
57
+ // Log status to console every 5 seconds
58
+ setInterval(() => {
59
+ console.log('NTP Clock Status:', ntpClock.getStatus());
60
+ }, 5000);
61
+ </script>
62
+ </body>
63
+ </html>