runpane 2.2.9__tar.gz

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-2.2.9/PKG-INFO ADDED
@@ -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,58 @@
1
+ # runpane
2
+
3
+ Thin PyPI installer and remote setup CLI for Pane.
4
+
5
+ The package does not include the Pane desktop runtime. It downloads the correct
6
+ Pane release artifact only when you run `runpane install` or `runpane update`.
7
+
8
+ ## Usage
9
+
10
+ One-shot execution:
11
+
12
+ ```bash
13
+ pipx run runpane install daemon --label "My Server"
14
+ uvx runpane@latest install daemon --label "My Server"
15
+ ```
16
+
17
+ Persistent install:
18
+
19
+ ```bash
20
+ python -m pip install runpane
21
+ runpane install daemon --label "My Server"
22
+
23
+ pipx install runpane
24
+ runpane install daemon --label "My Server"
25
+ ```
26
+
27
+ Module entrypoint:
28
+
29
+ ```bash
30
+ python -m runpane install daemon --label "My Server"
31
+ ```
32
+
33
+ ## Commands
34
+
35
+ ```bash
36
+ runpane install
37
+ runpane install client
38
+ runpane install daemon
39
+ runpane update
40
+ runpane version
41
+ runpane doctor
42
+ runpane --help
43
+ ```
44
+
45
+ `runpane install daemon` installs Pane and then invokes the installed executable
46
+ with `--remote-setup`, preserving the `pane-remote://...` connection-code output.
47
+
48
+ ## Attribution
49
+
50
+ PyPI package downloads use `source=pip` when requesting release artifacts from
51
+ `runpane.com/api/download`. If that route is unavailable, the CLI falls back to
52
+ matching GitHub release assets and prints a warning.
53
+
54
+ ## Publishing
55
+
56
+ This package should be published through PyPI Trusted Publishing from GitHub
57
+ Actions. Token-based `PYPI_API_TOKEN` publishing is a fallback for first package
58
+ reservation or manual publication only.
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "runpane"
7
+ version = "2.2.9"
8
+ description = "Thin PyPI installer and remote setup CLI for Pane"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "AGPL-3.0" }
12
+ authors = [
13
+ { name = "Dcouple Inc", email = "hello@dcouple.ai" }
14
+ ]
15
+ keywords = ["pane", "installer", "remote", "daemon", "cli"]
16
+ classifiers = [
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Topic :: Software Development"
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://runpane.com"
26
+ Repository = "https://github.com/dcouple/Pane"
27
+ Issues = "https://github.com/dcouple/Pane/issues"
28
+
29
+ [project.scripts]
30
+ runpane = "runpane.cli:main"
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "2.2.9"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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
+ ])
@@ -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
@@ -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
@@ -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")
@@ -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")
@@ -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
@@ -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,16 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/runpane/__init__.py
4
+ src/runpane/__main__.py
5
+ src/runpane/cli.py
6
+ src/runpane/doctor.py
7
+ src/runpane/download.py
8
+ src/runpane/installers.py
9
+ src/runpane/platforms.py
10
+ src/runpane/releases.py
11
+ src/runpane/version.py
12
+ src/runpane.egg-info/PKG-INFO
13
+ src/runpane.egg-info/SOURCES.txt
14
+ src/runpane.egg-info/dependency_links.txt
15
+ src/runpane.egg-info/entry_points.txt
16
+ src/runpane.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ runpane = runpane.cli:main
@@ -0,0 +1 @@
1
+ runpane