diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 544f9d44..51219d2a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -128,6 +128,90 @@ 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, validate-runpane-packages] + 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, validate-runpane-packages] + 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..d242a10c 100644 --- a/frontend/src/components/UpdateDialog.tsx +++ b/frontend/src/components/UpdateDialog.tsx @@ -265,7 +265,7 @@ 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. 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..18e5d547 --- /dev/null +++ b/packages/runpane-py/src/runpane/cli.py @@ -0,0 +1,275 @@ +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, + 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 + +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 + 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, + 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..f6830f7a --- /dev/null +++ b/packages/runpane-py/src/runpane/download.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import hashlib +import os +import shutil +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: + shutil.copyfileobj(response, target, length=1024 * 1024) + + +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..3cc38ab8 --- /dev/null +++ b/packages/runpane-py/src/runpane/installers.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import os +import platform +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 platform.system().lower() == "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 should_reuse_existing_pane(parsed, target: str) -> bool: + return parsed.command == "install" and target == "daemon" + + +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 and should_reuse_existing_pane(parsed, target): + 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..8c92bc8c --- /dev/null +++ b/packages/runpane-py/src/runpane/platforms.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import os +import platform +from dataclasses import dataclass +from typing import List + + +@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..07a420cf --- /dev/null +++ b/packages/runpane-py/src/runpane/releases.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import json +import os +import re +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(channel, source, platform, selected_format, release) + 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( + channel: str, + source: str, + platform: PanePlatform, + format_name: str, + release: Dict[str, Any], +) -> str: + query = urllib.parse.urlencode({ + "platform": platform_param(platform), + "arch": platform.arch, + "format": format_name, + "version": release["tag_name"], + "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 re.search(r"(?:^|[._-])win(?:32|64)?(?:[._-]|$)", lower) is not None + 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..ac8c42c0 --- /dev/null +++ b/packages/runpane/src/cli.ts @@ -0,0 +1,117 @@ +#!/usr/bin/env node +import { helpText, parseRunpaneArgs, type ParsedArgs } from './commands'; +import { downloadArtifact } from './download'; +import { runDoctor } from './doctor'; +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; + +export 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; +} + +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, + 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()); + } +} + +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/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..576ea348 --- /dev/null +++ b/packages/runpane/src/download.ts @@ -0,0 +1,99 @@ +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 { + 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}`); + } + + await pipeline( + Readable.fromWeb(response.body as unknown as NodeReadableStream), + fs.createWriteStream(targetPath) + ); +} + +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..39b8e6e7 --- /dev/null +++ b/packages/runpane/src/installers.ts @@ -0,0 +1,153 @@ +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 function shouldReuseExistingPane(parsed: Pick, target: InstallTarget): boolean { + return parsed.command === 'install' && target === 'daemon'; +} + +export async function installPaneArtifact( + artifact: DownloadedArtifact, + options: { + parsed: ParsedArgs; + platform: PanePlatform; + format: Exclude; + target: InstallTarget; + } +): Promise { + const existing = resolveExistingPanePath(options.parsed.panePath); + if (existing && shouldReuseExistingPane(options.parsed, options.target)) { + 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..7346a1cf --- /dev/null +++ b/packages/runpane/src/releases.ts @@ -0,0 +1,133 @@ +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, release); + + 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, + release: GitHubRelease +): string { + const params = new URLSearchParams({ + platform: platformParam(options.platform), + arch: options.platform.arch, + format, + version: release.tag_name, + 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') || /(?:^|[._-])win(?:32|64)?(?:[._-]|$)/.test(lower); + 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..d6c553aa --- /dev/null +++ b/scripts/test-runpane-contract.js @@ -0,0 +1,506 @@ +#!/usr/bin/env node +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, '..'); +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' } +]; + +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 } +]; + +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.'); + } +} + +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); +} + +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('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'); + + 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('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'); +} + +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 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'); +} + +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 = { + ...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); + } + } +} + +async function runChecks() { + ensureBuiltCli(); + compareParserParity(); + comparePlatformParity(); + compareArtifactSelectionParity(); + await checkPreferredDownloadUrls(); + 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 new file mode 100644 index 00000000..abbdc4e0 --- /dev/null +++ b/scripts/test-runpane-package-smoke.js @@ -0,0 +1,108 @@ +#!/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', + shell: process.platform === 'win32', + env: { + ...process.env, + PIP_DISABLE_PIP_VERSION_CHECK: '1', + ...env + }, + ...execOptions + }); +} + +function findPython() { + 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; + } 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 }); +}