From fefa582cd7c698d6958c4748c1ad78f15533edf0 Mon Sep 17 00:00:00 2001
From: ParsaKhaz
Date: Tue, 16 Jun 2026 20:04:36 -0700
Subject: [PATCH 1/9] Add runpane npm and PyPI installer packages
---
.github/workflows/build.yml | 65 ++++
.github/workflows/quality.yml | 43 +++
.gitignore | 1 +
README.md | 50 +++-
docs/RELEASE_INSTRUCTIONS.md | 32 ++
docs/RUNPANE_CLI_CONTRACT.md | 172 +++++++++++
docs/SELF_HOSTED_REMOTE_DAEMON.md | 25 ++
frontend/src/components/Settings.tsx | 26 +-
frontend/src/components/UpdateDialog.tsx | 4 +-
main/src/ipc/updater.ts | 2 +-
package.json | 4 +
packages/runpane-py/README.md | 58 ++++
packages/runpane-py/pyproject.toml | 33 ++
packages/runpane-py/src/runpane/__init__.py | 1 +
packages/runpane-py/src/runpane/__main__.py | 3 +
packages/runpane-py/src/runpane/cli.py | 264 ++++++++++++++++
packages/runpane-py/src/runpane/doctor.py | 38 +++
packages/runpane-py/src/runpane/download.py | 83 +++++
packages/runpane-py/src/runpane/installers.py | 124 ++++++++
packages/runpane-py/src/runpane/platforms.py | 66 ++++
packages/runpane-py/src/runpane/releases.py | 113 +++++++
packages/runpane-py/src/runpane/version.py | 41 +++
packages/runpane/README.md | 60 ++++
packages/runpane/eslint.config.js | 28 ++
packages/runpane/package.json | 45 +++
packages/runpane/scripts/mark-executable.js | 8 +
packages/runpane/src/cli.ts | 102 +++++++
packages/runpane/src/commands.ts | 283 ++++++++++++++++++
packages/runpane/src/doctor.ts | 41 +++
packages/runpane/src/download.ts | 94 ++++++
packages/runpane/src/installers.ts | 149 +++++++++
packages/runpane/src/platform.ts | 61 ++++
packages/runpane/src/releases.ts | 132 ++++++++
packages/runpane/src/version.ts | 53 ++++
packages/runpane/tsconfig.json | 22 ++
pnpm-lock.yaml | 24 ++
pnpm-workspace.yaml | 1 +
scripts/release.js | 49 ++-
scripts/sync-runpane-package-versions.js | 85 ++++++
scripts/test-runpane-contract.js | 272 +++++++++++++++++
scripts/test-runpane-package-smoke.js | 97 ++++++
41 files changed, 2834 insertions(+), 20 deletions(-)
create mode 100644 docs/RUNPANE_CLI_CONTRACT.md
create mode 100644 packages/runpane-py/README.md
create mode 100644 packages/runpane-py/pyproject.toml
create mode 100644 packages/runpane-py/src/runpane/__init__.py
create mode 100644 packages/runpane-py/src/runpane/__main__.py
create mode 100644 packages/runpane-py/src/runpane/cli.py
create mode 100644 packages/runpane-py/src/runpane/doctor.py
create mode 100644 packages/runpane-py/src/runpane/download.py
create mode 100644 packages/runpane-py/src/runpane/installers.py
create mode 100644 packages/runpane-py/src/runpane/platforms.py
create mode 100644 packages/runpane-py/src/runpane/releases.py
create mode 100644 packages/runpane-py/src/runpane/version.py
create mode 100644 packages/runpane/README.md
create mode 100644 packages/runpane/eslint.config.js
create mode 100644 packages/runpane/package.json
create mode 100644 packages/runpane/scripts/mark-executable.js
create mode 100644 packages/runpane/src/cli.ts
create mode 100644 packages/runpane/src/commands.ts
create mode 100644 packages/runpane/src/doctor.ts
create mode 100644 packages/runpane/src/download.ts
create mode 100644 packages/runpane/src/installers.ts
create mode 100644 packages/runpane/src/platform.ts
create mode 100644 packages/runpane/src/releases.ts
create mode 100644 packages/runpane/src/version.ts
create mode 100644 packages/runpane/tsconfig.json
create mode 100644 scripts/sync-runpane-package-versions.js
create mode 100644 scripts/test-runpane-contract.js
create mode 100644 scripts/test-runpane-package-smoke.js
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 544f9d44..4bbb3547 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -128,6 +128,71 @@ jobs:
gh release upload "v${VERSION}" SHA256SUMS.txt \
--repo dcouple/Pane --clobber
+ publish-npm:
+ if: github.ref_type == 'tag'
+ needs: [checksums]
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
+ env:
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22.15.1'
+ registry-url: 'https://registry.npmjs.org'
+ cache: 'pnpm'
+ - name: Ensure npm supports trusted publishing
+ run: npm install -g npm@^11.5.1
+ - run: pnpm install
+ - run: pnpm --filter runpane build
+ - name: Publish runpane to npm with token fallback
+ if: ${{ env.NPM_TOKEN != '' }}
+ working-directory: packages/runpane
+ env:
+ NODE_AUTH_TOKEN: ${{ env.NPM_TOKEN }}
+ run: npm publish --access public
+ - name: Publish runpane to npm with trusted publishing
+ if: ${{ env.NPM_TOKEN == '' }}
+ working-directory: packages/runpane
+ run: npm publish --access public
+
+ publish-pypi:
+ if: github.ref_type == 'tag'
+ needs: [checksums]
+ runs-on: ubuntu-latest
+ environment:
+ name: pypi
+ url: https://pypi.org/p/runpane
+ permissions:
+ contents: read
+ id-token: write
+ env:
+ PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+ - name: Build Python distribution
+ run: |
+ python -m pip install --upgrade build
+ python -m build packages/runpane-py
+ - name: Publish runpane to PyPI with token fallback
+ if: ${{ env.PYPI_API_TOKEN != '' }}
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ packages-dir: packages/runpane-py/dist
+ password: ${{ env.PYPI_API_TOKEN }}
+ - name: Publish runpane to PyPI with trusted publishing
+ if: ${{ env.PYPI_API_TOKEN == '' }}
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ packages-dir: packages/runpane-py/dist
+
build-windows:
strategy:
fail-fast: false
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index 70fafbbf..34aa0d09 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -51,6 +51,49 @@ jobs:
env:
PANE_DIR: ${{ runner.temp }}/pane-ci
+ runpane-wrapper-tests:
+ name: Runpane Wrapper (${{ matrix.os }}, Node ${{ matrix.node-version }}, Python ${{ matrix.python-version }})
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-2022]
+ node-version: ['18.17.0', '22.15.1']
+ python-version: ['3.8', '3.13']
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node-version }}
+ cache: 'pnpm'
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install wrapper dependencies
+ run: pnpm install --filter runpane --ignore-scripts
+
+ - name: Check wrapper package versions
+ run: node scripts/sync-runpane-package-versions.js --check
+
+ - name: Build npm wrapper
+ run: pnpm --filter runpane build
+
+ - name: Run wrapper contract tests
+ run: node scripts/test-runpane-contract.js
+
+ - name: Run package manager smoke tests
+ run: node scripts/test-runpane-package-smoke.js
+
cross-os-main-tests:
name: Main Process Tests (${{ matrix.os }})
runs-on: ${{ matrix.os }}
diff --git a/.gitignore b/.gitignore
index 979cbd5f..2298a4a9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@
node_modules/
.pnpm-store/
package-lock.json
+*.egg-info/
# Build outputs
dist/
diff --git a/README.md b/README.md
index fd41675d..0e743925 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,17 @@
**Quick install (recommended)**
+Package manager
+npx --yes runpane@latest install client
+
+pnpm
+pnpm dlx runpane@latest install client
+
+Python tools
+pipx run runpane install client
+
+or use the shell installers
+
Mac / Linux
curl -fsSL https://runpane.com/install.sh | sh
@@ -107,22 +118,40 @@ The easiest setup path is in the app:
4. On another desktop, open Pane, go to `Settings > Remote Pane`, paste the code, and connect.
5. On a phone or tablet, open [runpane.com/app](https://runpane.com/app/), paste the same code, and connect.
-For a headless VM or server, use the remote installer instead:
+For a headless VM or server, use `runpane`:
```bash
-curl -fsSL https://runpane.com/install-remote.sh | sh -s -- --label "My Server"
+npx --yes runpane@latest install daemon --label "My Server"
```
-Windows PowerShell:
+pnpm:
-```powershell
-& ([scriptblock]::Create((irm https://runpane.com/install-remote.ps1))) -Label "My Server"
+```bash
+pnpm dlx runpane@latest install daemon --label "My Server"
```
-Prefer SSH instead of Tailscale:
+Python tools:
```bash
-pnpm remote:setup -- --label "My Server" --prefer-tunnel ssh
+pipx run runpane install daemon --label "My Server"
+```
+
+Use SSH instead of Tailscale:
+
+```bash
+npx --yes runpane@latest install daemon --label "My Server" --prefer-tunnel ssh
+```
+
+The hosted shell installers remain available:
+
+```bash
+curl -fsSL https://runpane.com/install-remote.sh | sh -s -- --label "My Server"
+```
+
+Windows PowerShell:
+
+```powershell
+& ([scriptblock]::Create((irm https://runpane.com/install-remote.ps1))) -Label "My Server"
```
The CLI setup command prints the same connection code and, for SSH mode, the forwarding command. See the [Remote Daemon docs](https://runpane.com/docs/remote-daemon) for the full step-by-step setup, mobile install instructions, API key notes, and security model.
@@ -160,6 +189,13 @@ Other tools build custom chat UIs that only work with agents they've explicitly
### Quick Install
+**Package manager:**
+```bash
+npx --yes runpane@latest install client
+pnpm dlx runpane@latest install client
+pipx run runpane install client
+```
+
**Mac / Linux:**
```bash
curl -fsSL https://runpane.com/install.sh | sh
diff --git a/docs/RELEASE_INSTRUCTIONS.md b/docs/RELEASE_INSTRUCTIONS.md
index d42515e7..47b1ca24 100644
--- a/docs/RELEASE_INSTRUCTIONS.md
+++ b/docs/RELEASE_INSTRUCTIONS.md
@@ -4,6 +4,9 @@ Pane releases are cut from a clean `main` checkout with `scripts/release.js`.
The script updates `package.json`, commits `release: vX.Y.Z`, tags the same
commit, pushes `HEAD:main`, and pushes the tag.
+The same release commit also keeps the npm and PyPI wrapper package versions in
+sync through `scripts/sync-runpane-package-versions.js`.
+
## Mechanical Invariants
Before a release, these facts must be true:
@@ -12,6 +15,8 @@ Before a release, these facts must be true:
- `HEAD` matches `origin/main`.
- For inferred bumps (`patch`, `minor`, `major`), `package.json` version matches
the latest `v*` semver tag.
+- `packages/runpane/package.json`, `packages/runpane-py/pyproject.toml`, and
+ `packages/runpane-py/src/runpane/__init__.py` match the root release version.
- The release tag does not already exist locally or on `origin`.
- Dependencies are installed before running the release script so commit hooks can
run successfully.
@@ -34,6 +39,9 @@ node -p "require('./package.json').version"
pnpm typecheck
pnpm lint
+pnpm run check:runpane-package-versions
+pnpm run test:runpane-contract
+pnpm run test:runpane-package-smoke
pnpm test:ci:minimal
pnpm run release patch
@@ -58,6 +66,7 @@ Pull requests to `main` run:
- `Code Quality`
- typecheck
- lint
+ - runpane wrapper compatibility tests across Node, Python, Linux, macOS, and Windows
- main process tests on Linux, macOS, and Windows
- frontend unit tests
- maintained Playwright smoke tests
@@ -76,6 +85,8 @@ Pushes to `main` run:
- Windows arm64 installer
- GitHub release publishing
- `SHA256SUMS.txt`
+ - npm `runpane` publish
+ - PyPI `runpane` publish
- `Notify website on release`
The release is not considered complete until the tag-triggered `Build & Release`
@@ -99,6 +110,8 @@ Confirm:
- `HEAD` and `origin/main` point at the release commit.
- The release commit has the expected `vX.Y.Z` tag.
- `Build & Release` succeeded for the tag.
+- `npm view runpane version` reports `X.Y.Z`.
+- `python3 -m pip index versions runpane` includes `X.Y.Z`.
- `Notify website on release` succeeded for the tag.
- `Code Quality` succeeded for the release commit on `main`.
- `Deploy Remote PWA Preview` succeeded for the release commit on `main`.
@@ -107,6 +120,23 @@ Confirm:
GitHub Actions provides `GITHUB_TOKEN` automatically.
+The npm and PyPI packages should publish through trusted publishing:
+
+- npm: configure a trusted publisher for package `runpane` on npmjs.com with
+ repository `dcouple/Pane`, workflow filename `build.yml`, and `npm publish`
+ permission. The workflow installs npm `11.5.1` or newer for OIDC support.
+- PyPI: configure a trusted publisher for project `runpane` with repository
+ `dcouple/Pane`, workflow filename `build.yml`, and GitHub environment `pypi`.
+
+Fallback token publishing is allowed only for first package reservation or
+manual recovery. Use `NPM_TOKEN` or `PYPI_API_TOKEN` as local environment
+variables or GitHub Actions secrets, do not commit token files such as `.npmrc`
+or `.pypirc`, and revoke or rotate the tokens after use.
+
+If the source repository remains private, do not promise npm provenance:
+npm trusted publishing can still be used, but npm provenance attestations are
+not generated for private repositories.
+
The release and preview workflows also depend on repository secrets and
variables configured in GitHub Actions. Relevant examples include:
@@ -127,6 +157,8 @@ The build process generates update metadata and installers under
- macOS `.dmg` and `.zip`
- Linux `.deb` and `.AppImage`
- Windows `.exe`
+- npm package `runpane`
+- PyPI package `runpane`
## Rollback
diff --git a/docs/RUNPANE_CLI_CONTRACT.md b/docs/RUNPANE_CLI_CONTRACT.md
new file mode 100644
index 00000000..5b6c6d7f
--- /dev/null
+++ b/docs/RUNPANE_CLI_CONTRACT.md
@@ -0,0 +1,172 @@
+# Runpane CLI Contract
+
+`runpane` is a thin installer and configurator for Pane. The npm and PyPI
+packages expose the same command contract and download the real Pane release
+artifact at command runtime.
+
+The packages must not download, install, or configure Pane during package
+installation. Work starts only when a user runs `runpane ...`.
+
+## Maintainer Rules
+
+Treat this file as the source of truth for both wrapper packages:
+
+- Every command, flag, platform default, artifact-selection rule, and attribution
+ rule change must be reflected here.
+- The npm and PyPI wrappers must expose the same command behavior unless this
+ contract explicitly documents a package-manager-specific difference.
+- Root `README.md` should show the recommended user commands only. Package
+ READMEs may include package-specific runners such as `yarn dlx`, `bunx`,
+ `pipx`, or `uvx`.
+- Release version bumps must keep root `package.json`, `packages/runpane`, and
+ `packages/runpane-py` versions in sync. Run
+ `pnpm run check:runpane-package-versions` before release.
+- `pnpm run test:runpane-contract` must pass before changing wrapper command
+ parsing, help output, platform defaults, or release asset selection.
+- Token-based npm or PyPI publishing is a temporary fallback. Prefer trusted
+ publishing once the package names are reserved and trusted publishers are
+ configured.
+
+## Compatibility Floors
+
+The npm wrapper should run on Node.js `18.17.0` and newer. The root Electron app
+may require a newer Node.js version for development and packaging.
+
+The PyPI wrapper should run on Python `3.8` and newer. Keep runtime dependencies
+out of the wrapper unless a compatibility test covers the new dependency.
+
+## Package Manager Entrypoints
+
+Canonical npm and Node commands:
+
+```bash
+npx --yes runpane@latest install client
+npx --yes runpane@latest install daemon --label "My Server"
+npm i -g runpane && runpane install daemon --label "My Server"
+pnpm dlx runpane@latest install daemon --label "My Server"
+pnpm add -g runpane && runpane install daemon --label "My Server"
+yarn dlx runpane@latest install daemon --label "My Server"
+bunx runpane@latest install daemon --label "My Server"
+```
+
+Canonical Python commands:
+
+```bash
+python -m pip install runpane
+runpane install daemon --label "My Server"
+pipx install runpane
+pipx run runpane install daemon --label "My Server"
+uvx runpane@latest install daemon --label "My Server"
+python -m runpane install daemon --label "My Server"
+```
+
+Use `pnpm dlx` for one-shot pnpm execution and `pnpm add -g` for persistent
+CLI installation. Do not document `pnpm install runpane` as the public CLI
+install path.
+
+## Commands
+
+```bash
+runpane install
+runpane install client
+runpane install daemon
+runpane update
+runpane version
+runpane doctor
+runpane help
+runpane --help
+```
+
+`runpane install` is an alias for `runpane install client`.
+
+`runpane install client` downloads the selected Pane desktop artifact and
+installs, opens, or launches it for the current platform.
+
+`runpane install daemon` downloads or installs Pane, resolves a stable Pane
+executable path, and spawns:
+
+```bash
+ --remote-setup
+```
+
+The wrapper must stream Pane stdout/stderr without reformatting because
+`pane --remote-setup` prints the one-time `pane-remote://...` connection code.
+
+`runpane update` uses the same release resolution and installer path as
+`install client`.
+
+`runpane version` prints the wrapper package version, the installed Pane
+version when detectable, and the latest GitHub release version when reachable.
+
+`runpane doctor` checks platform support, release metadata reachability,
+download URL selection, installed Pane detection, and remote-daemon hints.
+
+## Wrapper Flags
+
+These flags are consumed by the wrapper:
+
+```bash
+--version
+--download-dir
+--pane-path
+--format
+--dry-run
+--yes
+--verbose
+```
+
+The top-level `runpane --version` form prints the wrapper version. The install
+subcommand form `runpane install --version vX.Y.Z` selects a Pane release.
+
+## Daemon Passthrough Flags
+
+`runpane install daemon` forwards these flags to `pane --remote-setup`:
+
+```bash
+--label
+--prefer-tunnel
+--channel
+--base-url
+--pane-dir
+--listen-port
+--port
+--auto-listen-port
+--interactive-tailscale-setup
+--no-install-service
+--no-tailscale-serve
+--print-only
+--repo-ref [
+```
+
+Unknown daemon flags should be forwarded rather than dropped so newer Pane
+versions can extend `--remote-setup` without requiring an immediate wrapper
+release. Unknown flags for non-daemon commands should fail clearly.
+
+## Download Attribution
+
+The npm package uses `source=npm` for all npm-registry consumers, including
+`npx`, `pnpm dlx`, `yarn dlx`, `bunx`, and global npm/pnpm installs.
+
+The PyPI package uses `source=pip` for all Python consumers, including pip,
+pipx, uvx, and `python -m runpane`.
+
+Wrappers should prefer:
+
+```text
+https://runpane.com/api/download?platform=&arch=&format=&version=&channel=&source=
+```
+
+If the website route cannot satisfy the download, wrappers may fall back to the
+matching GitHub release asset and print a warning that website attribution may
+be incomplete for that run.
+
+## Publishing Credentials
+
+Local implementation, build, and dry-run validation do not need npm or PyPI API
+tokens. Release publishing should prefer npm Trusted Publishing and PyPI
+Trusted Publishing from GitHub Actions.
+
+Fallback `NPM_TOKEN` or `PYPI_API_TOKEN` credentials may be used for first
+package reservation or manual publication only. They must be supplied through
+local environment variables or GitHub Actions secrets, never committed, and
+revoked or rotated after use.
diff --git a/docs/SELF_HOSTED_REMOTE_DAEMON.md b/docs/SELF_HOSTED_REMOTE_DAEMON.md
index f1783d23..61022251 100644
--- a/docs/SELF_HOSTED_REMOTE_DAEMON.md
+++ b/docs/SELF_HOSTED_REMOTE_DAEMON.md
@@ -12,6 +12,31 @@ Pane saves the profile and attempts to connect immediately. Local desktop mode i
## One-Command Setup
+Recommended package-manager commands:
+
+```bash
+npx --yes runpane@latest install daemon --label "VM"
+pnpm dlx runpane@latest install daemon --label "VM"
+pipx run runpane install daemon --label "VM"
+uvx runpane@latest install daemon --label "VM"
+```
+
+SSH tunnel mode:
+
+```bash
+npx --yes runpane@latest install daemon --label "VM" --prefer-tunnel ssh
+```
+
+Persistent installs are also supported:
+
+```bash
+npm i -g runpane
+runpane install daemon --label "VM"
+
+python -m pip install runpane
+runpane install daemon --label "VM"
+```
+
From a source checkout:
```bash
diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx
index 5061fe09..4bfe58c8 100644
--- a/frontend/src/components/Settings.tsx
+++ b/frontend/src/components/Settings.tsx
@@ -1458,15 +1458,29 @@ export function Settings({ isOpen, onClose, initialSection }: SettingsProps) {
]
-
macOS / Linux
-
- curl -fsSL https://runpane.com/install-remote.sh | sh -s -- --label "My Server"
+ npx
+
+ npx --yes runpane@latest install daemon --label "My Server"
-
Windows PowerShell
-
- & ([scriptblock]::Create((irm https://runpane.com/install-remote.ps1))) -Label "My Server"
+ pnpm
+
+ pnpm dlx runpane@latest install daemon --label "My Server"
+
+
+
+
+
+
Python tools
+
+ pipx run runpane install daemon --label "My Server"
+
+
+
+
SSH tunnel
+
+ npx --yes runpane@latest install daemon --label "My Server" --prefer-tunnel ssh
diff --git a/frontend/src/components/UpdateDialog.tsx b/frontend/src/components/UpdateDialog.tsx
index c48e1a8e..5c0617da 100644
--- a/frontend/src/components/UpdateDialog.tsx
+++ b/frontend/src/components/UpdateDialog.tsx
@@ -265,14 +265,14 @@ export function UpdateDialog({ isOpen, onClose, versionInfo }: UpdateDialogProps
This opens Terminal and copies the Pane update command.
- Paste it, press Return, and the latest Pane DMG will download and open.
+ Paste it, press Return, and the latest Pane installer will download and open.
After the DMG opens: close Pane, drag Pane.app into Applications, and choose Replace.
Your settings and sessions are preserved.
- curl -fsSL https://runpane.com/install.sh | sh
+ npx --yes runpane@latest update
diff --git a/main/src/ipc/updater.ts b/main/src/ipc/updater.ts
index 20dcafff..00288de5 100644
--- a/main/src/ipc/updater.ts
+++ b/main/src/ipc/updater.ts
@@ -8,7 +8,7 @@ import { commandExecutor } from '../utils/commandExecutor';
import { getCurrentWorktreeName } from '../utils/worktreeUtils';
import { getAppDirectory } from '../utils/appDirectory';
-const MAC_UPDATE_COMMAND = 'curl -fsSL https://runpane.com/install.sh | sh';
+const MAC_UPDATE_COMMAND = 'npx --yes runpane@latest update';
export function registerUpdaterHandlers(ipcMain: IpcMain, { app, versionChecker }: AppServices): void {
// Version checking handlers
diff --git a/package.json b/package.json
index 66816395..d6bfc273 100644
--- a/package.json
+++ b/package.json
@@ -47,6 +47,10 @@
"release:win:x64": "node scripts/build-win.js x64 --publish",
"release:win:arm64": "node scripts/build-win.js arm64 --publish",
"release": "node scripts/release.js",
+ "sync-runpane-package-versions": "node scripts/sync-runpane-package-versions.js",
+ "check:runpane-package-versions": "node scripts/sync-runpane-package-versions.js --check",
+ "test:runpane-contract": "pnpm --filter runpane build && node scripts/test-runpane-contract.js",
+ "test:runpane-package-smoke": "pnpm --filter runpane build && node scripts/test-runpane-package-smoke.js",
"inject-build-info": "node scripts/inject-build-info.js",
"release:mac": "pnpm run build:frontend && pnpm run build:main && pnpm run inject-build-info && pnpm run generate-notices && node scripts/configure-build.js && electron-builder --mac --publish always",
"release:mac:universal": "pnpm run build:frontend && pnpm run build:main && pnpm run inject-build-info && pnpm run generate-notices && node scripts/configure-build.js && electron-builder --mac --universal --publish always",
diff --git a/packages/runpane-py/README.md b/packages/runpane-py/README.md
new file mode 100644
index 00000000..00bd06b5
--- /dev/null
+++ b/packages/runpane-py/README.md
@@ -0,0 +1,58 @@
+# runpane
+
+Thin PyPI installer and remote setup CLI for Pane.
+
+The package does not include the Pane desktop runtime. It downloads the correct
+Pane release artifact only when you run `runpane install` or `runpane update`.
+
+## Usage
+
+One-shot execution:
+
+```bash
+pipx run runpane install daemon --label "My Server"
+uvx runpane@latest install daemon --label "My Server"
+```
+
+Persistent install:
+
+```bash
+python -m pip install runpane
+runpane install daemon --label "My Server"
+
+pipx install runpane
+runpane install daemon --label "My Server"
+```
+
+Module entrypoint:
+
+```bash
+python -m runpane install daemon --label "My Server"
+```
+
+## Commands
+
+```bash
+runpane install
+runpane install client
+runpane install daemon
+runpane update
+runpane version
+runpane doctor
+runpane --help
+```
+
+`runpane install daemon` installs Pane and then invokes the installed executable
+with `--remote-setup`, preserving the `pane-remote://...` connection-code output.
+
+## Attribution
+
+PyPI package downloads use `source=pip` when requesting release artifacts from
+`runpane.com/api/download`. If that route is unavailable, the CLI falls back to
+matching GitHub release assets and prints a warning.
+
+## Publishing
+
+This package should be published through PyPI Trusted Publishing from GitHub
+Actions. Token-based `PYPI_API_TOKEN` publishing is a fallback for first package
+reservation or manual publication only.
diff --git a/packages/runpane-py/pyproject.toml b/packages/runpane-py/pyproject.toml
new file mode 100644
index 00000000..d1cab170
--- /dev/null
+++ b/packages/runpane-py/pyproject.toml
@@ -0,0 +1,33 @@
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "runpane"
+version = "2.2.8"
+description = "Thin PyPI installer and remote setup CLI for Pane"
+readme = "README.md"
+requires-python = ">=3.8"
+license = { text = "AGPL-3.0" }
+authors = [
+ { name = "Dcouple Inc", email = "hello@dcouple.ai" }
+]
+keywords = ["pane", "installer", "remote", "daemon", "cli"]
+classifiers = [
+ "Environment :: Console",
+ "Intended Audience :: Developers",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3 :: Only",
+ "Topic :: Software Development"
+]
+
+[project.urls]
+Homepage = "https://runpane.com"
+Repository = "https://github.com/dcouple/Pane"
+Issues = "https://github.com/dcouple/Pane/issues"
+
+[project.scripts]
+runpane = "runpane.cli:main"
+
+[tool.setuptools.packages.find]
+where = ["src"]
diff --git a/packages/runpane-py/src/runpane/__init__.py b/packages/runpane-py/src/runpane/__init__.py
new file mode 100644
index 00000000..23bc6ef5
--- /dev/null
+++ b/packages/runpane-py/src/runpane/__init__.py
@@ -0,0 +1 @@
+__version__ = "2.2.8"
diff --git a/packages/runpane-py/src/runpane/__main__.py b/packages/runpane-py/src/runpane/__main__.py
new file mode 100644
index 00000000..eb53e2f3
--- /dev/null
+++ b/packages/runpane-py/src/runpane/__main__.py
@@ -0,0 +1,3 @@
+from .cli import main
+
+raise SystemExit(main())
diff --git a/packages/runpane-py/src/runpane/cli.py b/packages/runpane-py/src/runpane/cli.py
new file mode 100644
index 00000000..e3e2c290
--- /dev/null
+++ b/packages/runpane-py/src/runpane/cli.py
@@ -0,0 +1,264 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import List, Optional
+
+from .doctor import run_doctor
+from .download import download_artifact
+from .installers import install_pane_artifact, launch_pane_client, spawn_pane
+from .platforms import detect_platform
+from .releases import resolve_release
+from .version import print_version
+
+SOURCE = "pip"
+
+COMMANDS = {"help", "install", "update", "version", "doctor"}
+TARGETS = {"client", "daemon"}
+FORMATS = {"auto", "appimage", "deb", "dmg", "zip", "exe"}
+CHANNELS = {"stable", "nightly"}
+
+REMOTE_VALUE_FLAGS = {
+ "--label",
+ "--prefer-tunnel",
+ "--channel",
+ "--base-url",
+ "--pane-dir",
+ "--listen-port",
+ "--port",
+ "--repo-ref",
+}
+
+REMOTE_BOOLEAN_FLAGS = {
+ "--auto-listen-port",
+ "--interactive-tailscale-setup",
+ "--no-install-service",
+ "--no-tailscale-serve",
+ "--print-only",
+}
+
+
+@dataclass
+class ParsedArgs:
+ command: str
+ target: str = "client"
+ pane_version: str = "latest"
+ channel: str = "stable"
+ format: str = "auto"
+ download_dir: Optional[str] = None
+ pane_path: Optional[str] = None
+ dry_run: bool = False
+ yes: bool = False
+ verbose: bool = False
+ help_topic: Optional[str] = None
+ remote_setup_args: List[str] = field(default_factory=list)
+
+
+def main(argv: Optional[List[str]] = None) -> int:
+ import sys
+
+ try:
+ parsed = parse_args(sys.argv[1:] if argv is None else argv)
+ if parsed.command == "help":
+ print(help_text(parsed.help_topic))
+ return 0
+ if parsed.command == "version":
+ return print_version(parsed.pane_path)
+ if parsed.command == "doctor":
+ return run_doctor(parsed, SOURCE)
+ if parsed.command in {"install", "update"}:
+ return install_or_update(parsed)
+ print(help_text(None))
+ return 0
+ except Exception as error:
+ print(str(error), file=sys.stderr)
+ return 1
+
+
+def parse_args(argv: List[str]) -> ParsedArgs:
+ args = list(argv)
+ if not args or args[0] in {"-h", "--help"}:
+ return ParsedArgs(command="help")
+ first = args.pop(0)
+ if first in {"-v", "--version"}:
+ return ParsedArgs(command="version")
+ if first not in COMMANDS:
+ raise ValueError(f"Unknown command: {first}\n\n{help_text(None)}")
+ if first == "help":
+ return ParsedArgs(command="help", help_topic=args[0] if args else None)
+
+ parsed = ParsedArgs(command=first)
+ if parsed.command == "install" and args and not args[0].startswith("-"):
+ target = args.pop(0)
+ if target not in TARGETS:
+ raise ValueError(f'Unknown install target: {target}. Expected "client" or "daemon".')
+ parsed.target = target
+
+ if parsed.command == "update":
+ parsed.target = "client"
+
+ parse_flags(args, parsed)
+ return parsed
+
+
+def parse_flags(args: List[str], parsed: ParsedArgs) -> None:
+ index = 0
+ while index < len(args):
+ arg = args[index]
+ if arg in {"-h", "--help"}:
+ parsed.help_topic = parsed.command
+ parsed.command = "help"
+ elif arg == "--dry-run":
+ parsed.dry_run = True
+ elif arg in {"--yes", "-y"}:
+ parsed.yes = True
+ elif arg == "--verbose":
+ parsed.verbose = True
+ elif arg == "--version":
+ index += 1
+ parsed.pane_version = read_value(args, index, arg)
+ elif arg == "--download-dir":
+ index += 1
+ parsed.download_dir = read_value(args, index, arg)
+ elif arg == "--pane-path":
+ index += 1
+ parsed.pane_path = read_value(args, index, arg)
+ elif arg == "--format":
+ index += 1
+ value = read_value(args, index, arg)
+ if value not in FORMATS:
+ raise ValueError(f"Invalid --format {value}. Expected one of: {', '.join(sorted(FORMATS))}")
+ parsed.format = value
+ elif arg in REMOTE_VALUE_FLAGS:
+ index += 1
+ value = read_value(args, index, arg)
+ if arg == "--channel":
+ if value not in CHANNELS:
+ raise ValueError(f"Invalid --channel {value}. Expected stable or nightly.")
+ parsed.channel = value
+ append_remote_arg(parsed, arg, value)
+ elif arg in REMOTE_BOOLEAN_FLAGS:
+ append_remote_arg(parsed, arg)
+ elif parsed.command == "install" and parsed.target == "daemon":
+ parsed.remote_setup_args.append(arg)
+ if arg.startswith("-") and index + 1 < len(args) and not args[index + 1].startswith("-"):
+ index += 1
+ parsed.remote_setup_args.append(args[index])
+ else:
+ raise ValueError(f"Unknown option for {parsed.command}: {arg}")
+ index += 1
+
+
+def append_remote_arg(parsed: ParsedArgs, flag: str, value: Optional[str] = None) -> None:
+ if parsed.command == "install" and parsed.target == "daemon":
+ parsed.remote_setup_args.append(flag)
+ if value is not None:
+ parsed.remote_setup_args.append(value)
+ return
+ raise ValueError(f'{flag} is only valid with "runpane install daemon".')
+
+
+def read_value(args: List[str], index: int, flag: str) -> str:
+ if index >= len(args) or args[index].startswith("-"):
+ raise ValueError(f"{flag} requires a value.")
+ return args[index]
+
+
+def install_or_update(parsed: ParsedArgs) -> int:
+ target = "client" if parsed.command == "update" else parsed.target
+ platform = detect_platform()
+ resolved = resolve_release(
+ version=parsed.pane_version,
+ channel=parsed.channel,
+ source=SOURCE,
+ platform=platform,
+ format_name=parsed.format,
+ target=target,
+ )
+
+ if parsed.dry_run:
+ print("runpane dry run")
+ print(f"Command: {parsed.command}")
+ print(f"Target: {target}")
+ print(f"Pane release: {parsed.pane_version}")
+ print(f"Channel: {parsed.channel}")
+ print(f"Format: {parsed.format}")
+ print(f"Artifact: {resolved.artifact['name']}")
+ print(f"Preferred download: {resolved.preferred_download_url}")
+ print(f"GitHub fallback: {resolved.fallback_download_url}")
+ if parsed.pane_path:
+ print(f"Existing Pane path: {parsed.pane_path}")
+ if target == "daemon":
+ forwarded = " ".join(parsed.remote_setup_args)
+ print(f"Pane command: --remote-setup {forwarded}".strip())
+ return 0
+
+ artifact = download_artifact(resolved, parsed.download_dir, parsed.verbose)
+ installed = install_pane_artifact(artifact, parsed, platform, resolved.format, target)
+
+ if target == "daemon":
+ return spawn_pane(installed.executable_path, ["--remote-setup", *parsed.remote_setup_args])
+
+ if installed.install_kind == "installed":
+ launch_pane_client(installed.executable_path)
+
+ print(f"Pane {installed.install_kind}: {installed.executable_path}")
+ return 0
+
+
+def help_text(topic: Optional[str]) -> str:
+ if topic == "install":
+ return "\n".join([
+ "Usage:",
+ " runpane install [client|daemon] [options]",
+ "",
+ "Examples:",
+ ' npx --yes runpane@latest install daemon --label "My Server"',
+ ' pnpm dlx runpane@latest install daemon --prefer-tunnel ssh --label "VM"',
+ ' pipx run runpane install daemon --label "My Server"',
+ "",
+ "Wrapper options:",
+ " --version ",
+ " --format ",
+ " --download-dir ",
+ " --pane-path ",
+ " --dry-run",
+ " --yes",
+ " --verbose",
+ "",
+ "Daemon passthrough options:",
+ " --label ",
+ " --prefer-tunnel ",
+ " --channel ",
+ " --base-url ",
+ " --pane-dir ",
+ " --listen-port / --port ",
+ " --auto-listen-port",
+ " --interactive-tailscale-setup",
+ " --no-install-service",
+ " --no-tailscale-serve",
+ " --print-only",
+ " --repo-ref [",
+ ])
+
+ if topic == "update":
+ return "Usage:\n runpane update [--version ] [--dry-run] [--yes]"
+ if topic == "version":
+ return "Usage:\n runpane version\n runpane --version"
+ if topic == "doctor":
+ return "Usage:\n runpane doctor [--pane-path ] [--format ] [--verbose]"
+
+ return "\n".join([
+ "Usage:",
+ " runpane install [client|daemon] [options]",
+ " runpane update [options]",
+ " runpane version",
+ " runpane doctor",
+ " runpane help [command]",
+ "",
+ "Package manager examples:",
+ ' pipx run runpane install daemon --label "My Server"',
+ ' uvx runpane@latest install daemon --label "My Server"',
+ ' python -m runpane install daemon --label "My Server"',
+ "",
+ 'Run "runpane help install" for install options.',
+ ])
diff --git a/packages/runpane-py/src/runpane/doctor.py b/packages/runpane-py/src/runpane/doctor.py
new file mode 100644
index 00000000..659145c1
--- /dev/null
+++ b/packages/runpane-py/src/runpane/doctor.py
@@ -0,0 +1,38 @@
+from __future__ import annotations
+
+from .installers import resolve_existing_pane_path
+from .platforms import detect_platform
+from .releases import resolve_release
+from .version import pane_version
+
+
+def run_doctor(parsed, source: str = "pip") -> int:
+ ok = True
+ try:
+ platform = detect_platform()
+ print(f"Platform: {platform.os}/{platform.arch}")
+ release = resolve_release(
+ version=parsed.pane_version,
+ channel=parsed.channel,
+ source=source,
+ platform=platform,
+ format_name=parsed.format,
+ target="client",
+ )
+ print(f"Latest release: {release.release['tag_name']}")
+ print(f"Selected artifact: {release.artifact['name']}")
+ print(f"Website URL: {release.preferred_download_url}")
+ print(f"GitHub fallback: {release.fallback_download_url}")
+ except Exception as error:
+ ok = False
+ print(f"Release check: failed - {error}")
+
+ installed = resolve_existing_pane_path(parsed.pane_path)
+ if installed:
+ print(f"Installed Pane: {installed}")
+ print(f"Installed version: {pane_version(installed) or 'unknown'}")
+ else:
+ print("Installed Pane: not found")
+
+ print('Remote setup: run "runpane install daemon --label " to configure a headless host.')
+ return 0 if ok else 1
diff --git a/packages/runpane-py/src/runpane/download.py b/packages/runpane-py/src/runpane/download.py
new file mode 100644
index 00000000..d85aa402
--- /dev/null
+++ b/packages/runpane-py/src/runpane/download.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+import hashlib
+import os
+import tempfile
+import time
+import urllib.error
+import urllib.request
+from dataclasses import dataclass
+from typing import Optional
+
+from .releases import ResolvedRelease, artifact_file_name
+
+
+@dataclass
+class DownloadedArtifact:
+ path: str
+ file_name: str
+ used_fallback: bool
+
+
+def download_artifact(resolved: ResolvedRelease, download_dir: Optional[str], verbose: bool) -> DownloadedArtifact:
+ target_dir = download_dir or os.path.join(tempfile.gettempdir(), f"runpane-{int(time.time() * 1000)}")
+ os.makedirs(target_dir, exist_ok=True)
+
+ file_name = artifact_file_name(resolved.artifact["name"])
+ target_path = os.path.join(target_dir, file_name)
+ used_fallback = False
+
+ try:
+ download_to_file(resolved.preferred_download_url, target_path, verbose)
+ except Exception as error:
+ used_fallback = True
+ print(f"runpane: website download route failed; falling back to GitHub release asset. {error}")
+ download_to_file(resolved.fallback_download_url, target_path, verbose)
+
+ verify_checksum_if_available(resolved, target_path, file_name)
+ return DownloadedArtifact(path=target_path, file_name=file_name, used_fallback=used_fallback)
+
+
+def download_to_file(url: str, target_path: str, verbose: bool) -> None:
+ if verbose:
+ print(f"Downloading {url}")
+ req = urllib.request.Request(url, headers={"User-Agent": "runpane-installer"})
+ with urllib.request.urlopen(req, timeout=120) as response:
+ if getattr(response, "status", 200) >= 400:
+ raise RuntimeError(f"{response.status} {response.reason}")
+ with open(target_path, "wb") as target:
+ target.write(response.read())
+
+
+def verify_checksum_if_available(resolved: ResolvedRelease, artifact_path: str, file_name: str) -> None:
+ try:
+ req = urllib.request.Request(resolved.checksum_url, headers={"User-Agent": "runpane-installer"})
+ with urllib.request.urlopen(req, timeout=30) as response:
+ checksums = response.read().decode("utf-8")
+ except (urllib.error.URLError, TimeoutError, OSError) as error:
+ print(f"runpane: could not verify checksum for {file_name}. {error}")
+ return
+
+ expected = parse_checksum(checksums, file_name)
+ if not expected:
+ return
+
+ digest = hashlib.sha256()
+ with open(artifact_path, "rb") as source:
+ for chunk in iter(lambda: source.read(1024 * 1024), b""):
+ digest.update(chunk)
+
+ actual = digest.hexdigest()
+ if actual.lower() != expected.lower():
+ raise RuntimeError(f"Checksum mismatch for {file_name}. Expected {expected}, got {actual}.")
+
+
+def parse_checksum(checksums: str, file_name: str) -> Optional[str]:
+ for line in checksums.splitlines():
+ stripped = line.strip()
+ if not stripped.endswith(file_name):
+ continue
+ digest = stripped.split()[0]
+ if len(digest) == 64 and all(char in "0123456789abcdefABCDEF" for char in digest):
+ return digest
+ return None
diff --git a/packages/runpane-py/src/runpane/installers.py b/packages/runpane-py/src/runpane/installers.py
new file mode 100644
index 00000000..07621b8c
--- /dev/null
+++ b/packages/runpane-py/src/runpane/installers.py
@@ -0,0 +1,124 @@
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+from dataclasses import dataclass
+from typing import List, Optional
+
+from .download import DownloadedArtifact
+from .platforms import PanePlatform, default_install_root
+
+
+@dataclass
+class InstalledPane:
+ executable_path: str
+ install_kind: str
+
+
+def resolve_existing_pane_path(pane_path: Optional[str] = None) -> Optional[str]:
+ if pane_path:
+ return pane_path if os.path.exists(pane_path) else None
+
+ home = os.path.expanduser("~")
+ candidates = []
+ if os.name == "nt":
+ local = os.environ.get("LOCALAPPDATA")
+ program_files = os.environ.get("ProgramFiles")
+ if local:
+ candidates.extend([
+ os.path.join(local, "Programs", "Pane", "Pane.exe"),
+ os.path.join(local, "Pane", "Pane.exe"),
+ ])
+ if program_files:
+ candidates.append(os.path.join(program_files, "Pane", "Pane.exe"))
+ elif subprocess.run(["uname"], capture_output=True, text=True).stdout.strip() == "Darwin":
+ candidates.extend([
+ "/Applications/Pane.app/Contents/MacOS/Pane",
+ os.path.join(home, "Applications", "Pane.app", "Contents", "MacOS", "Pane"),
+ ])
+ else:
+ candidates.extend([
+ os.path.join(home, ".local", "bin", "pane"),
+ "/usr/bin/pane",
+ "/opt/Pane/pane",
+ ])
+
+ for candidate in candidates:
+ if os.path.exists(candidate):
+ return candidate
+ return None
+
+
+def install_pane_artifact(
+ artifact: DownloadedArtifact,
+ parsed,
+ platform: PanePlatform,
+ format_name: str,
+ target: str,
+) -> InstalledPane:
+ existing = resolve_existing_pane_path(parsed.pane_path)
+ if existing:
+ return InstalledPane(executable_path=existing, install_kind="existing")
+
+ if platform.os == "darwin":
+ return install_mac(artifact, format_name, target)
+ if platform.os == "linux":
+ return install_linux(artifact, format_name)
+ return install_windows(artifact, target)
+
+
+def spawn_pane(executable_path: str, args: List[str]) -> int:
+ try:
+ return subprocess.call([executable_path, *args])
+ except OSError as error:
+ print(f"Failed to launch Pane: {error}")
+ return 1
+
+
+def launch_pane_client(executable_path: str) -> None:
+ subprocess.Popen([executable_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
+
+
+def install_mac(artifact: DownloadedArtifact, format_name: str, target: str) -> InstalledPane:
+ if format_name == "dmg":
+ subprocess.call(["open", artifact.path])
+ return InstalledPane(executable_path="/Applications/Pane.app/Contents/MacOS/Pane", install_kind="launched-installer")
+
+ apps_root = os.path.join(os.path.expanduser("~"), "Applications")
+ app_path = os.path.join(apps_root, "Pane.app")
+ os.makedirs(apps_root, exist_ok=True)
+ subprocess.check_call(["ditto", "-x", "-k", artifact.path, apps_root])
+ executable_path = os.path.join(app_path, "Contents", "MacOS", "Pane")
+ if not os.path.exists(executable_path):
+ raise RuntimeError(f"Pane executable was not found after extracting {artifact.file_name}. Expected {executable_path}")
+ if target == "client":
+ subprocess.call(["open", app_path])
+ return InstalledPane(executable_path=executable_path, install_kind="installed")
+
+
+def install_linux(artifact: DownloadedArtifact, format_name: str) -> InstalledPane:
+ if format_name == "deb":
+ installer = "apt" if shutil.which("apt") else "dpkg"
+ args = ["install", "-y", artifact.path] if installer == "apt" else ["-i", artifact.path]
+ subprocess.call(["sudo", installer, *args])
+ executable = resolve_existing_pane_path()
+ if not executable:
+ raise RuntimeError("Pane installed from .deb, but the pane executable could not be found.")
+ return InstalledPane(executable_path=executable, install_kind="installed")
+
+ bin_root = default_install_root()
+ os.makedirs(bin_root, exist_ok=True)
+ executable_path = os.path.join(bin_root, "pane")
+ shutil.copyfile(artifact.path, executable_path)
+ os.chmod(executable_path, 0o755)
+ return InstalledPane(executable_path=executable_path, install_kind="installed")
+
+
+def install_windows(artifact: DownloadedArtifact, target: str) -> InstalledPane:
+ args = ["/S"] if target == "daemon" else []
+ subprocess.call([artifact.path, *args])
+ executable = resolve_existing_pane_path()
+ if not executable:
+ raise RuntimeError("Pane installer completed, but Pane.exe could not be found. Open the installer manually and rerun with --pane-path.")
+ return InstalledPane(executable_path=executable, install_kind="installed" if target == "daemon" else "launched-installer")
diff --git a/packages/runpane-py/src/runpane/platforms.py b/packages/runpane-py/src/runpane/platforms.py
new file mode 100644
index 00000000..56aa725c
--- /dev/null
+++ b/packages/runpane-py/src/runpane/platforms.py
@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+import os
+import platform
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class PanePlatform:
+ os: str
+ arch: str
+
+
+def detect_platform() -> PanePlatform:
+ system = platform.system().lower()
+ machine = platform.machine().lower()
+
+ if system == "darwin":
+ os_name = "darwin"
+ elif system == "linux":
+ os_name = "linux"
+ elif system == "windows":
+ os_name = "win32"
+ else:
+ raise RuntimeError(f"Unsupported OS: {system}")
+
+ if machine in {"x86_64", "amd64"}:
+ arch = "x64"
+ elif machine in {"arm64", "aarch64"}:
+ arch = "arm64"
+ else:
+ raise RuntimeError(f"Unsupported CPU architecture: {machine}")
+
+ return PanePlatform(os=os_name, arch=arch)
+
+
+def default_format(platform_info: PanePlatform, target: str) -> str:
+ if platform_info.os == "darwin":
+ return "zip" if target == "daemon" else "dmg"
+ if platform_info.os == "win32":
+ return "exe"
+ return "appimage"
+
+
+def platform_param(platform_info: PanePlatform) -> str:
+ if platform_info.os == "darwin":
+ return "mac"
+ if platform_info.os == "win32":
+ return "windows"
+ return "linux"
+
+
+def arch_aliases(platform_info: PanePlatform) -> list[str]:
+ if platform_info.arch == "arm64":
+ return ["arm64", "aarch64"]
+ if platform_info.os == "linux":
+ return ["x64", "x86_64", "amd64"]
+ return ["x64", "x86_64"]
+
+
+def default_install_root() -> str:
+ if os.name == "nt":
+ return os.environ.get("LOCALAPPDATA", os.path.join(os.path.expanduser("~"), "AppData", "Local", "Pane"))
+ if platform.system().lower() == "darwin":
+ return os.path.join(os.path.expanduser("~"), "Applications")
+ return os.path.join(os.path.expanduser("~"), ".local", "bin")
diff --git a/packages/runpane-py/src/runpane/releases.py b/packages/runpane-py/src/runpane/releases.py
new file mode 100644
index 00000000..ffb04872
--- /dev/null
+++ b/packages/runpane-py/src/runpane/releases.py
@@ -0,0 +1,113 @@
+from __future__ import annotations
+
+import json
+import os
+import urllib.parse
+import urllib.request
+from dataclasses import dataclass
+from typing import Any, Dict
+
+from .platforms import PanePlatform, arch_aliases, default_format, platform_param
+
+GITHUB_API_BASE = "https://api.github.com/repos/dcouple/Pane/releases"
+DOWNLOAD_API_BASE = "https://runpane.com/api/download"
+
+
+@dataclass
+class ResolvedRelease:
+ release: Dict[str, Any]
+ artifact: Dict[str, Any]
+ format: str
+ preferred_download_url: str
+ fallback_download_url: str
+ checksum_url: str
+
+
+def resolve_release(
+ *,
+ version: str,
+ channel: str,
+ source: str,
+ platform: PanePlatform,
+ format_name: str,
+ target: str,
+) -> ResolvedRelease:
+ release = fetch_release(version)
+ selected_format = default_format(platform, target) if format_name == "auto" else format_name
+ artifact = find_artifact(release, platform, selected_format)
+ preferred = build_preferred_download_url(version, channel, source, platform, selected_format)
+ tag_name = release["tag_name"]
+ return ResolvedRelease(
+ release=release,
+ artifact=artifact,
+ format=selected_format,
+ preferred_download_url=preferred,
+ fallback_download_url=artifact["browser_download_url"],
+ checksum_url=f"https://github.com/dcouple/Pane/releases/download/{tag_name}/SHA256SUMS.txt",
+ )
+
+
+def fetch_release(version: str) -> Dict[str, Any]:
+ normalized = "latest" if version == "latest" else f"tags/{version if version.startswith('v') else 'v' + version}"
+ req = urllib.request.Request(
+ f"{GITHUB_API_BASE}/{normalized}",
+ headers={"Accept": "application/vnd.github+json", "User-Agent": "runpane-installer"},
+ )
+ with urllib.request.urlopen(req, timeout=30) as response:
+ release = json.loads(response.read().decode("utf-8"))
+
+ if release.get("draft") or release.get("prerelease"):
+ raise RuntimeError(f"Release {release.get('tag_name')} is not a stable public release.")
+ return release
+
+
+def find_artifact(release: Dict[str, Any], platform: PanePlatform, format_name: str) -> Dict[str, Any]:
+ assets = release.get("assets") or []
+ candidates = [
+ asset for asset in assets
+ if matches_format(asset["name"], format_name) and matches_platform(asset["name"], platform)
+ ]
+ aliases = arch_aliases(platform)
+ for asset in candidates:
+ lower = asset["name"].lower()
+ if any(alias.lower() in lower for alias in aliases):
+ return asset
+ for asset in candidates:
+ if "universal" in asset["name"].lower():
+ return asset
+ if candidates:
+ return candidates[0]
+ names = ", ".join(asset["name"] for asset in assets) or "no assets"
+ raise RuntimeError(f"No Pane {format_name} asset found for {platform.os}/{platform.arch}. Assets: {names}")
+
+
+def artifact_file_name(url_or_name: str) -> str:
+ return os.path.basename(url_or_name.split("?", 1)[0])
+
+
+def build_preferred_download_url(version: str, channel: str, source: str, platform: PanePlatform, format_name: str) -> str:
+ query = urllib.parse.urlencode({
+ "platform": platform_param(platform),
+ "arch": platform.arch,
+ "format": format_name,
+ "version": "latest" if version == "latest" else version if version.startswith("v") else f"v{version}",
+ "channel": channel,
+ "source": source,
+ })
+ return f"{DOWNLOAD_API_BASE}?{query}"
+
+
+def matches_format(name: str, format_name: str) -> bool:
+ lower = name.lower()
+ if format_name == "appimage":
+ return lower.endswith(".appimage")
+ return lower.endswith(f".{format_name}")
+
+
+def matches_platform(name: str, platform: PanePlatform) -> bool:
+ lower = name.lower()
+ if platform.os == "darwin":
+ return "macos" in lower or "darwin" in lower or "mac" in lower
+ if platform.os == "win32":
+ return "windows" in lower or "win" in lower
+ return "linux" in lower
diff --git a/packages/runpane-py/src/runpane/version.py b/packages/runpane-py/src/runpane/version.py
new file mode 100644
index 00000000..c7249769
--- /dev/null
+++ b/packages/runpane-py/src/runpane/version.py
@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+import subprocess
+from importlib import metadata
+from typing import Optional
+
+from . import __version__
+from .installers import resolve_existing_pane_path
+from .releases import fetch_release
+
+
+def wrapper_version() -> str:
+ try:
+ return metadata.version("runpane")
+ except metadata.PackageNotFoundError:
+ return __version__
+
+
+def print_version(pane_path: Optional[str] = None) -> int:
+ installed_path = resolve_existing_pane_path(pane_path)
+ installed_version = pane_version(installed_path) if installed_path else None
+ try:
+ latest = fetch_release("latest")["tag_name"].lstrip("v")
+ except Exception:
+ latest = "unavailable"
+
+ print(f"runpane {wrapper_version()}")
+ print(f"Pane installed: {installed_version or 'not found'}")
+ print(f"Pane latest: {latest}")
+ if installed_path:
+ print(f"Pane path: {installed_path}")
+ return 0
+
+
+def pane_version(executable_path: str) -> Optional[str]:
+ try:
+ result = subprocess.run([executable_path, "--version"], capture_output=True, text=True, timeout=10)
+ except OSError:
+ return None
+ output = (result.stdout + result.stderr).strip()
+ return output or None
diff --git a/packages/runpane/README.md b/packages/runpane/README.md
new file mode 100644
index 00000000..b5e3dbad
--- /dev/null
+++ b/packages/runpane/README.md
@@ -0,0 +1,60 @@
+# runpane
+
+Thin npm installer and remote setup CLI for Pane.
+
+The package does not include the Pane desktop runtime. It downloads the correct
+Pane release artifact only when you run `runpane install` or `runpane update`.
+
+## Usage
+
+One-shot install:
+
+```bash
+npx --yes runpane@latest install client
+npx --yes runpane@latest install daemon --label "My Server"
+pnpm dlx runpane@latest install daemon --label "My Server"
+```
+
+Persistent install:
+
+```bash
+npm i -g runpane
+runpane install daemon --label "My Server"
+
+pnpm add -g runpane
+runpane install daemon --label "My Server"
+```
+
+Compatible runners:
+
+```bash
+yarn dlx runpane@latest install daemon --label "My Server"
+bunx runpane@latest install daemon --label "My Server"
+```
+
+## Commands
+
+```bash
+runpane install
+runpane install client
+runpane install daemon
+runpane update
+runpane version
+runpane doctor
+runpane --help
+```
+
+`runpane install daemon` installs Pane and then invokes the installed executable
+with `--remote-setup`, preserving the `pane-remote://...` connection-code output.
+
+## Attribution
+
+npm package downloads use `source=npm` when requesting release artifacts from
+`runpane.com/api/download`. If that route is unavailable, the CLI falls back to
+matching GitHub release assets and prints a warning.
+
+## Publishing
+
+This package should be published through npm Trusted Publishing from GitHub
+Actions. Token-based `NPM_TOKEN` publishing is a fallback for first package
+reservation or manual publication only.
diff --git a/packages/runpane/eslint.config.js b/packages/runpane/eslint.config.js
new file mode 100644
index 00000000..62a637c6
--- /dev/null
+++ b/packages/runpane/eslint.config.js
@@ -0,0 +1,28 @@
+const js = require('@eslint/js');
+const typescript = require('typescript-eslint');
+
+module.exports = [
+ js.configs.recommended,
+ ...typescript.configs.recommended,
+ {
+ files: ['src/**/*.ts'],
+ languageOptions: {
+ ecmaVersion: 2022,
+ sourceType: 'module',
+ parser: typescript.parser
+ },
+ rules: {
+ '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
+ '@typescript-eslint/no-explicit-any': 'error',
+ '@typescript-eslint/explicit-module-boundary-types': 'off',
+ '@typescript-eslint/no-require-imports': 'warn',
+ 'no-console': 'off',
+ 'no-useless-escape': 'warn',
+ 'prefer-const': 'warn',
+ 'no-empty': 'warn'
+ }
+ },
+ {
+ ignores: ['dist/', 'node_modules/', '*.config.js']
+ }
+];
diff --git a/packages/runpane/package.json b/packages/runpane/package.json
new file mode 100644
index 00000000..5f6c87fd
--- /dev/null
+++ b/packages/runpane/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "runpane",
+ "version": "2.2.8",
+ "description": "Thin npm installer and remote setup CLI for Pane",
+ "license": "AGPL-3.0",
+ "homepage": "https://runpane.com",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/dcouple/Pane.git",
+ "directory": "packages/runpane"
+ },
+ "bugs": {
+ "url": "https://github.com/dcouple/Pane/issues"
+ },
+ "bin": {
+ "runpane": "dist/cli.js"
+ },
+ "main": "dist/cli.js",
+ "types": "dist/cli.d.ts",
+ "files": [
+ "dist",
+ "README.md"
+ ],
+ "scripts": {
+ "build": "tsc && node scripts/mark-executable.js",
+ "lint": "eslint src --ext .ts",
+ "typecheck": "tsc --noEmit",
+ "prepack": "pnpm run build"
+ },
+ "engines": {
+ "node": ">=18.17.0"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.17.0",
+ "@types/node": "^22.10.2",
+ "@typescript-eslint/eslint-plugin": "^8.19.0",
+ "@typescript-eslint/parser": "^8.19.0",
+ "eslint": "^9.17.0",
+ "typescript": "^5.7.2",
+ "typescript-eslint": "^8.19.0"
+ }
+}
diff --git a/packages/runpane/scripts/mark-executable.js b/packages/runpane/scripts/mark-executable.js
new file mode 100644
index 00000000..71db847e
--- /dev/null
+++ b/packages/runpane/scripts/mark-executable.js
@@ -0,0 +1,8 @@
+const fs = require('fs');
+const path = require('path');
+
+const cliPath = path.resolve(__dirname, '..', 'dist', 'cli.js');
+
+if (fs.existsSync(cliPath)) {
+ fs.chmodSync(cliPath, 0o755);
+}
diff --git a/packages/runpane/src/cli.ts b/packages/runpane/src/cli.ts
new file mode 100644
index 00000000..e016e926
--- /dev/null
+++ b/packages/runpane/src/cli.ts
@@ -0,0 +1,102 @@
+#!/usr/bin/env node
+import { helpText, parseRunpaneArgs, type ParsedArgs } from './commands';
+import { downloadArtifact } from './download';
+import { runDoctor } from './doctor';
+import { installPaneArtifact, launchPaneClient, spawnPane } from './installers';
+import { detectPlatform } from './platform';
+import { resolveRelease } from './releases';
+import { printVersion } from './version';
+
+const SOURCE = 'npm' as const;
+
+async function main(argv: string[]): Promise {
+ const parsed = parseRunpaneArgs(argv);
+
+ if (parsed.command === 'help') {
+ console.log(helpText(parsed.helpTopic));
+ return 0;
+ }
+
+ if (parsed.command === 'version') {
+ return printVersion(parsed.panePath);
+ }
+
+ if (parsed.command === 'doctor') {
+ return runDoctor(parsed, SOURCE);
+ }
+
+ if (parsed.command === 'install' || parsed.command === 'update') {
+ return installOrUpdate(parsed);
+ }
+
+ console.log(helpText());
+ return 0;
+}
+
+async function installOrUpdate(parsed: ParsedArgs): Promise {
+ const target = parsed.command === 'update' ? 'client' : parsed.target;
+ const platform = detectPlatform();
+ const resolved = await resolveRelease({
+ version: parsed.paneVersion,
+ channel: parsed.channel,
+ source: SOURCE,
+ platform,
+ format: parsed.format,
+ target
+ });
+
+ if (parsed.dryRun) {
+ printDryRun(parsed, resolved.artifact.name, resolved.preferredDownloadUrl, resolved.fallbackDownloadUrl);
+ return 0;
+ }
+
+ const artifact = await downloadArtifact(resolved, parsed.downloadDir, parsed.verbose);
+ const installed = await installPaneArtifact(artifact, {
+ parsed,
+ platform,
+ format: resolved.format,
+ target
+ });
+
+ if (target === 'daemon') {
+ return spawnPane(installed.executablePath, ['--remote-setup', ...parsed.remoteSetupArgs]);
+ }
+
+ if (installed.installKind === 'installed') {
+ launchPaneClient(installed.executablePath);
+ }
+
+ console.log(`Pane ${installed.installKind === 'existing' ? 'found' : 'installed'}: ${installed.executablePath}`);
+ return 0;
+}
+
+function printDryRun(
+ parsed: ParsedArgs,
+ artifactName: string,
+ preferredDownloadUrl: string,
+ fallbackDownloadUrl: string
+): void {
+ const target = parsed.command === 'update' ? 'client' : parsed.target;
+ console.log('runpane dry run');
+ console.log(`Command: ${parsed.command}`);
+ console.log(`Target: ${target}`);
+ console.log(`Pane release: ${parsed.paneVersion}`);
+ console.log(`Channel: ${parsed.channel}`);
+ console.log(`Format: ${parsed.format}`);
+ console.log(`Artifact: ${artifactName}`);
+ console.log(`Preferred download: ${preferredDownloadUrl}`);
+ console.log(`GitHub fallback: ${fallbackDownloadUrl}`);
+ if (parsed.panePath) {
+ console.log(`Existing Pane path: ${parsed.panePath}`);
+ }
+ if (target === 'daemon') {
+ console.log(`Pane command: --remote-setup ${parsed.remoteSetupArgs.join(' ')}`.trim());
+ }
+}
+
+main(process.argv.slice(2)).then((code) => {
+ process.exitCode = code;
+}).catch((error) => {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exitCode = 1;
+});
diff --git a/packages/runpane/src/commands.ts b/packages/runpane/src/commands.ts
new file mode 100644
index 00000000..3122e478
--- /dev/null
+++ b/packages/runpane/src/commands.ts
@@ -0,0 +1,283 @@
+export type RunpaneCommand = 'help' | 'install' | 'update' | 'version' | 'doctor';
+export type InstallTarget = 'client' | 'daemon';
+export type ArtifactFormat = 'auto' | 'appimage' | 'deb' | 'dmg' | 'zip' | 'exe';
+
+export interface ParsedArgs {
+ command: RunpaneCommand;
+ helpTopic?: string;
+ target: InstallTarget;
+ paneVersion: string;
+ channel: 'stable' | 'nightly';
+ format: ArtifactFormat;
+ downloadDir?: string;
+ panePath?: string;
+ dryRun: boolean;
+ yes: boolean;
+ verbose: boolean;
+ remoteSetupArgs: string[];
+}
+
+const COMMANDS = new Set(['help', 'install', 'update', 'version', 'doctor']);
+const TARGETS = new Set(['client', 'daemon']);
+const FORMATS = new Set(['auto', 'appimage', 'deb', 'dmg', 'zip', 'exe']);
+const CHANNELS = new Set(['stable', 'nightly']);
+
+const REMOTE_VALUE_FLAGS = new Set([
+ '--label',
+ '--prefer-tunnel',
+ '--channel',
+ '--base-url',
+ '--pane-dir',
+ '--listen-port',
+ '--port',
+ '--repo-ref'
+]);
+
+const REMOTE_BOOLEAN_FLAGS = new Set([
+ '--auto-listen-port',
+ '--interactive-tailscale-setup',
+ '--no-install-service',
+ '--no-tailscale-serve',
+ '--print-only'
+]);
+
+const DEFAULTS: Omit = {
+ target: 'client',
+ paneVersion: 'latest',
+ channel: 'stable',
+ format: 'auto',
+ dryRun: false,
+ yes: false,
+ verbose: false,
+ remoteSetupArgs: []
+};
+
+export function parseRunpaneArgs(argv: string[]): ParsedArgs {
+ const args = [...argv];
+ const first = args.shift();
+
+ if (!first || first === '-h' || first === '--help') {
+ return { command: 'help', ...DEFAULTS };
+ }
+
+ if (first === '-v' || first === '--version') {
+ return { command: 'version', ...DEFAULTS };
+ }
+
+ if (!COMMANDS.has(first)) {
+ throw new Error(`Unknown command: ${first}\n\n${helpText()}`);
+ }
+
+ if (first === 'help') {
+ return {
+ command: 'help',
+ helpTopic: args[0],
+ ...DEFAULTS
+ };
+ }
+
+ const parsed: ParsedArgs = {
+ command: first as RunpaneCommand,
+ ...DEFAULTS,
+ remoteSetupArgs: []
+ };
+
+ if (parsed.command === 'install' && args[0] && !args[0].startsWith('-')) {
+ const target = args.shift();
+ if (!target || !TARGETS.has(target)) {
+ throw new Error(`Unknown install target: ${target ?? ''}. Expected "client" or "daemon".`);
+ }
+ parsed.target = target as InstallTarget;
+ }
+
+ if (parsed.command === 'update') {
+ parsed.target = 'client';
+ }
+
+ parseFlags(args, parsed);
+ return parsed;
+}
+
+function parseFlags(args: string[], parsed: ParsedArgs): void {
+ for (let index = 0; index < args.length; index++) {
+ const arg = args[index];
+
+ if (arg === '-h' || arg === '--help') {
+ const topic = parsed.command;
+ parsed.command = 'help';
+ parsed.helpTopic = topic;
+ continue;
+ }
+ if (arg === '--dry-run') {
+ parsed.dryRun = true;
+ continue;
+ }
+ if (arg === '--yes' || arg === '-y') {
+ parsed.yes = true;
+ continue;
+ }
+ if (arg === '--verbose') {
+ parsed.verbose = true;
+ continue;
+ }
+ if (arg === '--version') {
+ parsed.paneVersion = readValue(args, ++index, arg);
+ continue;
+ }
+ if (arg === '--download-dir') {
+ parsed.downloadDir = readValue(args, ++index, arg);
+ continue;
+ }
+ if (arg === '--pane-path') {
+ parsed.panePath = readValue(args, ++index, arg);
+ continue;
+ }
+ if (arg === '--format') {
+ const value = readValue(args, ++index, arg);
+ if (!FORMATS.has(value)) {
+ throw new Error(`Invalid --format "${value}". Expected one of: ${[...FORMATS].join(', ')}`);
+ }
+ parsed.format = value as ArtifactFormat;
+ continue;
+ }
+
+ if (REMOTE_VALUE_FLAGS.has(arg)) {
+ const value = readValue(args, ++index, arg);
+ if (arg === '--channel') {
+ if (!CHANNELS.has(value)) {
+ throw new Error(`Invalid --channel "${value}". Expected stable or nightly.`);
+ }
+ parsed.channel = value as 'stable' | 'nightly';
+ }
+ appendRemoteArg(parsed, arg, value);
+ continue;
+ }
+
+ if (REMOTE_BOOLEAN_FLAGS.has(arg)) {
+ appendRemoteArg(parsed, arg);
+ continue;
+ }
+
+ if (parsed.command === 'install' && parsed.target === 'daemon') {
+ index = appendUnknownRemoteArg(args, index, parsed, arg);
+ continue;
+ }
+
+ throw new Error(`Unknown option for ${parsed.command}: ${arg}`);
+ }
+}
+
+function appendRemoteArg(parsed: ParsedArgs, flag: string, value?: string): void {
+ if (parsed.command === 'install' && parsed.target === 'daemon') {
+ parsed.remoteSetupArgs.push(flag);
+ if (value !== undefined) {
+ parsed.remoteSetupArgs.push(value);
+ }
+ return;
+ }
+
+ if (REMOTE_VALUE_FLAGS.has(flag) || REMOTE_BOOLEAN_FLAGS.has(flag)) {
+ throw new Error(`${flag} is only valid with "runpane install daemon".`);
+ }
+}
+
+function appendUnknownRemoteArg(args: string[], index: number, parsed: ParsedArgs, arg: string): number {
+ parsed.remoteSetupArgs.push(arg);
+ const next = args[index + 1];
+ if (arg.startsWith('-') && next && !next.startsWith('-')) {
+ parsed.remoteSetupArgs.push(next);
+ return index + 1;
+ }
+ return index;
+}
+
+function readValue(args: string[], index: number, flag: string): string {
+ const value = args[index];
+ if (!value || value.startsWith('-')) {
+ throw new Error(`${flag} requires a value.`);
+ }
+ return value;
+}
+
+export function helpText(topic?: string): string {
+ if (topic === 'install') {
+ return [
+ 'Usage:',
+ ' runpane install [client|daemon] [options]',
+ '',
+ 'Examples:',
+ ' npx --yes runpane@latest install client',
+ ' npx --yes runpane@latest install daemon --label "My Server"',
+ ' pnpm dlx runpane@latest install daemon --prefer-tunnel ssh --label "VM"',
+ '',
+ 'Wrapper options:',
+ ' --version Pane release to install',
+ ' --format ',
+ ' --download-dir ',
+ ' --pane-path Use an existing Pane executable',
+ ' --dry-run Print the plan without downloading',
+ ' --yes Skip interactive prompts where possible',
+ ' --verbose',
+ '',
+ 'Daemon passthrough options:',
+ ' --label ',
+ ' --prefer-tunnel ',
+ ' --channel ',
+ ' --base-url ',
+ ' --pane-dir ',
+ ' --listen-port / --port ',
+ ' --auto-listen-port',
+ ' --interactive-tailscale-setup',
+ ' --no-install-service',
+ ' --no-tailscale-serve',
+ ' --print-only',
+ ' --repo-ref ]['
+ ].join('\n');
+ }
+
+ if (topic === 'update') {
+ return [
+ 'Usage:',
+ ' runpane update [options]',
+ '',
+ 'Updates Pane using the same artifact selection as "runpane install client".',
+ '',
+ 'Options:',
+ ' --version ',
+ ' --format ',
+ ' --download-dir ',
+ ' --pane-path ',
+ ' --dry-run',
+ ' --yes',
+ ' --verbose'
+ ].join('\n');
+ }
+
+ if (topic === 'version') {
+ return 'Usage:\n runpane version\n runpane --version';
+ }
+
+ if (topic === 'doctor') {
+ return 'Usage:\n runpane doctor [--pane-path ] [--format ] [--verbose]';
+ }
+
+ return [
+ 'Usage:',
+ ' runpane install [client|daemon] [options]',
+ ' runpane update [options]',
+ ' runpane version',
+ ' runpane doctor',
+ ' runpane help [command]',
+ '',
+ 'Package manager examples:',
+ ' npx --yes runpane@latest install daemon --label "My Server"',
+ ' pnpm dlx runpane@latest install daemon --label "My Server"',
+ ' npm i -g runpane && runpane install daemon --label "My Server"',
+ '',
+ 'Python package equivalents:',
+ ' pipx run runpane install daemon --label "My Server"',
+ ' uvx runpane@latest install daemon --label "My Server"',
+ '',
+ 'Run "runpane help install" for install options.'
+ ].join('\n');
+}
diff --git a/packages/runpane/src/doctor.ts b/packages/runpane/src/doctor.ts
new file mode 100644
index 00000000..9219c650
--- /dev/null
+++ b/packages/runpane/src/doctor.ts
@@ -0,0 +1,41 @@
+import type { ParsedArgs } from './commands';
+import { resolveExistingPanePath } from './installers';
+import { detectPlatform } from './platform';
+import { resolveRelease } from './releases';
+import { getPaneVersion } from './version';
+
+export async function runDoctor(parsed: ParsedArgs, source: 'npm' | 'pip' = 'npm'): Promise {
+ let ok = true;
+
+ try {
+ const platform = detectPlatform();
+ console.log(`Platform: ${platform.os}/${platform.arch}`);
+
+ const release = await resolveRelease({
+ version: parsed.paneVersion,
+ channel: parsed.channel,
+ source,
+ platform,
+ format: parsed.format,
+ target: 'client'
+ });
+ console.log(`Latest release: ${release.release.tag_name}`);
+ console.log(`Selected artifact: ${release.artifact.name}`);
+ console.log(`Website URL: ${release.preferredDownloadUrl}`);
+ console.log(`GitHub fallback: ${release.fallbackDownloadUrl}`);
+ } catch (error) {
+ ok = false;
+ console.error(`Release check: failed - ${error instanceof Error ? error.message : String(error)}`);
+ }
+
+ const installedPath = resolveExistingPanePath(parsed.panePath);
+ if (installedPath) {
+ console.log(`Installed Pane: ${installedPath}`);
+ console.log(`Installed version: ${getPaneVersion(installedPath) ?? 'unknown'}`);
+ } else {
+ console.log('Installed Pane: not found');
+ }
+
+ console.log('Remote setup: run "runpane install daemon --label " to configure a headless host.');
+ return ok ? 0 : 1;
+}
diff --git a/packages/runpane/src/download.ts b/packages/runpane/src/download.ts
new file mode 100644
index 00000000..79edbae6
--- /dev/null
+++ b/packages/runpane/src/download.ts
@@ -0,0 +1,94 @@
+import crypto from 'crypto';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import { artifactFileName, type ResolvedRelease } from './releases';
+
+export interface DownloadedArtifact {
+ path: string;
+ fileName: string;
+ usedFallback: boolean;
+}
+
+export async function downloadArtifact(
+ resolved: ResolvedRelease,
+ downloadDir?: string,
+ verbose = false
+): Promise {
+ const targetDir = downloadDir ?? path.join(os.tmpdir(), `runpane-${Date.now()}`);
+ fs.mkdirSync(targetDir, { recursive: true });
+
+ const fileName = artifactFileName(resolved.artifact.name);
+ const targetPath = path.join(targetDir, fileName);
+
+ let usedFallback = false;
+ try {
+ await downloadToFile(resolved.preferredDownloadUrl, targetPath, verbose);
+ } catch (error) {
+ usedFallback = true;
+ console.warn(`runpane: website download route failed; falling back to GitHub release asset. ${formatError(error)}`);
+ await downloadToFile(resolved.fallbackDownloadUrl, targetPath, verbose);
+ }
+
+ await verifyChecksumIfAvailable(resolved, targetPath, fileName);
+ return { path: targetPath, fileName, usedFallback };
+}
+
+async function downloadToFile(url: string, targetPath: string, verbose: boolean): Promise {
+ if (verbose) {
+ console.log(`Downloading ${url}`);
+ }
+
+ const response = await fetch(url, {
+ redirect: 'follow',
+ headers: { 'user-agent': 'runpane-installer' }
+ });
+
+ if (!response.ok || !response.body) {
+ throw new Error(`${response.status} ${response.statusText}`);
+ }
+
+ const buffer = Buffer.from(await response.arrayBuffer());
+ fs.writeFileSync(targetPath, buffer);
+}
+
+async function verifyChecksumIfAvailable(resolved: ResolvedRelease, artifactPath: string, fileName: string): Promise {
+ try {
+ const response = await fetch(resolved.checksumUrl, {
+ headers: { 'user-agent': 'runpane-installer' }
+ });
+ if (!response.ok) {
+ return;
+ }
+ const checksums = await response.text();
+ const expected = parseChecksum(checksums, fileName);
+ if (!expected) {
+ return;
+ }
+ const hash = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex');
+ if (hash.toLowerCase() !== expected.toLowerCase()) {
+ throw new Error(`Checksum mismatch for ${fileName}. Expected ${expected}, got ${hash}.`);
+ }
+ } catch (error) {
+ if (error instanceof Error && error.message.startsWith('Checksum mismatch')) {
+ throw error;
+ }
+ console.warn(`runpane: could not verify checksum for ${fileName}. ${formatError(error)}`);
+ }
+}
+
+function parseChecksum(checksums: string, fileName: string): string | undefined {
+ for (const line of checksums.split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed.endsWith(fileName)) continue;
+ const [hash] = trimmed.split(/\s+/);
+ if (/^[a-f0-9]{64}$/i.test(hash)) {
+ return hash;
+ }
+ }
+ return undefined;
+}
+
+function formatError(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
diff --git a/packages/runpane/src/installers.ts b/packages/runpane/src/installers.ts
new file mode 100644
index 00000000..39fc8b88
--- /dev/null
+++ b/packages/runpane/src/installers.ts
@@ -0,0 +1,149 @@
+import childProcess from 'child_process';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import type { ArtifactFormat, InstallTarget, ParsedArgs } from './commands';
+import type { DownloadedArtifact } from './download';
+import { defaultInstallRoot, type PanePlatform } from './platform';
+
+export interface InstalledPane {
+ executablePath: string;
+ installKind: 'existing' | 'installed' | 'launched-installer';
+}
+
+export function resolveExistingPanePath(panePath?: string): string | undefined {
+ if (panePath) {
+ return fs.existsSync(panePath) ? panePath : undefined;
+ }
+
+ const candidates = [
+ process.platform === 'darwin' ? '/Applications/Pane.app/Contents/MacOS/Pane' : undefined,
+ process.platform === 'darwin' ? path.join(os.homedir(), 'Applications', 'Pane.app', 'Contents', 'MacOS', 'Pane') : undefined,
+ process.platform === 'linux' ? path.join(os.homedir(), '.local', 'bin', 'pane') : undefined,
+ process.platform === 'linux' ? '/usr/bin/pane' : undefined,
+ process.platform === 'linux' ? '/opt/Pane/pane' : undefined,
+ process.platform === 'win32' && process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'Programs', 'Pane', 'Pane.exe') : undefined,
+ process.platform === 'win32' && process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'Pane', 'Pane.exe') : undefined,
+ process.platform === 'win32' && process.env.ProgramFiles ? path.join(process.env.ProgramFiles, 'Pane', 'Pane.exe') : undefined
+ ].filter((candidate): candidate is string => Boolean(candidate));
+
+ return candidates.find((candidate) => fs.existsSync(candidate));
+}
+
+export async function installPaneArtifact(
+ artifact: DownloadedArtifact,
+ options: {
+ parsed: ParsedArgs;
+ platform: PanePlatform;
+ format: Exclude;
+ target: InstallTarget;
+ }
+): Promise {
+ const existing = resolveExistingPanePath(options.parsed.panePath);
+ if (existing) {
+ return { executablePath: existing, installKind: 'existing' };
+ }
+
+ if (options.platform.os === 'darwin') {
+ return installMac(artifact, options);
+ }
+ if (options.platform.os === 'linux') {
+ return installLinux(artifact, options);
+ }
+ return installWindows(artifact, options);
+}
+
+export function spawnPane(executablePath: string, args: string[]): Promise {
+ return new Promise((resolve) => {
+ const child = childProcess.spawn(executablePath, args, {
+ stdio: 'inherit',
+ shell: false
+ });
+ child.on('close', (code) => resolve(code ?? 1));
+ child.on('error', (error) => {
+ console.error(`Failed to launch Pane: ${error.message}`);
+ resolve(1);
+ });
+ });
+}
+
+export function launchPaneClient(executablePath: string): void {
+ const child = childProcess.spawn(executablePath, [], {
+ detached: true,
+ stdio: 'ignore'
+ });
+ child.unref();
+}
+
+function installMac(
+ artifact: DownloadedArtifact,
+ { format, target }: { format: Exclude; target: InstallTarget }
+): InstalledPane {
+ if (format === 'dmg') {
+ childProcess.spawnSync('open', [artifact.path], { stdio: 'inherit' });
+ return { executablePath: '/Applications/Pane.app/Contents/MacOS/Pane', installKind: 'launched-installer' };
+ }
+
+ const appsRoot = path.join(os.homedir(), 'Applications');
+ const appPath = path.join(appsRoot, 'Pane.app');
+ fs.mkdirSync(appsRoot, { recursive: true });
+ childProcess.execFileSync('ditto', ['-x', '-k', artifact.path, appsRoot], { stdio: 'inherit' });
+ const executablePath = path.join(appPath, 'Contents', 'MacOS', 'Pane');
+
+ if (!fs.existsSync(executablePath)) {
+ throw new Error(`Pane executable was not found after extracting ${artifact.fileName}. Expected ${executablePath}`);
+ }
+
+ if (target === 'client') {
+ childProcess.spawnSync('open', [appPath], { stdio: 'inherit' });
+ }
+
+ return { executablePath, installKind: 'installed' };
+}
+
+function installLinux(
+ artifact: DownloadedArtifact,
+ { format }: { format: Exclude }
+): InstalledPane {
+ if (format === 'deb') {
+ const installer = commandExists('apt') ? 'apt' : 'dpkg';
+ const args = installer === 'apt' ? ['install', '-y', artifact.path] : ['-i', artifact.path];
+ childProcess.spawnSync('sudo', [installer, ...args], { stdio: 'inherit' });
+ const executablePath = resolveExistingPanePath();
+ if (!executablePath) {
+ throw new Error('Pane installed from .deb, but the pane executable could not be found.');
+ }
+ return { executablePath, installKind: 'installed' };
+ }
+
+ const binRoot = defaultInstallRoot();
+ const executablePath = path.join(binRoot, 'pane');
+ fs.mkdirSync(binRoot, { recursive: true });
+ fs.copyFileSync(artifact.path, executablePath);
+ fs.chmodSync(executablePath, 0o755);
+ return { executablePath, installKind: 'installed' };
+}
+
+function installWindows(
+ artifact: DownloadedArtifact,
+ { target }: { target: InstallTarget }
+): InstalledPane {
+ const args = target === 'daemon' ? ['/S'] : [];
+ const result = childProcess.spawnSync(artifact.path, args, { stdio: 'inherit' });
+ if (result.error) {
+ throw result.error;
+ }
+
+ const executablePath = resolveExistingPanePath();
+ if (!executablePath) {
+ throw new Error('Pane installer completed, but Pane.exe could not be found. Open the installer manually and rerun with --pane-path.');
+ }
+ return { executablePath, installKind: target === 'daemon' ? 'installed' : 'launched-installer' };
+}
+
+function commandExists(command: string): boolean {
+ const check = process.platform === 'win32' ? 'where' : 'command';
+ const args = process.platform === 'win32' ? [command] : ['-v', command];
+ const result = childProcess.spawnSync(check, args, { stdio: 'ignore', shell: process.platform !== 'win32' });
+ return result.status === 0;
+}
diff --git a/packages/runpane/src/platform.ts b/packages/runpane/src/platform.ts
new file mode 100644
index 00000000..a3e40243
--- /dev/null
+++ b/packages/runpane/src/platform.ts
@@ -0,0 +1,61 @@
+import os from 'os';
+import type { ArtifactFormat } from './commands';
+
+export interface PanePlatform {
+ os: 'darwin' | 'linux' | 'win32';
+ arch: 'x64' | 'arm64';
+}
+
+export function detectPlatform(): PanePlatform {
+ const platform = process.platform;
+ const arch = process.arch;
+
+ if (platform !== 'darwin' && platform !== 'linux' && platform !== 'win32') {
+ throw new Error(`Unsupported OS: ${platform}`);
+ }
+ if (arch !== 'x64' && arch !== 'arm64') {
+ throw new Error(`Unsupported CPU architecture: ${arch}`);
+ }
+
+ return { os: platform, arch };
+}
+
+export function defaultFormat(platform: PanePlatform, target: 'client' | 'daemon'): Exclude {
+ if (platform.os === 'darwin') {
+ return target === 'daemon' ? 'zip' : 'dmg';
+ }
+ if (platform.os === 'win32') {
+ return 'exe';
+ }
+ return 'appimage';
+}
+
+export function platformParam(platform: PanePlatform): 'mac' | 'linux' | 'windows' {
+ if (platform.os === 'darwin') return 'mac';
+ if (platform.os === 'win32') return 'windows';
+ return 'linux';
+}
+
+export function archAliases(platform: PanePlatform): string[] {
+ if (platform.arch === 'arm64') {
+ return ['arm64', 'aarch64'];
+ }
+ if (platform.os === 'linux') {
+ return ['x64', 'x86_64', 'amd64'];
+ }
+ return ['x64', 'x86_64'];
+}
+
+export function defaultInstallRoot(): string {
+ if (process.platform === 'win32') {
+ return process.env.LOCALAPPDATA
+ ? `${process.env.LOCALAPPDATA}\\Pane`
+ : `${os.homedir()}\\AppData\\Local\\Pane`;
+ }
+
+ if (process.platform === 'darwin') {
+ return `${os.homedir()}/Applications`;
+ }
+
+ return `${os.homedir()}/.local/bin`;
+}
diff --git a/packages/runpane/src/releases.ts b/packages/runpane/src/releases.ts
new file mode 100644
index 00000000..7411baa4
--- /dev/null
+++ b/packages/runpane/src/releases.ts
@@ -0,0 +1,132 @@
+import path from 'path';
+import type { ArtifactFormat } from './commands';
+import { archAliases, defaultFormat, platformParam, type PanePlatform } from './platform';
+
+const GITHUB_API_BASE = 'https://api.github.com/repos/dcouple/Pane/releases';
+const DOWNLOAD_API_BASE = 'https://runpane.com/api/download';
+
+export interface GitHubReleaseAsset {
+ name: string;
+ browser_download_url: string;
+}
+
+export interface GitHubRelease {
+ tag_name: string;
+ name: string;
+ body: string;
+ html_url: string;
+ published_at: string;
+ prerelease: boolean;
+ draft: boolean;
+ assets?: GitHubReleaseAsset[];
+}
+
+export interface ResolvedRelease {
+ release: GitHubRelease;
+ artifact: GitHubReleaseAsset;
+ format: Exclude;
+ preferredDownloadUrl: string;
+ fallbackDownloadUrl: string;
+ checksumUrl: string;
+}
+
+export interface ResolveReleaseOptions {
+ version: string;
+ channel: 'stable' | 'nightly';
+ source: 'npm' | 'pip';
+ platform: PanePlatform;
+ format: ArtifactFormat;
+ target: 'client' | 'daemon';
+}
+
+export async function resolveRelease(options: ResolveReleaseOptions): Promise {
+ const release = await fetchRelease(options.version);
+ const format = options.format === 'auto' ? defaultFormat(options.platform, options.target) : options.format;
+ const artifact = findArtifact(release, options.platform, format);
+ const preferredDownloadUrl = buildPreferredDownloadUrl(options, format);
+
+ return {
+ release,
+ artifact,
+ format,
+ preferredDownloadUrl,
+ fallbackDownloadUrl: artifact.browser_download_url,
+ checksumUrl: `https://github.com/dcouple/Pane/releases/download/${release.tag_name}/SHA256SUMS.txt`
+ };
+}
+
+export async function fetchRelease(version: string): Promise {
+ const normalized = version === 'latest' ? 'latest' : version.startsWith('v') ? `tags/${version}` : `tags/v${version}`;
+ const response = await fetch(`${GITHUB_API_BASE}/${normalized}`, {
+ headers: {
+ accept: 'application/vnd.github+json',
+ 'user-agent': 'runpane-installer'
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch Pane release ${version}: ${response.status} ${response.statusText}`);
+ }
+
+ const release = await response.json() as GitHubRelease;
+ if (release.draft || release.prerelease) {
+ throw new Error(`Release ${release.tag_name} is not a stable public release.`);
+ }
+ return release;
+}
+
+export function findArtifact(
+ release: GitHubRelease,
+ platform: PanePlatform,
+ format: Exclude
+): GitHubReleaseAsset {
+ const assets = release.assets ?? [];
+ const aliases = archAliases(platform);
+ const candidates = assets.filter((asset) => matchesFormat(asset.name, format) && matchesPlatform(asset.name, platform));
+ const exact = candidates.find((asset) => aliases.some((alias) => lowerName(asset).includes(alias.toLowerCase())));
+ const universal = candidates.find((asset) => lowerName(asset).includes('universal'));
+ const selected = exact ?? universal ?? candidates[0];
+
+ if (!selected) {
+ const assetNames = assets.map((asset) => asset.name).join(', ') || 'no assets';
+ throw new Error(`No Pane ${format} asset found for ${platform.os}/${platform.arch} in ${release.tag_name}. Assets: ${assetNames}`);
+ }
+
+ return selected;
+}
+
+export function artifactFileName(urlOrName: string): string {
+ return path.basename(urlOrName.split('?')[0]);
+}
+
+function buildPreferredDownloadUrl(
+ options: ResolveReleaseOptions,
+ format: Exclude
+): string {
+ const params = new URLSearchParams({
+ platform: platformParam(options.platform),
+ arch: options.platform.arch,
+ format,
+ version: options.version === 'latest' ? 'latest' : options.version.startsWith('v') ? options.version : `v${options.version}`,
+ channel: options.channel,
+ source: options.source
+ });
+ return `${DOWNLOAD_API_BASE}?${params.toString()}`;
+}
+
+function matchesFormat(name: string, format: Exclude): boolean {
+ const lower = name.toLowerCase();
+ if (format === 'appimage') return lower.endsWith('.appimage');
+ return lower.endsWith(`.${format}`);
+}
+
+function matchesPlatform(name: string, platform: PanePlatform): boolean {
+ const lower = name.toLowerCase();
+ if (platform.os === 'darwin') return lower.includes('macos') || lower.includes('darwin') || lower.includes('mac');
+ if (platform.os === 'win32') return lower.includes('windows') || lower.includes('win');
+ return lower.includes('linux');
+}
+
+function lowerName(asset: GitHubReleaseAsset): string {
+ return asset.name.toLowerCase();
+}
diff --git a/packages/runpane/src/version.ts b/packages/runpane/src/version.ts
new file mode 100644
index 00000000..56322a29
--- /dev/null
+++ b/packages/runpane/src/version.ts
@@ -0,0 +1,53 @@
+import childProcess from 'child_process';
+import fs from 'fs';
+import path from 'path';
+import { fetchRelease } from './releases';
+import { resolveExistingPanePath } from './installers';
+
+export function getWrapperVersion(): string {
+ const packagePath = path.resolve(__dirname, '..', 'package.json');
+ try {
+ const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')) as { version?: string };
+ return pkg.version ?? 'unknown';
+ } catch {
+ return 'unknown';
+ }
+}
+
+export async function printVersion(panePath?: string): Promise {
+ const wrapperVersion = getWrapperVersion();
+ const installedPath = resolveExistingPanePath(panePath);
+ const installedVersion = installedPath ? getPaneVersion(installedPath) : undefined;
+ let latest = 'unavailable';
+
+ try {
+ latest = normalizeVersion((await fetchRelease('latest')).tag_name);
+ } catch {
+ // Keep version output useful when offline.
+ }
+
+ console.log(`runpane ${wrapperVersion}`);
+ console.log(`Pane installed: ${installedVersion ?? 'not found'}`);
+ console.log(`Pane latest: ${latest}`);
+ if (installedPath) {
+ console.log(`Pane path: ${installedPath}`);
+ }
+ return 0;
+}
+
+export function getPaneVersion(executablePath: string): string | undefined {
+ try {
+ const result = childProcess.spawnSync(executablePath, ['--version'], {
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'pipe']
+ });
+ const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
+ return output || undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+function normalizeVersion(version: string): string {
+ return version.replace(/^v/, '');
+}
diff --git a/packages/runpane/tsconfig.json b/packages/runpane/tsconfig.json
new file mode 100644
index 00000000..72f3f184
--- /dev/null
+++ b/packages/runpane/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "commonjs",
+ "lib": ["ES2022", "DOM"],
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "moduleResolution": "node",
+ "allowSyntheticDefaultImports": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "types": ["node"]
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist", "src/**/__tests__/**", "src/**/*.test.ts"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cb675c40..fe1ae386 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -369,6 +369,30 @@ importers:
specifier: ^2.1.8
version: 2.1.9(@types/node@22.16.5)
+ packages/runpane:
+ devDependencies:
+ '@eslint/js':
+ specifier: ^9.17.0
+ version: 9.31.0
+ '@types/node':
+ specifier: ^22.10.2
+ version: 22.16.5
+ '@typescript-eslint/eslint-plugin':
+ specifier: ^8.19.0
+ version: 8.37.0(@typescript-eslint/parser@8.37.0(eslint@9.31.0(jiti@2.6.0))(typescript@5.8.3))(eslint@9.31.0(jiti@2.6.0))(typescript@5.8.3)
+ '@typescript-eslint/parser':
+ specifier: ^8.19.0
+ version: 8.37.0(eslint@9.31.0(jiti@2.6.0))(typescript@5.8.3)
+ eslint:
+ specifier: ^9.17.0
+ version: 9.31.0(jiti@2.6.0)
+ typescript:
+ specifier: ^5.7.2
+ version: 5.8.3
+ typescript-eslint:
+ specifier: ^8.19.0
+ version: 8.37.0(eslint@9.31.0(jiti@2.6.0))(typescript@5.8.3)
+
shared: {}
packages:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index abed606c..c54bd8b8 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,5 +1,6 @@
packages:
- 'frontend'
- 'main'
+ - 'packages/runpane'
- 'shared'
minimumReleaseAge: 10080
diff --git a/scripts/release.js b/scripts/release.js
index b9d851d6..e2e9be91 100644
--- a/scripts/release.js
+++ b/scripts/release.js
@@ -13,6 +13,12 @@ const path = require('path');
const rootDir = path.resolve(__dirname, '..');
const pkgPath = path.join(rootDir, 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
+const versionedPackageFiles = [
+ 'package.json',
+ 'packages/runpane/package.json',
+ 'packages/runpane-py/pyproject.toml',
+ 'packages/runpane-py/src/runpane/__init__.py',
+];
function run(command, options = {}) {
const result = execSync(command, {
@@ -37,6 +43,41 @@ function getHeadPackageVersion() {
}
}
+function getHeadFile(filePath) {
+ try {
+ return run(`git show HEAD:${filePath}`);
+ } catch {
+ console.error(`Unable to read ${filePath} from HEAD. Aborting before tag.`);
+ process.exit(1);
+ }
+}
+
+function verifyHeadPackageVersions(expectedVersion) {
+ const rootPackage = JSON.parse(getHeadFile('package.json'));
+ const npmPackage = JSON.parse(getHeadFile('packages/runpane/package.json'));
+ const pyproject = getHeadFile('packages/runpane-py/pyproject.toml');
+ const pyInit = getHeadFile('packages/runpane-py/src/runpane/__init__.py');
+
+ const pyprojectMatch = /^version = "([^"]+)"/m.exec(pyproject);
+ const pyInitMatch = /^__version__ = "([^"]+)"/m.exec(pyInit);
+ const versions = {
+ 'package.json': rootPackage.version,
+ 'packages/runpane/package.json': npmPackage.version,
+ 'packages/runpane-py/pyproject.toml': pyprojectMatch?.[1],
+ 'packages/runpane-py/src/runpane/__init__.py': pyInitMatch?.[1],
+ };
+
+ const mismatches = Object.entries(versions)
+ .filter(([, version]) => version !== expectedVersion)
+ .map(([filePath, version]) => `${filePath}: ${version || 'missing'}`);
+
+ if (mismatches.length > 0) {
+ console.error(`HEAD package versions are not all ${expectedVersion}. Aborting before tag.`);
+ mismatches.forEach((mismatch) => console.error(` ${mismatch}`));
+ process.exit(1);
+ }
+}
+
function parseSemver(version) {
const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(version.trim());
if (!match) {
@@ -183,12 +224,11 @@ try {
// Tag does not exist remotely.
}
-// Update version
-pkg.version = cleanVersion;
-fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
+// Update version metadata for the desktop app and wrapper packages.
+run(`node scripts/sync-runpane-package-versions.js ${cleanVersion}`, { stdio: 'inherit' });
// Commit, tag, push
-run('git add package.json', { stdio: 'inherit' });
+run(`git add ${versionedPackageFiles.join(' ')}`, { stdio: 'inherit' });
try {
run(`git commit -m "release: v${cleanVersion}"`, { stdio: 'inherit' });
} catch {
@@ -209,6 +249,7 @@ if (headPackageVersion !== cleanVersion) {
console.error('The release tag must point at a commit that contains the released package.json version.');
process.exit(1);
}
+verifyHeadPackageVersions(cleanVersion);
run(`git tag ${tagName}`, { stdio: 'inherit' });
run('git push origin HEAD:main', { stdio: 'inherit' });
run(`git push origin ${tagName}`, { stdio: 'inherit' });
diff --git a/scripts/sync-runpane-package-versions.js b/scripts/sync-runpane-package-versions.js
new file mode 100644
index 00000000..593d7b22
--- /dev/null
+++ b/scripts/sync-runpane-package-versions.js
@@ -0,0 +1,85 @@
+#!/usr/bin/env node
+const fs = require('fs');
+const path = require('path');
+
+const rootDir = path.resolve(__dirname, '..');
+const args = process.argv.slice(2);
+const checkMode = args[0] === '--check';
+const requestedVersion = checkMode ? undefined : args[0];
+
+if (!checkMode && (!requestedVersion || !/^\d+\.\d+\.\d+$/.test(requestedVersion))) {
+ console.error('Usage: node scripts/sync-runpane-package-versions.js ');
+ console.error(' or: node scripts/sync-runpane-package-versions.js --check');
+ process.exit(1);
+}
+
+const files = {
+ rootPackage: path.join(rootDir, 'package.json'),
+ npmPackage: path.join(rootDir, 'packages', 'runpane', 'package.json'),
+ pyproject: path.join(rootDir, 'packages', 'runpane-py', 'pyproject.toml'),
+ pyInit: path.join(rootDir, 'packages', 'runpane-py', 'src', 'runpane', '__init__.py')
+};
+
+function readJsonVersion(filePath) {
+ return JSON.parse(fs.readFileSync(filePath, 'utf8')).version;
+}
+
+function readVersionOrFail(filePath, pattern) {
+ const content = fs.readFileSync(filePath, 'utf8');
+ const match = content.match(pattern);
+ if (!match) {
+ throw new Error(`Could not read version in ${path.relative(rootDir, filePath)}`);
+ }
+ return match[1];
+}
+
+function updateJsonVersion(filePath) {
+ const pkg = JSON.parse(fs.readFileSync(filePath, 'utf8'));
+ pkg.version = requestedVersion;
+ fs.writeFileSync(filePath, JSON.stringify(pkg, null, 2) + '\n');
+}
+
+function replaceOrFail(filePath, pattern, replacement) {
+ const content = fs.readFileSync(filePath, 'utf8');
+ if (!pattern.test(content)) {
+ throw new Error(`Could not update version in ${path.relative(rootDir, filePath)}`);
+ }
+ const next = content.replace(pattern, replacement);
+ fs.writeFileSync(filePath, next);
+}
+
+function checkVersions() {
+ const versions = {
+ 'package.json': readJsonVersion(files.rootPackage),
+ 'packages/runpane/package.json': readJsonVersion(files.npmPackage),
+ 'packages/runpane-py/pyproject.toml': readVersionOrFail(files.pyproject, /^version = "([^"]+)"/m),
+ 'packages/runpane-py/src/runpane/__init__.py': readVersionOrFail(files.pyInit, /^__version__ = "([^"]+)"/m)
+ };
+ const expected = versions['package.json'];
+ const mismatches = Object.entries(versions).filter(([, value]) => value !== expected);
+
+ if (mismatches.length > 0) {
+ console.error(`runpane package versions are out of sync with package.json (${expected}):`);
+ for (const [filePath, value] of mismatches) {
+ console.error(` ${filePath}: ${value}`);
+ }
+ process.exit(1);
+ }
+
+ console.log(`runpane package versions are in sync at ${expected}`);
+}
+
+try {
+ if (checkMode) {
+ checkVersions();
+ } else {
+ updateJsonVersion(files.rootPackage);
+ updateJsonVersion(files.npmPackage);
+ replaceOrFail(files.pyproject, /^version = "[^"]+"/m, `version = "${requestedVersion}"`);
+ replaceOrFail(files.pyInit, /^__version__ = "[^"]+"/m, `__version__ = "${requestedVersion}"`);
+ console.log(`Synced runpane package versions to ${requestedVersion}`);
+ }
+} catch (error) {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exit(1);
+}
diff --git a/scripts/test-runpane-contract.js b/scripts/test-runpane-contract.js
new file mode 100644
index 00000000..ff2a7907
--- /dev/null
+++ b/scripts/test-runpane-contract.js
@@ -0,0 +1,272 @@
+#!/usr/bin/env node
+const assert = require('assert');
+const childProcess = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+const rootDir = path.resolve(__dirname, '..');
+const npmCli = path.join(rootDir, 'packages', 'runpane', 'dist', 'cli.js');
+const pythonSource = path.join(rootDir, 'packages', 'runpane-py', 'src');
+
+const parserSamples = [
+ ['install'],
+ ['install', 'client', '--version', 'v2.2.8', '--format', 'dmg', '--download-dir', '/tmp/pane-downloads', '--dry-run', '--yes'],
+ [
+ 'install',
+ 'daemon',
+ '--label',
+ 'VM',
+ '--prefer-tunnel',
+ 'ssh',
+ '--channel',
+ 'nightly',
+ '--base-url',
+ 'https://example.test',
+ '--pane-dir',
+ '/tmp/pane',
+ '--listen-port',
+ '4555',
+ '--auto-listen-port',
+ '--print-only',
+ '--repo-ref',
+ 'main',
+ '--unknown-future-flag',
+ 'future-value',
+ '--dry-run',
+ '--verbose'
+ ],
+ ['update', '--version', 'latest', '--format', 'appimage', '--pane-path', '/usr/bin/pane', '--dry-run'],
+ ['doctor', '--pane-path', '/usr/bin/pane', '--format', 'zip', '--verbose'],
+ ['--version']
+];
+
+const platformCases = [
+ { platform: { os: 'darwin', arch: 'arm64' }, target: 'client' },
+ { platform: { os: 'darwin', arch: 'arm64' }, target: 'daemon' },
+ { platform: { os: 'linux', arch: 'x64' }, target: 'client' },
+ { platform: { os: 'linux', arch: 'arm64' }, target: 'daemon' },
+ { platform: { os: 'win32', arch: 'x64' }, target: 'client' },
+ { platform: { os: 'win32', arch: 'arm64' }, target: 'daemon' }
+];
+
+const artifactRelease = {
+ tag_name: 'v2.2.8',
+ name: 'v2.2.8',
+ body: '',
+ html_url: 'https://github.com/dcouple/Pane/releases/tag/v2.2.8',
+ published_at: '2026-01-01T00:00:00Z',
+ prerelease: false,
+ draft: false,
+ assets: [
+ { name: 'Pane-2.2.8-linux-x86_64.AppImage', browser_download_url: 'https://example.test/linux-x64.AppImage' },
+ { name: 'Pane-2.2.8-linux-arm64.AppImage', browser_download_url: 'https://example.test/linux-arm64.AppImage' },
+ { name: 'Pane-2.2.8-linux-x86_64.deb', browser_download_url: 'https://example.test/linux-x64.deb' },
+ { name: 'Pane-2.2.8-linux-arm64.deb', browser_download_url: 'https://example.test/linux-arm64.deb' },
+ { name: 'Pane-2.2.8-macOS-universal.dmg', browser_download_url: 'https://example.test/macos.dmg' },
+ { name: 'Pane-2.2.8-macOS-universal.zip', browser_download_url: 'https://example.test/macos.zip' },
+ { name: 'Pane-2.2.8-Windows-x64.exe', browser_download_url: 'https://example.test/win-x64.exe' },
+ { name: 'Pane-2.2.8-Windows-arm64.exe', browser_download_url: 'https://example.test/win-arm64.exe' }
+ ]
+};
+
+const artifactCases = [
+ { platform: { os: 'linux', arch: 'x64' }, format: 'appimage' },
+ { platform: { os: 'linux', arch: 'arm64' }, format: 'appimage' },
+ { platform: { os: 'linux', arch: 'x64' }, format: 'deb' },
+ { platform: { os: 'darwin', arch: 'arm64' }, format: 'dmg' },
+ { platform: { os: 'darwin', arch: 'x64' }, format: 'zip' },
+ { platform: { os: 'win32', arch: 'x64' }, format: 'exe' },
+ { platform: { os: 'win32', arch: 'arm64' }, format: 'exe' }
+];
+
+function ensureBuiltCli() {
+ if (!fs.existsSync(npmCli)) {
+ throw new Error('packages/runpane/dist/cli.js is missing. Run "pnpm --filter runpane build" first.');
+ }
+}
+
+function findPython() {
+ for (const command of [process.env.PYTHON, 'python3', 'python'].filter(Boolean)) {
+ try {
+ childProcess.execFileSync(command, ['--version'], { stdio: 'ignore' });
+ return command;
+ } catch {
+ // Try the next candidate.
+ }
+ }
+ throw new Error('Could not find a Python executable. Set PYTHON to override.');
+}
+
+function runPythonSnippet(source, input) {
+ return childProcess.execFileSync(findPython(), ['-c', source], {
+ cwd: rootDir,
+ encoding: 'utf8',
+ input,
+ env: {
+ ...process.env,
+ PYTHONPATH: pythonSource
+ }
+ }).trim();
+}
+
+function assertIncludes(text, expected) {
+ assert.ok(text.includes(expected), `Expected output to include: ${expected}`);
+}
+
+function compareParserParity() {
+ const { parseRunpaneArgs } = require(path.join(rootDir, 'packages', 'runpane', 'dist', 'commands.js'));
+ const nodeOutput = parserSamples.map((args) => {
+ const parsed = parseRunpaneArgs(args);
+ return {
+ command: parsed.command,
+ helpTopic: parsed.helpTopic ?? null,
+ target: parsed.target,
+ paneVersion: parsed.paneVersion,
+ channel: parsed.channel,
+ format: parsed.format,
+ downloadDir: parsed.downloadDir ?? null,
+ panePath: parsed.panePath ?? null,
+ dryRun: parsed.dryRun,
+ yes: parsed.yes,
+ verbose: parsed.verbose,
+ remoteSetupArgs: parsed.remoteSetupArgs
+ };
+ });
+
+ const pythonOutput = runPythonSnippet(`
+import json
+import sys
+from runpane.cli import parse_args
+
+samples = json.loads(sys.stdin.read())
+normalized = []
+for args in samples:
+ parsed = parse_args(args)
+ normalized.append({
+ "command": parsed.command,
+ "helpTopic": parsed.help_topic,
+ "target": parsed.target,
+ "paneVersion": parsed.pane_version,
+ "channel": parsed.channel,
+ "format": parsed.format,
+ "downloadDir": parsed.download_dir,
+ "panePath": parsed.pane_path,
+ "dryRun": parsed.dry_run,
+ "yes": parsed.yes,
+ "verbose": parsed.verbose,
+ "remoteSetupArgs": parsed.remote_setup_args,
+ })
+print(json.dumps(normalized))
+`, JSON.stringify(parserSamples));
+
+ assert.deepStrictEqual(JSON.parse(pythonOutput), nodeOutput);
+}
+
+function comparePlatformParity() {
+ const { archAliases, defaultFormat, platformParam } = require(path.join(rootDir, 'packages', 'runpane', 'dist', 'platform.js'));
+ const nodeOutput = platformCases.map(({ platform, target }) => ({
+ platform,
+ target,
+ defaultFormat: defaultFormat(platform, target),
+ platformParam: platformParam(platform),
+ archAliases: archAliases(platform)
+ }));
+
+ const pythonOutput = runPythonSnippet(`
+import json
+import sys
+from runpane.platforms import PanePlatform, arch_aliases, default_format, platform_param
+
+cases = json.loads(sys.stdin.read())
+normalized = []
+for case in cases:
+ platform = PanePlatform(**case["platform"])
+ normalized.append({
+ "platform": case["platform"],
+ "target": case["target"],
+ "defaultFormat": default_format(platform, case["target"]),
+ "platformParam": platform_param(platform),
+ "archAliases": arch_aliases(platform),
+ })
+print(json.dumps(normalized))
+`, JSON.stringify(platformCases));
+
+ assert.deepStrictEqual(JSON.parse(pythonOutput), nodeOutput);
+}
+
+function compareArtifactSelectionParity() {
+ const { findArtifact } = require(path.join(rootDir, 'packages', 'runpane', 'dist', 'releases.js'));
+ const nodeOutput = artifactCases.map(({ platform, format }) => ({
+ platform,
+ format,
+ artifact: findArtifact(artifactRelease, platform, format).name
+ }));
+
+ const pythonOutput = runPythonSnippet(`
+import json
+import sys
+from runpane.platforms import PanePlatform
+from runpane.releases import find_artifact
+
+payload = json.loads(sys.stdin.read())
+release = payload["release"]
+cases = payload["cases"]
+normalized = []
+for case in cases:
+ platform = PanePlatform(**case["platform"])
+ normalized.append({
+ "platform": case["platform"],
+ "format": case["format"],
+ "artifact": find_artifact(release, platform, case["format"])["name"],
+ })
+print(json.dumps(normalized))
+`, JSON.stringify({ release: artifactRelease, cases: artifactCases }));
+
+ assert.deepStrictEqual(JSON.parse(pythonOutput), nodeOutput);
+}
+
+function checkHelpOutput() {
+ const python = findPython();
+ const pythonEnv = {
+ ...process.env,
+ PYTHONPATH: pythonSource
+ };
+ const nodeHelp = childProcess.execFileSync(process.execPath, [npmCli, '--help'], { encoding: 'utf8' });
+ const nodeInstallHelp = childProcess.execFileSync(process.execPath, [npmCli, 'help', 'install'], { encoding: 'utf8' });
+ const pyHelp = childProcess.execFileSync(python, ['-m', 'runpane', '--help'], { encoding: 'utf8', env: pythonEnv, cwd: rootDir });
+ const pyInstallHelp = childProcess.execFileSync(python, ['-m', 'runpane', 'help', 'install'], {
+ encoding: 'utf8',
+ env: pythonEnv,
+ cwd: rootDir
+ });
+
+ for (const output of [nodeHelp, pyHelp]) {
+ for (const text of ['runpane install', 'runpane update', 'runpane version', 'runpane doctor']) {
+ assertIncludes(output, text);
+ }
+ }
+
+ assertIncludes(nodeHelp, 'pnpm dlx runpane@latest');
+ assertIncludes(pyHelp, 'pipx run runpane');
+
+ for (const output of [nodeInstallHelp, pyInstallHelp]) {
+ for (const text of [
+ '--version ',
+ '--format ',
+ '--download-dir ',
+ '--pane-path ',
+ '--label ',
+ '--prefer-tunnel ',
+ '--repo-ref ]['
+ ]) {
+ assertIncludes(output, text);
+ }
+ }
+}
+
+ensureBuiltCli();
+compareParserParity();
+comparePlatformParity();
+compareArtifactSelectionParity();
+checkHelpOutput();
+console.log('runpane CLI contract checks passed');
diff --git a/scripts/test-runpane-package-smoke.js b/scripts/test-runpane-package-smoke.js
new file mode 100644
index 00000000..cc5e6016
--- /dev/null
+++ b/scripts/test-runpane-package-smoke.js
@@ -0,0 +1,97 @@
+#!/usr/bin/env node
+const childProcess = require('child_process');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const rootDir = path.resolve(__dirname, '..');
+const packageDir = path.join(rootDir, 'packages', 'runpane');
+const pythonPackageDir = path.join(rootDir, 'packages', 'runpane-py');
+const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'runpane-package-smoke-'));
+
+function run(command, args, options = {}) {
+ const { env, ...execOptions } = options;
+ childProcess.execFileSync(command, args, {
+ cwd: rootDir,
+ stdio: 'inherit',
+ env: {
+ ...process.env,
+ PIP_DISABLE_PIP_VERSION_CHECK: '1',
+ ...env
+ },
+ ...execOptions
+ });
+}
+
+function findPython() {
+ for (const command of [process.env.PYTHON, 'python3', 'python'].filter(Boolean)) {
+ try {
+ childProcess.execFileSync(command, ['--version'], { stdio: 'ignore' });
+ return command;
+ } catch {
+ // Try the next candidate.
+ }
+ }
+ throw new Error('Could not find a Python executable. Set PYTHON to override.');
+}
+
+function venvPython(venvDir) {
+ return process.platform === 'win32'
+ ? path.join(venvDir, 'Scripts', 'python.exe')
+ : path.join(venvDir, 'bin', 'python');
+}
+
+function packageBin(installDir) {
+ return process.platform === 'win32'
+ ? path.join(installDir, 'node_modules', '.bin', 'runpane.cmd')
+ : path.join(installDir, 'node_modules', '.bin', 'runpane');
+}
+
+function packNpmPackage() {
+ run('pnpm', ['--filter', 'runpane', 'pack', '--pack-destination', tempDir]);
+ const tarball = fs.readdirSync(tempDir)
+ .filter((fileName) => /^runpane-\d+\.\d+\.\d+\.tgz$/.test(fileName))
+ .sort()
+ .pop();
+ if (!tarball) {
+ throw new Error(`Could not find runpane tarball in ${tempDir}`);
+ }
+ return path.join(tempDir, tarball);
+}
+
+function smokeNpmPackage(tarball) {
+ const npmInstallDir = path.join(tempDir, 'npm-install');
+ const pnpmInstallDir = path.join(tempDir, 'pnpm-install');
+ fs.mkdirSync(npmInstallDir);
+ fs.mkdirSync(pnpmInstallDir);
+
+ run('npx', ['--yes', '--package', tarball, 'runpane', '--help']);
+ run('pnpm', ['--package', tarball, 'dlx', 'runpane', '--help']);
+
+ run('npm', ['install', '--prefix', npmInstallDir, tarball]);
+ run(packageBin(npmInstallDir), ['--help']);
+
+ run('pnpm', ['--dir', pnpmInstallDir, 'add', tarball]);
+ run(packageBin(pnpmInstallDir), ['--help']);
+}
+
+function smokePythonPackage() {
+ const python = findPython();
+ const venvDir = path.join(tempDir, 'venv');
+ run(python, ['-m', 'venv', venvDir]);
+ const isolatedPython = venvPython(venvDir);
+ run(isolatedPython, ['-m', 'pip', 'install', pythonPackageDir]);
+ run(isolatedPython, ['-m', 'runpane', '--help']);
+}
+
+try {
+ if (!fs.existsSync(path.join(packageDir, 'dist', 'cli.js'))) {
+ throw new Error('packages/runpane/dist/cli.js is missing. Run "pnpm --filter runpane build" first.');
+ }
+ const tarball = packNpmPackage();
+ smokeNpmPackage(tarball);
+ smokePythonPackage();
+ console.log('runpane package smoke checks passed');
+} finally {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+}
From f655ef05f68e7f566759c8061f6243420e041486 Mon Sep 17 00:00:00 2001
From: ParsaKhaz
Date: Tue, 16 Jun 2026 20:19:38 -0700
Subject: [PATCH 2/9] Fix runpane update applying existing installs
---
packages/runpane-py/src/runpane/installers.py | 6 ++-
packages/runpane/src/installers.ts | 6 ++-
scripts/test-runpane-contract.js | 37 +++++++++++++++++++
3 files changed, 47 insertions(+), 2 deletions(-)
diff --git a/packages/runpane-py/src/runpane/installers.py b/packages/runpane-py/src/runpane/installers.py
index 07621b8c..31514302 100644
--- a/packages/runpane-py/src/runpane/installers.py
+++ b/packages/runpane-py/src/runpane/installers.py
@@ -50,6 +50,10 @@ def resolve_existing_pane_path(pane_path: Optional[str] = None) -> Optional[str]
return None
+def should_reuse_existing_pane(parsed, target: str) -> bool:
+ return parsed.command == "install" and target == "daemon"
+
+
def install_pane_artifact(
artifact: DownloadedArtifact,
parsed,
@@ -58,7 +62,7 @@ def install_pane_artifact(
target: str,
) -> InstalledPane:
existing = resolve_existing_pane_path(parsed.pane_path)
- if existing:
+ if existing and should_reuse_existing_pane(parsed, target):
return InstalledPane(executable_path=existing, install_kind="existing")
if platform.os == "darwin":
diff --git a/packages/runpane/src/installers.ts b/packages/runpane/src/installers.ts
index 39fc8b88..39b8e6e7 100644
--- a/packages/runpane/src/installers.ts
+++ b/packages/runpane/src/installers.ts
@@ -30,6 +30,10 @@ export function resolveExistingPanePath(panePath?: string): string | undefined {
return candidates.find((candidate) => fs.existsSync(candidate));
}
+export function shouldReuseExistingPane(parsed: Pick, target: InstallTarget): boolean {
+ return parsed.command === 'install' && target === 'daemon';
+}
+
export async function installPaneArtifact(
artifact: DownloadedArtifact,
options: {
@@ -40,7 +44,7 @@ export async function installPaneArtifact(
}
): Promise {
const existing = resolveExistingPanePath(options.parsed.panePath);
- if (existing) {
+ if (existing && shouldReuseExistingPane(options.parsed, options.target)) {
return { executablePath: existing, installKind: 'existing' };
}
diff --git a/scripts/test-runpane-contract.js b/scripts/test-runpane-contract.js
index ff2a7907..4b5851bf 100644
--- a/scripts/test-runpane-contract.js
+++ b/scripts/test-runpane-contract.js
@@ -79,6 +79,13 @@ const artifactCases = [
{ platform: { os: 'win32', arch: 'arm64' }, format: 'exe' }
];
+const existingReuseCases = [
+ { args: ['install', 'daemon', '--pane-path', '/tmp/pane'], expected: true },
+ { args: ['install', 'client', '--pane-path', '/tmp/pane'], expected: false },
+ { args: ['install', '--pane-path', '/tmp/pane'], expected: false },
+ { args: ['update', '--pane-path', '/tmp/pane'], expected: false }
+];
+
function ensureBuiltCli() {
if (!fs.existsSync(npmCli)) {
throw new Error('packages/runpane/dist/cli.js is missing. Run "pnpm --filter runpane build" first.');
@@ -225,6 +232,35 @@ print(json.dumps(normalized))
assert.deepStrictEqual(JSON.parse(pythonOutput), nodeOutput);
}
+function compareExistingReusePolicy() {
+ const { parseRunpaneArgs } = require(path.join(rootDir, 'packages', 'runpane', 'dist', 'commands.js'));
+ const { shouldReuseExistingPane } = require(path.join(rootDir, 'packages', 'runpane', 'dist', 'installers.js'));
+ const nodeOutput = existingReuseCases.map(({ args }) => {
+ const parsed = parseRunpaneArgs(args);
+ const target = parsed.command === 'update' ? 'client' : parsed.target;
+ return shouldReuseExistingPane(parsed, target);
+ });
+
+ const pythonOutput = runPythonSnippet(`
+import json
+import sys
+from runpane.cli import parse_args
+from runpane.installers import should_reuse_existing_pane
+
+cases = json.loads(sys.stdin.read())
+normalized = []
+for case in cases:
+ parsed = parse_args(case["args"])
+ target = "client" if parsed.command == "update" else parsed.target
+ normalized.append(should_reuse_existing_pane(parsed, target))
+print(json.dumps(normalized))
+`, JSON.stringify(existingReuseCases));
+
+ const expected = existingReuseCases.map((testCase) => testCase.expected);
+ assert.deepStrictEqual(nodeOutput, expected);
+ assert.deepStrictEqual(JSON.parse(pythonOutput), expected);
+}
+
function checkHelpOutput() {
const python = findPython();
const pythonEnv = {
@@ -268,5 +304,6 @@ ensureBuiltCli();
compareParserParity();
comparePlatformParity();
compareArtifactSelectionParity();
+compareExistingReusePolicy();
checkHelpOutput();
console.log('runpane CLI contract checks passed');
From 5dc9b746c1c11182e5e71c15383ad40997f70585 Mon Sep 17 00:00:00 2001
From: ParsaKhaz
Date: Tue, 16 Jun 2026 20:25:15 -0700
Subject: [PATCH 3/9] Address runpane wrapper review feedback
---
packages/runpane-py/src/runpane/download.py | 3 +-
packages/runpane-py/src/runpane/installers.py | 3 +-
packages/runpane-py/src/runpane/releases.py | 3 +-
packages/runpane/src/download.ts | 9 +++--
packages/runpane/src/releases.ts | 2 +-
scripts/test-runpane-contract.js | 34 +++++++++++++++++++
6 files changed, 48 insertions(+), 6 deletions(-)
diff --git a/packages/runpane-py/src/runpane/download.py b/packages/runpane-py/src/runpane/download.py
index d85aa402..f6830f7a 100644
--- a/packages/runpane-py/src/runpane/download.py
+++ b/packages/runpane-py/src/runpane/download.py
@@ -2,6 +2,7 @@
import hashlib
import os
+import shutil
import tempfile
import time
import urllib.error
@@ -46,7 +47,7 @@ def download_to_file(url: str, target_path: str, verbose: bool) -> None:
if getattr(response, "status", 200) >= 400:
raise RuntimeError(f"{response.status} {response.reason}")
with open(target_path, "wb") as target:
- target.write(response.read())
+ shutil.copyfileobj(response, target, length=1024 * 1024)
def verify_checksum_if_available(resolved: ResolvedRelease, artifact_path: str, file_name: str) -> None:
diff --git a/packages/runpane-py/src/runpane/installers.py b/packages/runpane-py/src/runpane/installers.py
index 31514302..3cc38ab8 100644
--- a/packages/runpane-py/src/runpane/installers.py
+++ b/packages/runpane-py/src/runpane/installers.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import os
+import platform
import shutil
import subprocess
from dataclasses import dataclass
@@ -32,7 +33,7 @@ def resolve_existing_pane_path(pane_path: Optional[str] = None) -> Optional[str]
])
if program_files:
candidates.append(os.path.join(program_files, "Pane", "Pane.exe"))
- elif subprocess.run(["uname"], capture_output=True, text=True).stdout.strip() == "Darwin":
+ elif platform.system().lower() == "darwin":
candidates.extend([
"/Applications/Pane.app/Contents/MacOS/Pane",
os.path.join(home, "Applications", "Pane.app", "Contents", "MacOS", "Pane"),
diff --git a/packages/runpane-py/src/runpane/releases.py b/packages/runpane-py/src/runpane/releases.py
index ffb04872..eebcec99 100644
--- a/packages/runpane-py/src/runpane/releases.py
+++ b/packages/runpane-py/src/runpane/releases.py
@@ -2,6 +2,7 @@
import json
import os
+import re
import urllib.parse
import urllib.request
from dataclasses import dataclass
@@ -109,5 +110,5 @@ def matches_platform(name: str, platform: PanePlatform) -> bool:
if platform.os == "darwin":
return "macos" in lower or "darwin" in lower or "mac" in lower
if platform.os == "win32":
- return "windows" in lower or "win" in lower
+ return "windows" in lower or re.search(r"(?:^|[._-])win(?:32|64)?(?:[._-]|$)", lower) is not None
return "linux" in lower
diff --git a/packages/runpane/src/download.ts b/packages/runpane/src/download.ts
index 79edbae6..576ea348 100644
--- a/packages/runpane/src/download.ts
+++ b/packages/runpane/src/download.ts
@@ -2,6 +2,9 @@ import crypto from 'crypto';
import fs from 'fs';
import os from 'os';
import path from 'path';
+import { Readable } from 'stream';
+import { pipeline } from 'stream/promises';
+import type { ReadableStream as NodeReadableStream } from 'stream/web';
import { artifactFileName, type ResolvedRelease } from './releases';
export interface DownloadedArtifact {
@@ -48,8 +51,10 @@ async function downloadToFile(url: string, targetPath: string, verbose: boolean)
throw new Error(`${response.status} ${response.statusText}`);
}
- const buffer = Buffer.from(await response.arrayBuffer());
- fs.writeFileSync(targetPath, buffer);
+ await pipeline(
+ Readable.fromWeb(response.body as unknown as NodeReadableStream),
+ fs.createWriteStream(targetPath)
+ );
}
async function verifyChecksumIfAvailable(resolved: ResolvedRelease, artifactPath: string, fileName: string): Promise {
diff --git a/packages/runpane/src/releases.ts b/packages/runpane/src/releases.ts
index 7411baa4..23327290 100644
--- a/packages/runpane/src/releases.ts
+++ b/packages/runpane/src/releases.ts
@@ -123,7 +123,7 @@ function matchesFormat(name: string, format: Exclude): b
function matchesPlatform(name: string, platform: PanePlatform): boolean {
const lower = name.toLowerCase();
if (platform.os === 'darwin') return lower.includes('macos') || lower.includes('darwin') || lower.includes('mac');
- if (platform.os === 'win32') return lower.includes('windows') || lower.includes('win');
+ if (platform.os === 'win32') return lower.includes('windows') || /(?:^|[._-])win(?:32|64)?(?:[._-]|$)/.test(lower);
return lower.includes('linux');
}
diff --git a/scripts/test-runpane-contract.js b/scripts/test-runpane-contract.js
index 4b5851bf..fe84f8d0 100644
--- a/scripts/test-runpane-contract.js
+++ b/scripts/test-runpane-contract.js
@@ -86,6 +86,20 @@ const existingReuseCases = [
{ args: ['update', '--pane-path', '/tmp/pane'], expected: false }
];
+const platformEdgeRelease = {
+ tag_name: 'v2.2.8',
+ name: 'v2.2.8',
+ body: '',
+ html_url: 'https://github.com/dcouple/Pane/releases/tag/v2.2.8',
+ published_at: '2026-01-01T00:00:00Z',
+ prerelease: false,
+ draft: false,
+ assets: [
+ { name: 'Pane-2.2.8-darwin-x64.zip', browser_download_url: 'https://example.test/darwin-x64.zip' },
+ { name: 'Pane-2.2.8-Windows-x64.zip', browser_download_url: 'https://example.test/windows-x64.zip' }
+ ]
+};
+
function ensureBuiltCli() {
if (!fs.existsSync(npmCli)) {
throw new Error('packages/runpane/dist/cli.js is missing. Run "pnpm --filter runpane build" first.');
@@ -261,6 +275,25 @@ print(json.dumps(normalized))
assert.deepStrictEqual(JSON.parse(pythonOutput), expected);
}
+function checkPlatformMatchingEdgeCases() {
+ const { findArtifact } = require(path.join(rootDir, 'packages', 'runpane', 'dist', 'releases.js'));
+ const nodeArtifact = findArtifact(platformEdgeRelease, { os: 'win32', arch: 'x64' }, 'zip').name;
+
+ const pythonArtifact = runPythonSnippet(`
+import json
+import sys
+from runpane.platforms import PanePlatform
+from runpane.releases import find_artifact
+
+release = json.loads(sys.stdin.read())
+artifact = find_artifact(release, PanePlatform(os="win32", arch="x64"), "zip")
+print(artifact["name"])
+`, JSON.stringify(platformEdgeRelease));
+
+ assert.strictEqual(nodeArtifact, 'Pane-2.2.8-Windows-x64.zip');
+ assert.strictEqual(pythonArtifact, 'Pane-2.2.8-Windows-x64.zip');
+}
+
function checkHelpOutput() {
const python = findPython();
const pythonEnv = {
@@ -305,5 +338,6 @@ compareParserParity();
comparePlatformParity();
compareArtifactSelectionParity();
compareExistingReusePolicy();
+checkPlatformMatchingEdgeCases();
checkHelpOutput();
console.log('runpane CLI contract checks passed');
From 85fd88eda3abf892b78a65e4a3b2af7e55e45193 Mon Sep 17 00:00:00 2001
From: ParsaKhaz
Date: Tue, 16 Jun 2026 20:40:54 -0700
Subject: [PATCH 4/9] Fix runpane wrapper Windows smoke and reuse path
---
packages/runpane-py/src/runpane/cli.py | 13 ++-
packages/runpane/src/cli.ts | 33 +++++--
scripts/test-runpane-contract.js | 114 +++++++++++++++++++++++--
scripts/test-runpane-package-smoke.js | 1 +
4 files changed, 143 insertions(+), 18 deletions(-)
diff --git a/packages/runpane-py/src/runpane/cli.py b/packages/runpane-py/src/runpane/cli.py
index e3e2c290..18e5d547 100644
--- a/packages/runpane-py/src/runpane/cli.py
+++ b/packages/runpane-py/src/runpane/cli.py
@@ -5,7 +5,13 @@
from .doctor import run_doctor
from .download import download_artifact
-from .installers import install_pane_artifact, launch_pane_client, spawn_pane
+from .installers import (
+ install_pane_artifact,
+ launch_pane_client,
+ resolve_existing_pane_path,
+ should_reuse_existing_pane,
+ spawn_pane,
+)
from .platforms import detect_platform
from .releases import resolve_release
from .version import print_version
@@ -165,6 +171,11 @@ def read_value(args: List[str], index: int, flag: str) -> str:
def install_or_update(parsed: ParsedArgs) -> int:
target = "client" if parsed.command == "update" else parsed.target
+ if not parsed.dry_run and should_reuse_existing_pane(parsed, target):
+ existing = resolve_existing_pane_path(parsed.pane_path)
+ if existing:
+ return spawn_pane(existing, ["--remote-setup", *parsed.remote_setup_args])
+
platform = detect_platform()
resolved = resolve_release(
version=parsed.pane_version,
diff --git a/packages/runpane/src/cli.ts b/packages/runpane/src/cli.ts
index e016e926..ac8c42c0 100644
--- a/packages/runpane/src/cli.ts
+++ b/packages/runpane/src/cli.ts
@@ -2,14 +2,20 @@
import { helpText, parseRunpaneArgs, type ParsedArgs } from './commands';
import { downloadArtifact } from './download';
import { runDoctor } from './doctor';
-import { installPaneArtifact, launchPaneClient, spawnPane } from './installers';
+import {
+ installPaneArtifact,
+ launchPaneClient,
+ resolveExistingPanePath,
+ shouldReuseExistingPane,
+ spawnPane
+} from './installers';
import { detectPlatform } from './platform';
import { resolveRelease } from './releases';
import { printVersion } from './version';
const SOURCE = 'npm' as const;
-async function main(argv: string[]): Promise {
+export async function main(argv: string[]): Promise {
const parsed = parseRunpaneArgs(argv);
if (parsed.command === 'help') {
@@ -33,8 +39,15 @@ async function main(argv: string[]): Promise {
return 0;
}
-async function installOrUpdate(parsed: ParsedArgs): Promise {
+export async function installOrUpdate(parsed: ParsedArgs): Promise {
const target = parsed.command === 'update' ? 'client' : parsed.target;
+ if (!parsed.dryRun && shouldReuseExistingPane(parsed, target)) {
+ const existing = resolveExistingPanePath(parsed.panePath);
+ if (existing) {
+ return spawnPane(existing, ['--remote-setup', ...parsed.remoteSetupArgs]);
+ }
+ }
+
const platform = detectPlatform();
const resolved = await resolveRelease({
version: parsed.paneVersion,
@@ -94,9 +107,11 @@ function printDryRun(
}
}
-main(process.argv.slice(2)).then((code) => {
- process.exitCode = code;
-}).catch((error) => {
- console.error(error instanceof Error ? error.message : String(error));
- process.exitCode = 1;
-});
+if (require.main === module) {
+ main(process.argv.slice(2)).then((code) => {
+ process.exitCode = code;
+ }).catch((error) => {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exitCode = 1;
+ });
+}
diff --git a/scripts/test-runpane-contract.js b/scripts/test-runpane-contract.js
index fe84f8d0..a7db56ce 100644
--- a/scripts/test-runpane-contract.js
+++ b/scripts/test-runpane-contract.js
@@ -2,6 +2,7 @@
const assert = require('assert');
const childProcess = require('child_process');
const fs = require('fs');
+const os = require('os');
const path = require('path');
const rootDir = path.resolve(__dirname, '..');
@@ -294,6 +295,95 @@ print(artifact["name"])
assert.strictEqual(pythonArtifact, 'Pane-2.2.8-Windows-x64.zip');
}
+async function checkExistingDaemonShortCircuit() {
+ const existingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'runpane-existing-'));
+ const existingPath = path.join(existingDir, process.platform === 'win32' ? 'Pane.exe' : 'pane');
+ fs.writeFileSync(existingPath, '');
+
+ const releasesPath = path.join(rootDir, 'packages', 'runpane', 'dist', 'releases.js');
+ const downloadPath = path.join(rootDir, 'packages', 'runpane', 'dist', 'download.js');
+ const installersPath = path.join(rootDir, 'packages', 'runpane', 'dist', 'installers.js');
+ const cliPath = path.join(rootDir, 'packages', 'runpane', 'dist', 'cli.js');
+ const { parseRunpaneArgs } = require(path.join(rootDir, 'packages', 'runpane', 'dist', 'commands.js'));
+ const releases = require(releasesPath);
+ const download = require(downloadPath);
+ const installers = require(installersPath);
+ const originalResolveRelease = releases.resolveRelease;
+ const originalDownloadArtifact = download.downloadArtifact;
+ const originalSpawnPane = installers.spawnPane;
+ let spawned = null;
+
+ releases.resolveRelease = async () => {
+ throw new Error('resolveRelease should not be called for existing daemon reuse');
+ };
+ download.downloadArtifact = async () => {
+ throw new Error('downloadArtifact should not be called for existing daemon reuse');
+ };
+ installers.spawnPane = async (executablePath, args) => {
+ spawned = { executablePath, args };
+ return 0;
+ };
+
+ try {
+ delete require.cache[require.resolve(cliPath)];
+ const { installOrUpdate } = require(cliPath);
+ const parsed = parseRunpaneArgs(['install', 'daemon', '--pane-path', existingPath, '--label', 'Existing', '--print-only']);
+ const code = await installOrUpdate(parsed);
+ assert.strictEqual(code, 0);
+ assert.deepStrictEqual(spawned, {
+ executablePath: existingPath,
+ args: ['--remote-setup', '--label', 'Existing', '--print-only']
+ });
+ } finally {
+ releases.resolveRelease = originalResolveRelease;
+ download.downloadArtifact = originalDownloadArtifact;
+ installers.spawnPane = originalSpawnPane;
+ delete require.cache[require.resolve(cliPath)];
+ fs.rmSync(existingDir, { recursive: true, force: true });
+ }
+
+ const pythonOutput = runPythonSnippet(`
+import json
+import os
+import tempfile
+import runpane.cli as cli
+from runpane.cli import install_or_update, parse_args
+
+handle = tempfile.NamedTemporaryFile(delete=False)
+handle.close()
+captured = {}
+
+def fail_resolve(*args, **kwargs):
+ raise AssertionError("resolve_release should not be called for existing daemon reuse")
+
+def fail_download(*args, **kwargs):
+ raise AssertionError("download_artifact should not be called for existing daemon reuse")
+
+def fake_spawn(executable_path, args):
+ captured["matchesExisting"] = executable_path == handle.name
+ captured["args"] = args
+ return 0
+
+cli.resolve_release = fail_resolve
+cli.download_artifact = fail_download
+cli.spawn_pane = fake_spawn
+
+try:
+ parsed = parse_args(["install", "daemon", "--pane-path", handle.name, "--label", "Existing", "--print-only"])
+ code = install_or_update(parsed)
+ print(json.dumps({"code": code, "captured": captured}))
+finally:
+ os.unlink(handle.name)
+`);
+ assert.deepStrictEqual(JSON.parse(pythonOutput), {
+ code: 0,
+ captured: {
+ matchesExisting: true,
+ args: ['--remote-setup', '--label', 'Existing', '--print-only']
+ }
+ });
+}
+
function checkHelpOutput() {
const python = findPython();
const pythonEnv = {
@@ -333,11 +423,19 @@ function checkHelpOutput() {
}
}
-ensureBuiltCli();
-compareParserParity();
-comparePlatformParity();
-compareArtifactSelectionParity();
-compareExistingReusePolicy();
-checkPlatformMatchingEdgeCases();
-checkHelpOutput();
-console.log('runpane CLI contract checks passed');
+async function runChecks() {
+ ensureBuiltCli();
+ compareParserParity();
+ comparePlatformParity();
+ compareArtifactSelectionParity();
+ compareExistingReusePolicy();
+ checkPlatformMatchingEdgeCases();
+ await checkExistingDaemonShortCircuit();
+ checkHelpOutput();
+ console.log('runpane CLI contract checks passed');
+}
+
+runChecks().catch((error) => {
+ console.error(error);
+ process.exit(1);
+});
diff --git a/scripts/test-runpane-package-smoke.js b/scripts/test-runpane-package-smoke.js
index cc5e6016..f1b54bf0 100644
--- a/scripts/test-runpane-package-smoke.js
+++ b/scripts/test-runpane-package-smoke.js
@@ -14,6 +14,7 @@ function run(command, args, options = {}) {
childProcess.execFileSync(command, args, {
cwd: rootDir,
stdio: 'inherit',
+ shell: process.platform === 'win32',
env: {
...process.env,
PIP_DISABLE_PIP_VERSION_CHECK: '1',
From 1b64d9399f6cce9aea3de79bf01e056ba0bf2019 Mon Sep 17 00:00:00 2001
From: ParsaKhaz
Date: Tue, 16 Jun 2026 20:45:43 -0700
Subject: [PATCH 5/9] Fix Windows Python smoke runner
---
scripts/test-runpane-package-smoke.js | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/scripts/test-runpane-package-smoke.js b/scripts/test-runpane-package-smoke.js
index f1b54bf0..abbdc4e0 100644
--- a/scripts/test-runpane-package-smoke.js
+++ b/scripts/test-runpane-package-smoke.js
@@ -25,7 +25,17 @@ function run(command, args, options = {}) {
}
function findPython() {
- for (const command of [process.env.PYTHON, 'python3', 'python'].filter(Boolean)) {
+ const candidates = process.platform === 'win32'
+ ? [
+ process.env.PYTHON,
+ process.env.pythonLocation ? path.join(process.env.pythonLocation, 'python.exe') : undefined,
+ 'python',
+ 'py',
+ 'python3'
+ ]
+ : [process.env.PYTHON, 'python3', 'python'];
+
+ for (const command of candidates.filter(Boolean)) {
try {
childProcess.execFileSync(command, ['--version'], { stdio: 'ignore' });
return command;
From e4554ff8067e9b4eb6872c38934ee3e5273a9430 Mon Sep 17 00:00:00 2001
From: ParsaKhaz
Date: Tue, 16 Jun 2026 20:49:39 -0700
Subject: [PATCH 6/9] Restore curl manual update fallback
---
frontend/src/components/UpdateDialog.tsx | 2 +-
main/src/ipc/updater.ts | 2 +-
packages/runpane-py/src/runpane/platforms.py | 3 ++-
3 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/frontend/src/components/UpdateDialog.tsx b/frontend/src/components/UpdateDialog.tsx
index 5c0617da..d242a10c 100644
--- a/frontend/src/components/UpdateDialog.tsx
+++ b/frontend/src/components/UpdateDialog.tsx
@@ -272,7 +272,7 @@ export function UpdateDialog({ isOpen, onClose, versionInfo }: UpdateDialogProps
Your settings and sessions are preserved.
]
- npx --yes runpane@latest update
+ curl -fsSL https://runpane.com/install.sh | sh
diff --git a/main/src/ipc/updater.ts b/main/src/ipc/updater.ts
index 00288de5..20dcafff 100644
--- a/main/src/ipc/updater.ts
+++ b/main/src/ipc/updater.ts
@@ -8,7 +8,7 @@ import { commandExecutor } from '../utils/commandExecutor';
import { getCurrentWorktreeName } from '../utils/worktreeUtils';
import { getAppDirectory } from '../utils/appDirectory';
-const MAC_UPDATE_COMMAND = 'npx --yes runpane@latest update';
+const MAC_UPDATE_COMMAND = 'curl -fsSL https://runpane.com/install.sh | sh';
export function registerUpdaterHandlers(ipcMain: IpcMain, { app, versionChecker }: AppServices): void {
// Version checking handlers
diff --git a/packages/runpane-py/src/runpane/platforms.py b/packages/runpane-py/src/runpane/platforms.py
index 56aa725c..8c92bc8c 100644
--- a/packages/runpane-py/src/runpane/platforms.py
+++ b/packages/runpane-py/src/runpane/platforms.py
@@ -3,6 +3,7 @@
import os
import platform
from dataclasses import dataclass
+from typing import List
@dataclass(frozen=True)
@@ -50,7 +51,7 @@ def platform_param(platform_info: PanePlatform) -> str:
return "linux"
-def arch_aliases(platform_info: PanePlatform) -> list[str]:
+def arch_aliases(platform_info: PanePlatform) -> List[str]:
if platform_info.arch == "arm64":
return ["arm64", "aarch64"]
if platform_info.os == "linux":
From 1f9ae1b74124fb4d9c66e5a64c9a49f005ffd6b3 Mon Sep 17 00:00:00 2001
From: ParsaKhaz
Date: Tue, 16 Jun 2026 21:01:52 -0700
Subject: [PATCH 7/9] Fix runpane website download URLs
---
docs/RUNPANE_CLI_CONTRACT.md | 2 +-
packages/runpane-py/src/runpane/releases.py | 16 ++++-
packages/runpane/src/releases.ts | 9 ++-
scripts/test-runpane-contract.js | 65 +++++++++++++++++++++
4 files changed, 85 insertions(+), 7 deletions(-)
diff --git a/docs/RUNPANE_CLI_CONTRACT.md b/docs/RUNPANE_CLI_CONTRACT.md
index 5b6c6d7f..7f95b112 100644
--- a/docs/RUNPANE_CLI_CONTRACT.md
+++ b/docs/RUNPANE_CLI_CONTRACT.md
@@ -153,7 +153,7 @@ pipx, uvx, and `python -m runpane`.
Wrappers should prefer:
```text
-https://runpane.com/api/download?platform=&arch=&format=&version=&channel=&source=
+https://runpane.com/api/download?platform=&arch=&format=&v=&file=&channel=&source=
```
If the website route cannot satisfy the download, wrappers may fall back to the
diff --git a/packages/runpane-py/src/runpane/releases.py b/packages/runpane-py/src/runpane/releases.py
index eebcec99..ded127b6 100644
--- a/packages/runpane-py/src/runpane/releases.py
+++ b/packages/runpane-py/src/runpane/releases.py
@@ -36,7 +36,7 @@ def resolve_release(
release = fetch_release(version)
selected_format = default_format(platform, target) if format_name == "auto" else format_name
artifact = find_artifact(release, platform, selected_format)
- preferred = build_preferred_download_url(version, channel, source, platform, selected_format)
+ preferred = build_preferred_download_url(channel, source, platform, selected_format, release, artifact)
tag_name = release["tag_name"]
return ResolvedRelease(
release=release,
@@ -86,12 +86,22 @@ def artifact_file_name(url_or_name: str) -> str:
return os.path.basename(url_or_name.split("?", 1)[0])
-def build_preferred_download_url(version: str, channel: str, source: str, platform: PanePlatform, format_name: str) -> str:
+def build_preferred_download_url(
+ channel: str,
+ source: str,
+ platform: PanePlatform,
+ format_name: str,
+ release: Dict[str, Any],
+ artifact: Dict[str, Any],
+) -> str:
+ tag_name = str(release["tag_name"])
+ version = tag_name[1:] if tag_name.startswith("v") else tag_name
query = urllib.parse.urlencode({
"platform": platform_param(platform),
"arch": platform.arch,
"format": format_name,
- "version": "latest" if version == "latest" else version if version.startswith("v") else f"v{version}",
+ "v": version,
+ "file": artifact["name"],
"channel": channel,
"source": source,
})
diff --git a/packages/runpane/src/releases.ts b/packages/runpane/src/releases.ts
index 23327290..b8b3f5fa 100644
--- a/packages/runpane/src/releases.ts
+++ b/packages/runpane/src/releases.ts
@@ -43,7 +43,7 @@ export async function resolveRelease(options: ResolveReleaseOptions): Promise
+ format: Exclude,
+ release: GitHubRelease,
+ artifact: GitHubReleaseAsset
): string {
const params = new URLSearchParams({
platform: platformParam(options.platform),
arch: options.platform.arch,
format,
- version: options.version === 'latest' ? 'latest' : options.version.startsWith('v') ? options.version : `v${options.version}`,
+ v: release.tag_name.replace(/^v/, ''),
+ file: artifact.name,
channel: options.channel,
source: options.source
});
diff --git a/scripts/test-runpane-contract.js b/scripts/test-runpane-contract.js
index a7db56ce..268f64e7 100644
--- a/scripts/test-runpane-contract.js
+++ b/scripts/test-runpane-contract.js
@@ -247,6 +247,70 @@ print(json.dumps(normalized))
assert.deepStrictEqual(JSON.parse(pythonOutput), nodeOutput);
}
+async function checkPreferredDownloadUrls() {
+ const releases = require(path.join(rootDir, 'packages', 'runpane', 'dist', 'releases.js'));
+ const originalFetch = global.fetch;
+
+ global.fetch = async () => ({
+ ok: true,
+ json: async () => artifactRelease
+ });
+
+ let nodeUrl;
+ try {
+ const resolved = await releases.resolveRelease({
+ version: 'latest',
+ channel: 'stable',
+ source: 'npm',
+ platform: { os: 'linux', arch: 'x64' },
+ format: 'appimage',
+ target: 'client'
+ });
+ nodeUrl = resolved.preferredDownloadUrl;
+ } finally {
+ global.fetch = originalFetch;
+ }
+
+ const parsedNodeUrl = new URL(nodeUrl);
+ assert.strictEqual(`${parsedNodeUrl.origin}${parsedNodeUrl.pathname}`, 'https://runpane.com/api/download');
+ assert.strictEqual(parsedNodeUrl.searchParams.get('platform'), 'linux');
+ assert.strictEqual(parsedNodeUrl.searchParams.get('arch'), 'x64');
+ assert.strictEqual(parsedNodeUrl.searchParams.get('format'), 'appimage');
+ assert.strictEqual(parsedNodeUrl.searchParams.get('v'), '2.2.8');
+ assert.strictEqual(parsedNodeUrl.searchParams.get('file'), 'Pane-2.2.8-linux-x86_64.AppImage');
+ assert.strictEqual(parsedNodeUrl.searchParams.get('channel'), 'stable');
+ assert.strictEqual(parsedNodeUrl.searchParams.get('source'), 'npm');
+
+ const pythonUrl = runPythonSnippet(`
+import json
+import sys
+import runpane.releases as releases
+from runpane.platforms import PanePlatform
+
+release = json.loads(sys.stdin.read())
+releases.fetch_release = lambda version: release
+resolved = releases.resolve_release(
+ version="latest",
+ channel="stable",
+ source="pip",
+ platform=PanePlatform(os="linux", arch="x64"),
+ format_name="appimage",
+ target="client",
+)
+print(resolved.preferred_download_url)
+`, JSON.stringify(artifactRelease));
+
+ const parsedPythonUrl = new URL(pythonUrl);
+ assert.strictEqual(`${parsedPythonUrl.origin}${parsedPythonUrl.pathname}`, 'https://runpane.com/api/download');
+ assert.strictEqual(parsedPythonUrl.searchParams.get('platform'), 'linux');
+ assert.strictEqual(parsedPythonUrl.searchParams.get('arch'), 'x64');
+ assert.strictEqual(parsedPythonUrl.searchParams.get('format'), 'appimage');
+ assert.strictEqual(parsedPythonUrl.searchParams.get('v'), '2.2.8');
+ assert.strictEqual(parsedPythonUrl.searchParams.get('file'), 'Pane-2.2.8-linux-x86_64.AppImage');
+ assert.strictEqual(parsedPythonUrl.searchParams.get('channel'), 'stable');
+ assert.strictEqual(parsedPythonUrl.searchParams.get('source'), 'pip');
+}
+
function compareExistingReusePolicy() {
const { parseRunpaneArgs } = require(path.join(rootDir, 'packages', 'runpane', 'dist', 'commands.js'));
const { shouldReuseExistingPane } = require(path.join(rootDir, 'packages', 'runpane', 'dist', 'installers.js'));
@@ -428,6 +492,7 @@ async function runChecks() {
compareParserParity();
comparePlatformParity();
compareArtifactSelectionParity();
+ await checkPreferredDownloadUrls();
compareExistingReusePolicy();
checkPlatformMatchingEdgeCases();
await checkExistingDaemonShortCircuit();
From a883074bd6aa235032e36055a01d2853ab4f54fe Mon Sep 17 00:00:00 2001
From: ParsaKhaz
Date: Tue, 16 Jun 2026 21:07:15 -0700
Subject: [PATCH 8/9] Gate runpane release publishing on package validation
---
.github/workflows/build.yml | 23 +++++++++++++++++++++--
1 file changed, 21 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 4bbb3547..51219d2a 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -128,9 +128,28 @@ jobs:
gh release upload "v${VERSION}" SHA256SUMS.txt \
--repo dcouple/Pane --clobber
+ validate-runpane-packages:
+ if: github.ref_type == 'tag'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22.15.1'
+ cache: 'pnpm'
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+ - run: pnpm install --filter runpane --ignore-scripts
+ - run: pnpm run check:runpane-package-versions
+ - run: pnpm --filter runpane build
+ - run: pnpm run test:runpane-contract
+ - run: pnpm run test:runpane-package-smoke
+
publish-npm:
if: github.ref_type == 'tag'
- needs: [checksums]
+ needs: [checksums, validate-runpane-packages]
runs-on: ubuntu-latest
permissions:
contents: read
@@ -162,7 +181,7 @@ jobs:
publish-pypi:
if: github.ref_type == 'tag'
- needs: [checksums]
+ needs: [checksums, validate-runpane-packages]
runs-on: ubuntu-latest
environment:
name: pypi
From 54f98eeed9843e77c58080d5067b01dbd6939e6f Mon Sep 17 00:00:00 2001
From: ParsaKhaz
Date: Tue, 16 Jun 2026 21:21:42 -0700
Subject: [PATCH 9/9] Restore runpane platform download contract
---
docs/RUNPANE_CLI_CONTRACT.md | 2 +-
packages/runpane-py/src/runpane/releases.py | 8 ++------
packages/runpane/src/releases.ts | 8 +++-----
scripts/test-runpane-contract.js | 8 ++++----
4 files changed, 10 insertions(+), 16 deletions(-)
diff --git a/docs/RUNPANE_CLI_CONTRACT.md b/docs/RUNPANE_CLI_CONTRACT.md
index 7f95b112..5b6c6d7f 100644
--- a/docs/RUNPANE_CLI_CONTRACT.md
+++ b/docs/RUNPANE_CLI_CONTRACT.md
@@ -153,7 +153,7 @@ pipx, uvx, and `python -m runpane`.
Wrappers should prefer:
```text
-https://runpane.com/api/download?platform=&arch=&format=&v=&file=&channel=&source=
+https://runpane.com/api/download?platform=&arch=&format=&version=&channel=&source=
```
If the website route cannot satisfy the download, wrappers may fall back to the
diff --git a/packages/runpane-py/src/runpane/releases.py b/packages/runpane-py/src/runpane/releases.py
index ded127b6..07a420cf 100644
--- a/packages/runpane-py/src/runpane/releases.py
+++ b/packages/runpane-py/src/runpane/releases.py
@@ -36,7 +36,7 @@ def resolve_release(
release = fetch_release(version)
selected_format = default_format(platform, target) if format_name == "auto" else format_name
artifact = find_artifact(release, platform, selected_format)
- preferred = build_preferred_download_url(channel, source, platform, selected_format, release, artifact)
+ preferred = build_preferred_download_url(channel, source, platform, selected_format, release)
tag_name = release["tag_name"]
return ResolvedRelease(
release=release,
@@ -92,16 +92,12 @@ def build_preferred_download_url(
platform: PanePlatform,
format_name: str,
release: Dict[str, Any],
- artifact: Dict[str, Any],
) -> str:
- tag_name = str(release["tag_name"])
- version = tag_name[1:] if tag_name.startswith("v") else tag_name
query = urllib.parse.urlencode({
"platform": platform_param(platform),
"arch": platform.arch,
"format": format_name,
- "v": version,
- "file": artifact["name"],
+ "version": release["tag_name"],
"channel": channel,
"source": source,
})
diff --git a/packages/runpane/src/releases.ts b/packages/runpane/src/releases.ts
index b8b3f5fa..7346a1cf 100644
--- a/packages/runpane/src/releases.ts
+++ b/packages/runpane/src/releases.ts
@@ -43,7 +43,7 @@ export async function resolveRelease(options: ResolveReleaseOptions): Promise,
- release: GitHubRelease,
- artifact: GitHubReleaseAsset
+ release: GitHubRelease
): string {
const params = new URLSearchParams({
platform: platformParam(options.platform),
arch: options.platform.arch,
format,
- v: release.tag_name.replace(/^v/, ''),
- file: artifact.name,
+ version: release.tag_name,
channel: options.channel,
source: options.source
});
diff --git a/scripts/test-runpane-contract.js b/scripts/test-runpane-contract.js
index 268f64e7..d6c553aa 100644
--- a/scripts/test-runpane-contract.js
+++ b/scripts/test-runpane-contract.js
@@ -276,8 +276,8 @@ async function checkPreferredDownloadUrls() {
assert.strictEqual(parsedNodeUrl.searchParams.get('platform'), 'linux');
assert.strictEqual(parsedNodeUrl.searchParams.get('arch'), 'x64');
assert.strictEqual(parsedNodeUrl.searchParams.get('format'), 'appimage');
- assert.strictEqual(parsedNodeUrl.searchParams.get('v'), '2.2.8');
- assert.strictEqual(parsedNodeUrl.searchParams.get('file'), 'Pane-2.2.8-linux-x86_64.AppImage');
+ assert.strictEqual(parsedNodeUrl.searchParams.get('version'), 'v2.2.8');
+ assert.strictEqual(parsedNodeUrl.searchParams.get('file'), null);
assert.strictEqual(parsedNodeUrl.searchParams.get('channel'), 'stable');
assert.strictEqual(parsedNodeUrl.searchParams.get('source'), 'npm');
@@ -305,8 +305,8 @@ print(resolved.preferred_download_url)
assert.strictEqual(parsedPythonUrl.searchParams.get('platform'), 'linux');
assert.strictEqual(parsedPythonUrl.searchParams.get('arch'), 'x64');
assert.strictEqual(parsedPythonUrl.searchParams.get('format'), 'appimage');
- assert.strictEqual(parsedPythonUrl.searchParams.get('v'), '2.2.8');
- assert.strictEqual(parsedPythonUrl.searchParams.get('file'), 'Pane-2.2.8-linux-x86_64.AppImage');
+ assert.strictEqual(parsedPythonUrl.searchParams.get('version'), 'v2.2.8');
+ assert.strictEqual(parsedPythonUrl.searchParams.get('file'), null);
assert.strictEqual(parsedPythonUrl.searchParams.get('channel'), 'stable');
assert.strictEqual(parsedPythonUrl.searchParams.get('source'), 'pip');
}