runpane 2.2.9__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.
- runpane/__init__.py +1 -0
- runpane/__main__.py +3 -0
- runpane/cli.py +275 -0
- runpane/doctor.py +38 -0
- runpane/download.py +84 -0
- runpane/installers.py +129 -0
- runpane/platforms.py +67 -0
- runpane/releases.py +120 -0
- runpane/version.py +41 -0
- runpane-2.2.9.dist-info/METADATA +76 -0
- runpane-2.2.9.dist-info/RECORD +14 -0
- runpane-2.2.9.dist-info/WHEEL +5 -0
- runpane-2.2.9.dist-info/entry_points.txt +2 -0
- runpane-2.2.9.dist-info/top_level.txt +1 -0
runpane/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.2.9"
|
runpane/__main__.py
ADDED
runpane/cli.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
from .doctor import run_doctor
|
|
7
|
+
from .download import download_artifact
|
|
8
|
+
from .installers import (
|
|
9
|
+
install_pane_artifact,
|
|
10
|
+
launch_pane_client,
|
|
11
|
+
resolve_existing_pane_path,
|
|
12
|
+
should_reuse_existing_pane,
|
|
13
|
+
spawn_pane,
|
|
14
|
+
)
|
|
15
|
+
from .platforms import detect_platform
|
|
16
|
+
from .releases import resolve_release
|
|
17
|
+
from .version import print_version
|
|
18
|
+
|
|
19
|
+
SOURCE = "pip"
|
|
20
|
+
|
|
21
|
+
COMMANDS = {"help", "install", "update", "version", "doctor"}
|
|
22
|
+
TARGETS = {"client", "daemon"}
|
|
23
|
+
FORMATS = {"auto", "appimage", "deb", "dmg", "zip", "exe"}
|
|
24
|
+
CHANNELS = {"stable", "nightly"}
|
|
25
|
+
|
|
26
|
+
REMOTE_VALUE_FLAGS = {
|
|
27
|
+
"--label",
|
|
28
|
+
"--prefer-tunnel",
|
|
29
|
+
"--channel",
|
|
30
|
+
"--base-url",
|
|
31
|
+
"--pane-dir",
|
|
32
|
+
"--listen-port",
|
|
33
|
+
"--port",
|
|
34
|
+
"--repo-ref",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
REMOTE_BOOLEAN_FLAGS = {
|
|
38
|
+
"--auto-listen-port",
|
|
39
|
+
"--interactive-tailscale-setup",
|
|
40
|
+
"--no-install-service",
|
|
41
|
+
"--no-tailscale-serve",
|
|
42
|
+
"--print-only",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class ParsedArgs:
|
|
48
|
+
command: str
|
|
49
|
+
target: str = "client"
|
|
50
|
+
pane_version: str = "latest"
|
|
51
|
+
channel: str = "stable"
|
|
52
|
+
format: str = "auto"
|
|
53
|
+
download_dir: Optional[str] = None
|
|
54
|
+
pane_path: Optional[str] = None
|
|
55
|
+
dry_run: bool = False
|
|
56
|
+
yes: bool = False
|
|
57
|
+
verbose: bool = False
|
|
58
|
+
help_topic: Optional[str] = None
|
|
59
|
+
remote_setup_args: List[str] = field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
63
|
+
import sys
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
parsed = parse_args(sys.argv[1:] if argv is None else argv)
|
|
67
|
+
if parsed.command == "help":
|
|
68
|
+
print(help_text(parsed.help_topic))
|
|
69
|
+
return 0
|
|
70
|
+
if parsed.command == "version":
|
|
71
|
+
return print_version(parsed.pane_path)
|
|
72
|
+
if parsed.command == "doctor":
|
|
73
|
+
return run_doctor(parsed, SOURCE)
|
|
74
|
+
if parsed.command in {"install", "update"}:
|
|
75
|
+
return install_or_update(parsed)
|
|
76
|
+
print(help_text(None))
|
|
77
|
+
return 0
|
|
78
|
+
except Exception as error:
|
|
79
|
+
print(str(error), file=sys.stderr)
|
|
80
|
+
return 1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_args(argv: List[str]) -> ParsedArgs:
|
|
84
|
+
args = list(argv)
|
|
85
|
+
if not args or args[0] in {"-h", "--help"}:
|
|
86
|
+
return ParsedArgs(command="help")
|
|
87
|
+
first = args.pop(0)
|
|
88
|
+
if first in {"-v", "--version"}:
|
|
89
|
+
return ParsedArgs(command="version")
|
|
90
|
+
if first not in COMMANDS:
|
|
91
|
+
raise ValueError(f"Unknown command: {first}\n\n{help_text(None)}")
|
|
92
|
+
if first == "help":
|
|
93
|
+
return ParsedArgs(command="help", help_topic=args[0] if args else None)
|
|
94
|
+
|
|
95
|
+
parsed = ParsedArgs(command=first)
|
|
96
|
+
if parsed.command == "install" and args and not args[0].startswith("-"):
|
|
97
|
+
target = args.pop(0)
|
|
98
|
+
if target not in TARGETS:
|
|
99
|
+
raise ValueError(f'Unknown install target: {target}. Expected "client" or "daemon".')
|
|
100
|
+
parsed.target = target
|
|
101
|
+
|
|
102
|
+
if parsed.command == "update":
|
|
103
|
+
parsed.target = "client"
|
|
104
|
+
|
|
105
|
+
parse_flags(args, parsed)
|
|
106
|
+
return parsed
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def parse_flags(args: List[str], parsed: ParsedArgs) -> None:
|
|
110
|
+
index = 0
|
|
111
|
+
while index < len(args):
|
|
112
|
+
arg = args[index]
|
|
113
|
+
if arg in {"-h", "--help"}:
|
|
114
|
+
parsed.help_topic = parsed.command
|
|
115
|
+
parsed.command = "help"
|
|
116
|
+
elif arg == "--dry-run":
|
|
117
|
+
parsed.dry_run = True
|
|
118
|
+
elif arg in {"--yes", "-y"}:
|
|
119
|
+
parsed.yes = True
|
|
120
|
+
elif arg == "--verbose":
|
|
121
|
+
parsed.verbose = True
|
|
122
|
+
elif arg == "--version":
|
|
123
|
+
index += 1
|
|
124
|
+
parsed.pane_version = read_value(args, index, arg)
|
|
125
|
+
elif arg == "--download-dir":
|
|
126
|
+
index += 1
|
|
127
|
+
parsed.download_dir = read_value(args, index, arg)
|
|
128
|
+
elif arg == "--pane-path":
|
|
129
|
+
index += 1
|
|
130
|
+
parsed.pane_path = read_value(args, index, arg)
|
|
131
|
+
elif arg == "--format":
|
|
132
|
+
index += 1
|
|
133
|
+
value = read_value(args, index, arg)
|
|
134
|
+
if value not in FORMATS:
|
|
135
|
+
raise ValueError(f"Invalid --format {value}. Expected one of: {', '.join(sorted(FORMATS))}")
|
|
136
|
+
parsed.format = value
|
|
137
|
+
elif arg in REMOTE_VALUE_FLAGS:
|
|
138
|
+
index += 1
|
|
139
|
+
value = read_value(args, index, arg)
|
|
140
|
+
if arg == "--channel":
|
|
141
|
+
if value not in CHANNELS:
|
|
142
|
+
raise ValueError(f"Invalid --channel {value}. Expected stable or nightly.")
|
|
143
|
+
parsed.channel = value
|
|
144
|
+
append_remote_arg(parsed, arg, value)
|
|
145
|
+
elif arg in REMOTE_BOOLEAN_FLAGS:
|
|
146
|
+
append_remote_arg(parsed, arg)
|
|
147
|
+
elif parsed.command == "install" and parsed.target == "daemon":
|
|
148
|
+
parsed.remote_setup_args.append(arg)
|
|
149
|
+
if arg.startswith("-") and index + 1 < len(args) and not args[index + 1].startswith("-"):
|
|
150
|
+
index += 1
|
|
151
|
+
parsed.remote_setup_args.append(args[index])
|
|
152
|
+
else:
|
|
153
|
+
raise ValueError(f"Unknown option for {parsed.command}: {arg}")
|
|
154
|
+
index += 1
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def append_remote_arg(parsed: ParsedArgs, flag: str, value: Optional[str] = None) -> None:
|
|
158
|
+
if parsed.command == "install" and parsed.target == "daemon":
|
|
159
|
+
parsed.remote_setup_args.append(flag)
|
|
160
|
+
if value is not None:
|
|
161
|
+
parsed.remote_setup_args.append(value)
|
|
162
|
+
return
|
|
163
|
+
raise ValueError(f'{flag} is only valid with "runpane install daemon".')
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def read_value(args: List[str], index: int, flag: str) -> str:
|
|
167
|
+
if index >= len(args) or args[index].startswith("-"):
|
|
168
|
+
raise ValueError(f"{flag} requires a value.")
|
|
169
|
+
return args[index]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def install_or_update(parsed: ParsedArgs) -> int:
|
|
173
|
+
target = "client" if parsed.command == "update" else parsed.target
|
|
174
|
+
if not parsed.dry_run and should_reuse_existing_pane(parsed, target):
|
|
175
|
+
existing = resolve_existing_pane_path(parsed.pane_path)
|
|
176
|
+
if existing:
|
|
177
|
+
return spawn_pane(existing, ["--remote-setup", *parsed.remote_setup_args])
|
|
178
|
+
|
|
179
|
+
platform = detect_platform()
|
|
180
|
+
resolved = resolve_release(
|
|
181
|
+
version=parsed.pane_version,
|
|
182
|
+
channel=parsed.channel,
|
|
183
|
+
source=SOURCE,
|
|
184
|
+
platform=platform,
|
|
185
|
+
format_name=parsed.format,
|
|
186
|
+
target=target,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if parsed.dry_run:
|
|
190
|
+
print("runpane dry run")
|
|
191
|
+
print(f"Command: {parsed.command}")
|
|
192
|
+
print(f"Target: {target}")
|
|
193
|
+
print(f"Pane release: {parsed.pane_version}")
|
|
194
|
+
print(f"Channel: {parsed.channel}")
|
|
195
|
+
print(f"Format: {parsed.format}")
|
|
196
|
+
print(f"Artifact: {resolved.artifact['name']}")
|
|
197
|
+
print(f"Preferred download: {resolved.preferred_download_url}")
|
|
198
|
+
print(f"GitHub fallback: {resolved.fallback_download_url}")
|
|
199
|
+
if parsed.pane_path:
|
|
200
|
+
print(f"Existing Pane path: {parsed.pane_path}")
|
|
201
|
+
if target == "daemon":
|
|
202
|
+
forwarded = " ".join(parsed.remote_setup_args)
|
|
203
|
+
print(f"Pane command: <pane executable> --remote-setup {forwarded}".strip())
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
artifact = download_artifact(resolved, parsed.download_dir, parsed.verbose)
|
|
207
|
+
installed = install_pane_artifact(artifact, parsed, platform, resolved.format, target)
|
|
208
|
+
|
|
209
|
+
if target == "daemon":
|
|
210
|
+
return spawn_pane(installed.executable_path, ["--remote-setup", *parsed.remote_setup_args])
|
|
211
|
+
|
|
212
|
+
if installed.install_kind == "installed":
|
|
213
|
+
launch_pane_client(installed.executable_path)
|
|
214
|
+
|
|
215
|
+
print(f"Pane {installed.install_kind}: {installed.executable_path}")
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def help_text(topic: Optional[str]) -> str:
|
|
220
|
+
if topic == "install":
|
|
221
|
+
return "\n".join([
|
|
222
|
+
"Usage:",
|
|
223
|
+
" runpane install [client|daemon] [options]",
|
|
224
|
+
"",
|
|
225
|
+
"Examples:",
|
|
226
|
+
' npx --yes runpane@latest install daemon --label "My Server"',
|
|
227
|
+
' pnpm dlx runpane@latest install daemon --prefer-tunnel ssh --label "VM"',
|
|
228
|
+
' pipx run runpane install daemon --label "My Server"',
|
|
229
|
+
"",
|
|
230
|
+
"Wrapper options:",
|
|
231
|
+
" --version <latest|vX.Y.Z>",
|
|
232
|
+
" --format <auto|appimage|deb|dmg|zip|exe>",
|
|
233
|
+
" --download-dir <path>",
|
|
234
|
+
" --pane-path <path>",
|
|
235
|
+
" --dry-run",
|
|
236
|
+
" --yes",
|
|
237
|
+
" --verbose",
|
|
238
|
+
"",
|
|
239
|
+
"Daemon passthrough options:",
|
|
240
|
+
" --label <name>",
|
|
241
|
+
" --prefer-tunnel <tailscale|ssh|manual|auto>",
|
|
242
|
+
" --channel <stable|nightly>",
|
|
243
|
+
" --base-url <url>",
|
|
244
|
+
" --pane-dir <path>",
|
|
245
|
+
" --listen-port <port> / --port <port>",
|
|
246
|
+
" --auto-listen-port",
|
|
247
|
+
" --interactive-tailscale-setup",
|
|
248
|
+
" --no-install-service",
|
|
249
|
+
" --no-tailscale-serve",
|
|
250
|
+
" --print-only",
|
|
251
|
+
" --repo-ref <ref>",
|
|
252
|
+
])
|
|
253
|
+
|
|
254
|
+
if topic == "update":
|
|
255
|
+
return "Usage:\n runpane update [--version <latest|vX.Y.Z>] [--dry-run] [--yes]"
|
|
256
|
+
if topic == "version":
|
|
257
|
+
return "Usage:\n runpane version\n runpane --version"
|
|
258
|
+
if topic == "doctor":
|
|
259
|
+
return "Usage:\n runpane doctor [--pane-path <path>] [--format <format>] [--verbose]"
|
|
260
|
+
|
|
261
|
+
return "\n".join([
|
|
262
|
+
"Usage:",
|
|
263
|
+
" runpane install [client|daemon] [options]",
|
|
264
|
+
" runpane update [options]",
|
|
265
|
+
" runpane version",
|
|
266
|
+
" runpane doctor",
|
|
267
|
+
" runpane help [command]",
|
|
268
|
+
"",
|
|
269
|
+
"Package manager examples:",
|
|
270
|
+
' pipx run runpane install daemon --label "My Server"',
|
|
271
|
+
' uvx runpane@latest install daemon --label "My Server"',
|
|
272
|
+
' python -m runpane install daemon --label "My Server"',
|
|
273
|
+
"",
|
|
274
|
+
'Run "runpane help install" for install options.',
|
|
275
|
+
])
|
runpane/doctor.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .installers import resolve_existing_pane_path
|
|
4
|
+
from .platforms import detect_platform
|
|
5
|
+
from .releases import resolve_release
|
|
6
|
+
from .version import pane_version
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def run_doctor(parsed, source: str = "pip") -> int:
|
|
10
|
+
ok = True
|
|
11
|
+
try:
|
|
12
|
+
platform = detect_platform()
|
|
13
|
+
print(f"Platform: {platform.os}/{platform.arch}")
|
|
14
|
+
release = resolve_release(
|
|
15
|
+
version=parsed.pane_version,
|
|
16
|
+
channel=parsed.channel,
|
|
17
|
+
source=source,
|
|
18
|
+
platform=platform,
|
|
19
|
+
format_name=parsed.format,
|
|
20
|
+
target="client",
|
|
21
|
+
)
|
|
22
|
+
print(f"Latest release: {release.release['tag_name']}")
|
|
23
|
+
print(f"Selected artifact: {release.artifact['name']}")
|
|
24
|
+
print(f"Website URL: {release.preferred_download_url}")
|
|
25
|
+
print(f"GitHub fallback: {release.fallback_download_url}")
|
|
26
|
+
except Exception as error:
|
|
27
|
+
ok = False
|
|
28
|
+
print(f"Release check: failed - {error}")
|
|
29
|
+
|
|
30
|
+
installed = resolve_existing_pane_path(parsed.pane_path)
|
|
31
|
+
if installed:
|
|
32
|
+
print(f"Installed Pane: {installed}")
|
|
33
|
+
print(f"Installed version: {pane_version(installed) or 'unknown'}")
|
|
34
|
+
else:
|
|
35
|
+
print("Installed Pane: not found")
|
|
36
|
+
|
|
37
|
+
print('Remote setup: run "runpane install daemon --label <name>" to configure a headless host.')
|
|
38
|
+
return 0 if ok else 1
|
runpane/download.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from .releases import ResolvedRelease, artifact_file_name
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class DownloadedArtifact:
|
|
18
|
+
path: str
|
|
19
|
+
file_name: str
|
|
20
|
+
used_fallback: bool
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def download_artifact(resolved: ResolvedRelease, download_dir: Optional[str], verbose: bool) -> DownloadedArtifact:
|
|
24
|
+
target_dir = download_dir or os.path.join(tempfile.gettempdir(), f"runpane-{int(time.time() * 1000)}")
|
|
25
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
26
|
+
|
|
27
|
+
file_name = artifact_file_name(resolved.artifact["name"])
|
|
28
|
+
target_path = os.path.join(target_dir, file_name)
|
|
29
|
+
used_fallback = False
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
download_to_file(resolved.preferred_download_url, target_path, verbose)
|
|
33
|
+
except Exception as error:
|
|
34
|
+
used_fallback = True
|
|
35
|
+
print(f"runpane: website download route failed; falling back to GitHub release asset. {error}")
|
|
36
|
+
download_to_file(resolved.fallback_download_url, target_path, verbose)
|
|
37
|
+
|
|
38
|
+
verify_checksum_if_available(resolved, target_path, file_name)
|
|
39
|
+
return DownloadedArtifact(path=target_path, file_name=file_name, used_fallback=used_fallback)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def download_to_file(url: str, target_path: str, verbose: bool) -> None:
|
|
43
|
+
if verbose:
|
|
44
|
+
print(f"Downloading {url}")
|
|
45
|
+
req = urllib.request.Request(url, headers={"User-Agent": "runpane-installer"})
|
|
46
|
+
with urllib.request.urlopen(req, timeout=120) as response:
|
|
47
|
+
if getattr(response, "status", 200) >= 400:
|
|
48
|
+
raise RuntimeError(f"{response.status} {response.reason}")
|
|
49
|
+
with open(target_path, "wb") as target:
|
|
50
|
+
shutil.copyfileobj(response, target, length=1024 * 1024)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def verify_checksum_if_available(resolved: ResolvedRelease, artifact_path: str, file_name: str) -> None:
|
|
54
|
+
try:
|
|
55
|
+
req = urllib.request.Request(resolved.checksum_url, headers={"User-Agent": "runpane-installer"})
|
|
56
|
+
with urllib.request.urlopen(req, timeout=30) as response:
|
|
57
|
+
checksums = response.read().decode("utf-8")
|
|
58
|
+
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
|
59
|
+
print(f"runpane: could not verify checksum for {file_name}. {error}")
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
expected = parse_checksum(checksums, file_name)
|
|
63
|
+
if not expected:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
digest = hashlib.sha256()
|
|
67
|
+
with open(artifact_path, "rb") as source:
|
|
68
|
+
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
|
69
|
+
digest.update(chunk)
|
|
70
|
+
|
|
71
|
+
actual = digest.hexdigest()
|
|
72
|
+
if actual.lower() != expected.lower():
|
|
73
|
+
raise RuntimeError(f"Checksum mismatch for {file_name}. Expected {expected}, got {actual}.")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_checksum(checksums: str, file_name: str) -> Optional[str]:
|
|
77
|
+
for line in checksums.splitlines():
|
|
78
|
+
stripped = line.strip()
|
|
79
|
+
if not stripped.endswith(file_name):
|
|
80
|
+
continue
|
|
81
|
+
digest = stripped.split()[0]
|
|
82
|
+
if len(digest) == 64 and all(char in "0123456789abcdefABCDEF" for char in digest):
|
|
83
|
+
return digest
|
|
84
|
+
return None
|
runpane/installers.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
from .download import DownloadedArtifact
|
|
11
|
+
from .platforms import PanePlatform, default_install_root
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class InstalledPane:
|
|
16
|
+
executable_path: str
|
|
17
|
+
install_kind: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_existing_pane_path(pane_path: Optional[str] = None) -> Optional[str]:
|
|
21
|
+
if pane_path:
|
|
22
|
+
return pane_path if os.path.exists(pane_path) else None
|
|
23
|
+
|
|
24
|
+
home = os.path.expanduser("~")
|
|
25
|
+
candidates = []
|
|
26
|
+
if os.name == "nt":
|
|
27
|
+
local = os.environ.get("LOCALAPPDATA")
|
|
28
|
+
program_files = os.environ.get("ProgramFiles")
|
|
29
|
+
if local:
|
|
30
|
+
candidates.extend([
|
|
31
|
+
os.path.join(local, "Programs", "Pane", "Pane.exe"),
|
|
32
|
+
os.path.join(local, "Pane", "Pane.exe"),
|
|
33
|
+
])
|
|
34
|
+
if program_files:
|
|
35
|
+
candidates.append(os.path.join(program_files, "Pane", "Pane.exe"))
|
|
36
|
+
elif platform.system().lower() == "darwin":
|
|
37
|
+
candidates.extend([
|
|
38
|
+
"/Applications/Pane.app/Contents/MacOS/Pane",
|
|
39
|
+
os.path.join(home, "Applications", "Pane.app", "Contents", "MacOS", "Pane"),
|
|
40
|
+
])
|
|
41
|
+
else:
|
|
42
|
+
candidates.extend([
|
|
43
|
+
os.path.join(home, ".local", "bin", "pane"),
|
|
44
|
+
"/usr/bin/pane",
|
|
45
|
+
"/opt/Pane/pane",
|
|
46
|
+
])
|
|
47
|
+
|
|
48
|
+
for candidate in candidates:
|
|
49
|
+
if os.path.exists(candidate):
|
|
50
|
+
return candidate
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def should_reuse_existing_pane(parsed, target: str) -> bool:
|
|
55
|
+
return parsed.command == "install" and target == "daemon"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def install_pane_artifact(
|
|
59
|
+
artifact: DownloadedArtifact,
|
|
60
|
+
parsed,
|
|
61
|
+
platform: PanePlatform,
|
|
62
|
+
format_name: str,
|
|
63
|
+
target: str,
|
|
64
|
+
) -> InstalledPane:
|
|
65
|
+
existing = resolve_existing_pane_path(parsed.pane_path)
|
|
66
|
+
if existing and should_reuse_existing_pane(parsed, target):
|
|
67
|
+
return InstalledPane(executable_path=existing, install_kind="existing")
|
|
68
|
+
|
|
69
|
+
if platform.os == "darwin":
|
|
70
|
+
return install_mac(artifact, format_name, target)
|
|
71
|
+
if platform.os == "linux":
|
|
72
|
+
return install_linux(artifact, format_name)
|
|
73
|
+
return install_windows(artifact, target)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def spawn_pane(executable_path: str, args: List[str]) -> int:
|
|
77
|
+
try:
|
|
78
|
+
return subprocess.call([executable_path, *args])
|
|
79
|
+
except OSError as error:
|
|
80
|
+
print(f"Failed to launch Pane: {error}")
|
|
81
|
+
return 1
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def launch_pane_client(executable_path: str) -> None:
|
|
85
|
+
subprocess.Popen([executable_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def install_mac(artifact: DownloadedArtifact, format_name: str, target: str) -> InstalledPane:
|
|
89
|
+
if format_name == "dmg":
|
|
90
|
+
subprocess.call(["open", artifact.path])
|
|
91
|
+
return InstalledPane(executable_path="/Applications/Pane.app/Contents/MacOS/Pane", install_kind="launched-installer")
|
|
92
|
+
|
|
93
|
+
apps_root = os.path.join(os.path.expanduser("~"), "Applications")
|
|
94
|
+
app_path = os.path.join(apps_root, "Pane.app")
|
|
95
|
+
os.makedirs(apps_root, exist_ok=True)
|
|
96
|
+
subprocess.check_call(["ditto", "-x", "-k", artifact.path, apps_root])
|
|
97
|
+
executable_path = os.path.join(app_path, "Contents", "MacOS", "Pane")
|
|
98
|
+
if not os.path.exists(executable_path):
|
|
99
|
+
raise RuntimeError(f"Pane executable was not found after extracting {artifact.file_name}. Expected {executable_path}")
|
|
100
|
+
if target == "client":
|
|
101
|
+
subprocess.call(["open", app_path])
|
|
102
|
+
return InstalledPane(executable_path=executable_path, install_kind="installed")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def install_linux(artifact: DownloadedArtifact, format_name: str) -> InstalledPane:
|
|
106
|
+
if format_name == "deb":
|
|
107
|
+
installer = "apt" if shutil.which("apt") else "dpkg"
|
|
108
|
+
args = ["install", "-y", artifact.path] if installer == "apt" else ["-i", artifact.path]
|
|
109
|
+
subprocess.call(["sudo", installer, *args])
|
|
110
|
+
executable = resolve_existing_pane_path()
|
|
111
|
+
if not executable:
|
|
112
|
+
raise RuntimeError("Pane installed from .deb, but the pane executable could not be found.")
|
|
113
|
+
return InstalledPane(executable_path=executable, install_kind="installed")
|
|
114
|
+
|
|
115
|
+
bin_root = default_install_root()
|
|
116
|
+
os.makedirs(bin_root, exist_ok=True)
|
|
117
|
+
executable_path = os.path.join(bin_root, "pane")
|
|
118
|
+
shutil.copyfile(artifact.path, executable_path)
|
|
119
|
+
os.chmod(executable_path, 0o755)
|
|
120
|
+
return InstalledPane(executable_path=executable_path, install_kind="installed")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def install_windows(artifact: DownloadedArtifact, target: str) -> InstalledPane:
|
|
124
|
+
args = ["/S"] if target == "daemon" else []
|
|
125
|
+
subprocess.call([artifact.path, *args])
|
|
126
|
+
executable = resolve_existing_pane_path()
|
|
127
|
+
if not executable:
|
|
128
|
+
raise RuntimeError("Pane installer completed, but Pane.exe could not be found. Open the installer manually and rerun with --pane-path.")
|
|
129
|
+
return InstalledPane(executable_path=executable, install_kind="installed" if target == "daemon" else "launched-installer")
|
runpane/platforms.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class PanePlatform:
|
|
11
|
+
os: str
|
|
12
|
+
arch: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def detect_platform() -> PanePlatform:
|
|
16
|
+
system = platform.system().lower()
|
|
17
|
+
machine = platform.machine().lower()
|
|
18
|
+
|
|
19
|
+
if system == "darwin":
|
|
20
|
+
os_name = "darwin"
|
|
21
|
+
elif system == "linux":
|
|
22
|
+
os_name = "linux"
|
|
23
|
+
elif system == "windows":
|
|
24
|
+
os_name = "win32"
|
|
25
|
+
else:
|
|
26
|
+
raise RuntimeError(f"Unsupported OS: {system}")
|
|
27
|
+
|
|
28
|
+
if machine in {"x86_64", "amd64"}:
|
|
29
|
+
arch = "x64"
|
|
30
|
+
elif machine in {"arm64", "aarch64"}:
|
|
31
|
+
arch = "arm64"
|
|
32
|
+
else:
|
|
33
|
+
raise RuntimeError(f"Unsupported CPU architecture: {machine}")
|
|
34
|
+
|
|
35
|
+
return PanePlatform(os=os_name, arch=arch)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def default_format(platform_info: PanePlatform, target: str) -> str:
|
|
39
|
+
if platform_info.os == "darwin":
|
|
40
|
+
return "zip" if target == "daemon" else "dmg"
|
|
41
|
+
if platform_info.os == "win32":
|
|
42
|
+
return "exe"
|
|
43
|
+
return "appimage"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def platform_param(platform_info: PanePlatform) -> str:
|
|
47
|
+
if platform_info.os == "darwin":
|
|
48
|
+
return "mac"
|
|
49
|
+
if platform_info.os == "win32":
|
|
50
|
+
return "windows"
|
|
51
|
+
return "linux"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def arch_aliases(platform_info: PanePlatform) -> List[str]:
|
|
55
|
+
if platform_info.arch == "arm64":
|
|
56
|
+
return ["arm64", "aarch64"]
|
|
57
|
+
if platform_info.os == "linux":
|
|
58
|
+
return ["x64", "x86_64", "amd64"]
|
|
59
|
+
return ["x64", "x86_64"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def default_install_root() -> str:
|
|
63
|
+
if os.name == "nt":
|
|
64
|
+
return os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local", "Pane"))
|
|
65
|
+
if platform.system().lower() == "darwin":
|
|
66
|
+
return os.path.join(os.path.expanduser("~"), "Applications")
|
|
67
|
+
return os.path.join(os.path.expanduser("~"), ".local", "bin")
|
runpane/releases.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Dict
|
|
10
|
+
|
|
11
|
+
from .platforms import PanePlatform, arch_aliases, default_format, platform_param
|
|
12
|
+
|
|
13
|
+
GITHUB_API_BASE = "https://api.github.com/repos/dcouple/Pane/releases"
|
|
14
|
+
DOWNLOAD_API_BASE = "https://runpane.com/api/download"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ResolvedRelease:
|
|
19
|
+
release: Dict[str, Any]
|
|
20
|
+
artifact: Dict[str, Any]
|
|
21
|
+
format: str
|
|
22
|
+
preferred_download_url: str
|
|
23
|
+
fallback_download_url: str
|
|
24
|
+
checksum_url: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_release(
|
|
28
|
+
*,
|
|
29
|
+
version: str,
|
|
30
|
+
channel: str,
|
|
31
|
+
source: str,
|
|
32
|
+
platform: PanePlatform,
|
|
33
|
+
format_name: str,
|
|
34
|
+
target: str,
|
|
35
|
+
) -> ResolvedRelease:
|
|
36
|
+
release = fetch_release(version)
|
|
37
|
+
selected_format = default_format(platform, target) if format_name == "auto" else format_name
|
|
38
|
+
artifact = find_artifact(release, platform, selected_format)
|
|
39
|
+
preferred = build_preferred_download_url(channel, source, platform, selected_format, release)
|
|
40
|
+
tag_name = release["tag_name"]
|
|
41
|
+
return ResolvedRelease(
|
|
42
|
+
release=release,
|
|
43
|
+
artifact=artifact,
|
|
44
|
+
format=selected_format,
|
|
45
|
+
preferred_download_url=preferred,
|
|
46
|
+
fallback_download_url=artifact["browser_download_url"],
|
|
47
|
+
checksum_url=f"https://github.com/dcouple/Pane/releases/download/{tag_name}/SHA256SUMS.txt",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def fetch_release(version: str) -> Dict[str, Any]:
|
|
52
|
+
normalized = "latest" if version == "latest" else f"tags/{version if version.startswith('v') else 'v' + version}"
|
|
53
|
+
req = urllib.request.Request(
|
|
54
|
+
f"{GITHUB_API_BASE}/{normalized}",
|
|
55
|
+
headers={"Accept": "application/vnd.github+json", "User-Agent": "runpane-installer"},
|
|
56
|
+
)
|
|
57
|
+
with urllib.request.urlopen(req, timeout=30) as response:
|
|
58
|
+
release = json.loads(response.read().decode("utf-8"))
|
|
59
|
+
|
|
60
|
+
if release.get("draft") or release.get("prerelease"):
|
|
61
|
+
raise RuntimeError(f"Release {release.get('tag_name')} is not a stable public release.")
|
|
62
|
+
return release
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def find_artifact(release: Dict[str, Any], platform: PanePlatform, format_name: str) -> Dict[str, Any]:
|
|
66
|
+
assets = release.get("assets") or []
|
|
67
|
+
candidates = [
|
|
68
|
+
asset for asset in assets
|
|
69
|
+
if matches_format(asset["name"], format_name) and matches_platform(asset["name"], platform)
|
|
70
|
+
]
|
|
71
|
+
aliases = arch_aliases(platform)
|
|
72
|
+
for asset in candidates:
|
|
73
|
+
lower = asset["name"].lower()
|
|
74
|
+
if any(alias.lower() in lower for alias in aliases):
|
|
75
|
+
return asset
|
|
76
|
+
for asset in candidates:
|
|
77
|
+
if "universal" in asset["name"].lower():
|
|
78
|
+
return asset
|
|
79
|
+
if candidates:
|
|
80
|
+
return candidates[0]
|
|
81
|
+
names = ", ".join(asset["name"] for asset in assets) or "no assets"
|
|
82
|
+
raise RuntimeError(f"No Pane {format_name} asset found for {platform.os}/{platform.arch}. Assets: {names}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def artifact_file_name(url_or_name: str) -> str:
|
|
86
|
+
return os.path.basename(url_or_name.split("?", 1)[0])
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_preferred_download_url(
|
|
90
|
+
channel: str,
|
|
91
|
+
source: str,
|
|
92
|
+
platform: PanePlatform,
|
|
93
|
+
format_name: str,
|
|
94
|
+
release: Dict[str, Any],
|
|
95
|
+
) -> str:
|
|
96
|
+
query = urllib.parse.urlencode({
|
|
97
|
+
"platform": platform_param(platform),
|
|
98
|
+
"arch": platform.arch,
|
|
99
|
+
"format": format_name,
|
|
100
|
+
"version": release["tag_name"],
|
|
101
|
+
"channel": channel,
|
|
102
|
+
"source": source,
|
|
103
|
+
})
|
|
104
|
+
return f"{DOWNLOAD_API_BASE}?{query}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def matches_format(name: str, format_name: str) -> bool:
|
|
108
|
+
lower = name.lower()
|
|
109
|
+
if format_name == "appimage":
|
|
110
|
+
return lower.endswith(".appimage")
|
|
111
|
+
return lower.endswith(f".{format_name}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def matches_platform(name: str, platform: PanePlatform) -> bool:
|
|
115
|
+
lower = name.lower()
|
|
116
|
+
if platform.os == "darwin":
|
|
117
|
+
return "macos" in lower or "darwin" in lower or "mac" in lower
|
|
118
|
+
if platform.os == "win32":
|
|
119
|
+
return "windows" in lower or re.search(r"(?:^|[._-])win(?:32|64)?(?:[._-]|$)", lower) is not None
|
|
120
|
+
return "linux" in lower
|
runpane/version.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from importlib import metadata
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .installers import resolve_existing_pane_path
|
|
9
|
+
from .releases import fetch_release
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def wrapper_version() -> str:
|
|
13
|
+
try:
|
|
14
|
+
return metadata.version("runpane")
|
|
15
|
+
except metadata.PackageNotFoundError:
|
|
16
|
+
return __version__
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def print_version(pane_path: Optional[str] = None) -> int:
|
|
20
|
+
installed_path = resolve_existing_pane_path(pane_path)
|
|
21
|
+
installed_version = pane_version(installed_path) if installed_path else None
|
|
22
|
+
try:
|
|
23
|
+
latest = fetch_release("latest")["tag_name"].lstrip("v")
|
|
24
|
+
except Exception:
|
|
25
|
+
latest = "unavailable"
|
|
26
|
+
|
|
27
|
+
print(f"runpane {wrapper_version()}")
|
|
28
|
+
print(f"Pane installed: {installed_version or 'not found'}")
|
|
29
|
+
print(f"Pane latest: {latest}")
|
|
30
|
+
if installed_path:
|
|
31
|
+
print(f"Pane path: {installed_path}")
|
|
32
|
+
return 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def pane_version(executable_path: str) -> Optional[str]:
|
|
36
|
+
try:
|
|
37
|
+
result = subprocess.run([executable_path, "--version"], capture_output=True, text=True, timeout=10)
|
|
38
|
+
except OSError:
|
|
39
|
+
return None
|
|
40
|
+
output = (result.stdout + result.stderr).strip()
|
|
41
|
+
return output or None
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runpane
|
|
3
|
+
Version: 2.2.9
|
|
4
|
+
Summary: Thin PyPI installer and remote setup CLI for Pane
|
|
5
|
+
Author-email: Dcouple Inc <hello@dcouple.ai>
|
|
6
|
+
License: AGPL-3.0
|
|
7
|
+
Project-URL: Homepage, https://runpane.com
|
|
8
|
+
Project-URL: Repository, https://github.com/dcouple/Pane
|
|
9
|
+
Project-URL: Issues, https://github.com/dcouple/Pane/issues
|
|
10
|
+
Keywords: pane,installer,remote,daemon,cli
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Software Development
|
|
16
|
+
Requires-Python: >=3.8
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# runpane
|
|
20
|
+
|
|
21
|
+
Thin PyPI installer and remote setup CLI for Pane.
|
|
22
|
+
|
|
23
|
+
The package does not include the Pane desktop runtime. It downloads the correct
|
|
24
|
+
Pane release artifact only when you run `runpane install` or `runpane update`.
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
One-shot execution:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pipx run runpane install daemon --label "My Server"
|
|
32
|
+
uvx runpane@latest install daemon --label "My Server"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Persistent install:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
python -m pip install runpane
|
|
39
|
+
runpane install daemon --label "My Server"
|
|
40
|
+
|
|
41
|
+
pipx install runpane
|
|
42
|
+
runpane install daemon --label "My Server"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Module entrypoint:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
python -m runpane install daemon --label "My Server"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Commands
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
runpane install
|
|
55
|
+
runpane install client
|
|
56
|
+
runpane install daemon
|
|
57
|
+
runpane update
|
|
58
|
+
runpane version
|
|
59
|
+
runpane doctor
|
|
60
|
+
runpane --help
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`runpane install daemon` installs Pane and then invokes the installed executable
|
|
64
|
+
with `--remote-setup`, preserving the `pane-remote://...` connection-code output.
|
|
65
|
+
|
|
66
|
+
## Attribution
|
|
67
|
+
|
|
68
|
+
PyPI package downloads use `source=pip` when requesting release artifacts from
|
|
69
|
+
`runpane.com/api/download`. If that route is unavailable, the CLI falls back to
|
|
70
|
+
matching GitHub release assets and prints a warning.
|
|
71
|
+
|
|
72
|
+
## Publishing
|
|
73
|
+
|
|
74
|
+
This package should be published through PyPI Trusted Publishing from GitHub
|
|
75
|
+
Actions. Token-based `PYPI_API_TOKEN` publishing is a fallback for first package
|
|
76
|
+
reservation or manual publication only.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
runpane/__init__.py,sha256=4yiQmg1jez9eDe7VvOU40EgTkcAYoip5MZXkPgQyf-I,22
|
|
2
|
+
runpane/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
runpane/cli.py,sha256=tHOeW3o-wV-9DYUoyFGXvJhMA2SztSsz7iaR85jcm7w,9582
|
|
4
|
+
runpane/doctor.py,sha256=72k4cQTYDSIBecBdpvlMhZBll3fxw09IuoxPXN3FPO0,1360
|
|
5
|
+
runpane/download.py,sha256=QsLLIp92kYPYke7LrRuz-EZ9U3jjKA8OpF5H13l3JFw,3110
|
|
6
|
+
runpane/installers.py,sha256=7Uc4e5WwYHAkN31upY6hj3JnN-pb8VydCO8o6GN1faM,4963
|
|
7
|
+
runpane/platforms.py,sha256=pYtN7FE7iW4FB7GxFvSYabFfyIt0TU7GHunn3kbcBQw,1834
|
|
8
|
+
runpane/releases.py,sha256=51nXU9rkEVf4acORl8-5_-AtsP1q86gMszL5BN6j248,4007
|
|
9
|
+
runpane/version.py,sha256=1cFRGy1Ju7tpSc4RN5vH-bMnjGw0NTZvQQ4l44izloQ,1229
|
|
10
|
+
runpane-2.2.9.dist-info/METADATA,sha256=4NgoSO6GpBnre_9-Z4S5FKd6hBJTZd5GmZKhAaOXYpk,2051
|
|
11
|
+
runpane-2.2.9.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
runpane-2.2.9.dist-info/entry_points.txt,sha256=7R0nNreFQ5xeluCzoypdlcsk3bJm3a9r-Xgt1kj4ZPQ,45
|
|
13
|
+
runpane-2.2.9.dist-info/top_level.txt,sha256=1I4gNmo6R1UAvbsmlTYzYVZvYZiZrKN9r3VfZDVPM7c,8
|
|
14
|
+
runpane-2.2.9.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
runpane
|