diff --git a/README.md b/README.md
index 0e743925..91d167a1 100644
--- a/README.md
+++ b/README.md
@@ -25,29 +25,33 @@
**Quick install (recommended)**
-Package manager
-
npx --yes runpane@latest install client
+npx --yes runpane@latest
+
+Opens a guided setup for desktop install, remote host setup, updates, and diagnostics.
+
+
+
+
+
+Other install methods
+
pnpm
-pnpm dlx runpane@latest install client
+pnpm dlx runpane@latest
Python tools
-pipx run runpane install client
+pipx run runpane
-or use the shell installers
+Persistent npm install
+npm i -g runpane
+runpane setup
-Mac / Linux
+Mac / Linux shell installer
curl -fsSL https://runpane.com/install.sh | sh
-Windows (PowerShell)
+Windows PowerShell installer
irm https://runpane.com/install.ps1 | iex
-Bypasses the macOS Gatekeeper / Windows SmartScreen prompts on direct downloads.
-
-
-
-or download the installer directly
-
@@ -58,6 +62,8 @@
+
+
@@ -189,19 +195,40 @@ Other tools build custom chat UIs that only work with agents they've explicitly
### Quick Install
-**Package manager:**
+Run the guided setup:
+
+```bash
+npx --yes runpane@latest
+```
+
+The wizard can install Pane on this machine, configure this machine as a remote
+host, update Pane, or run diagnostics.
+
+### Advanced Install Methods
+
+Explicit desktop install:
+
```bash
npx --yes runpane@latest install client
pnpm dlx runpane@latest install client
pipx run runpane install client
```
-**Mac / Linux:**
+Persistent npm install:
+
+```bash
+npm i -g runpane
+runpane setup
+```
+
+Mac / Linux shell installer:
+
```bash
curl -fsSL https://runpane.com/install.sh | sh
```
-**Windows (PowerShell):**
+Windows PowerShell installer:
+
```powershell
irm https://runpane.com/install.ps1 | iex
```
diff --git a/docs/RUNPANE_CLI_CONTRACT.md b/docs/RUNPANE_CLI_CONTRACT.md
index 5b6c6d7f..d8e11a72 100644
--- a/docs/RUNPANE_CLI_CONTRACT.md
+++ b/docs/RUNPANE_CLI_CONTRACT.md
@@ -15,9 +15,9 @@ Treat this file as the source of truth for both wrapper packages:
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`.
+- Root `README.md` and package READMEs should lead with one guided quick-start
+ command. Explicit commands, package-manager variants, and flags belong in an
+ Advanced section.
- 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.
@@ -37,13 +37,29 @@ out of the wrapper unless a compatibility test covers the new dependency.
## Package Manager Entrypoints
+Recommended guided quick starts:
+
+```bash
+npx --yes runpane@latest
+npm i -g runpane && runpane setup
+pipx run runpane
+python -m pip install runpane && python -m runpane setup
+```
+
Canonical npm and Node commands:
```bash
+npx --yes runpane@latest
+npx --yes runpane@latest setup
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
pnpm dlx runpane@latest install daemon --label "My Server"
+npm i -g runpane && runpane
+npm i -g runpane && runpane setup
+npm i -g runpane && runpane install daemon --label "My Server"
+pnpm add -g runpane && runpane
+pnpm add -g runpane && runpane setup
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"
@@ -52,10 +68,19 @@ bunx runpane@latest install daemon --label "My Server"
Canonical Python commands:
```bash
+pipx run runpane
+pipx run runpane setup
python -m pip install runpane
+runpane
+runpane setup
+python -m runpane setup
runpane install daemon --label "My Server"
pipx install runpane
+runpane
+runpane setup
pipx run runpane install daemon --label "My Server"
+uvx runpane@latest
+uvx runpane@latest setup
uvx runpane@latest install daemon --label "My Server"
python -m runpane install daemon --label "My Server"
```
@@ -67,6 +92,8 @@ install path.
## Commands
```bash
+runpane
+runpane setup
runpane install
runpane install client
runpane install daemon
@@ -77,6 +104,11 @@ runpane help
runpane --help
```
+When stdin and stdout are TTYs, `runpane` with no arguments and `runpane setup`
+open an interactive wizard for desktop install, remote host setup, update, and
+diagnostics. In non-interactive shells or CI, both forms must print help and
+exit successfully instead of waiting for input.
+
`runpane install` is an alias for `runpane install client`.
`runpane install client` downloads the selected Pane desktop artifact and
diff --git a/packages/runpane-py/README.md b/packages/runpane-py/README.md
index 00bd06b5..94550b78 100644
--- a/packages/runpane-py/README.md
+++ b/packages/runpane-py/README.md
@@ -1,38 +1,66 @@
# runpane
-Thin PyPI installer and remote setup CLI for Pane.
+Install or configure Pane from PyPI.
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
+## Quick Start
-One-shot execution:
+Run the guided setup:
```bash
-pipx run runpane install daemon --label "My Server"
-uvx runpane@latest install daemon --label "My Server"
+pipx run runpane
```
Persistent install:
```bash
python -m pip install runpane
-runpane install daemon --label "My Server"
+python -m runpane setup
+```
-pipx install runpane
-runpane install daemon --label "My Server"
+The wizard can install Pane on this machine, configure this machine as a remote
+host, update Pane, or run diagnostics.
+
+## Advanced
+
+### Explicit Commands
+
+```bash
+pipx run runpane setup
+pipx run runpane install client
+pipx run runpane install daemon --label "My Server"
+pipx run runpane update
+pipx run runpane doctor
```
-Module entrypoint:
+`runpane install daemon` installs Pane and then invokes the installed executable
+with `--remote-setup`, preserving the `pane-remote://...` connection-code output.
+
+### Python Runners
+
+One-shot execution:
```bash
-python -m runpane install daemon --label "My Server"
+uvx runpane@latest
```
-## Commands
+Persistent install:
```bash
+python -m pip install runpane
+python -m runpane setup
+
+pipx install runpane
+runpane setup
+```
+
+### Commands
+
+```bash
+runpane
+runpane setup
runpane install
runpane install client
runpane install daemon
@@ -42,8 +70,24 @@ 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.
+### Common Options
+
+```bash
+--version
+--format
+--download-dir
+--pane-path
+--dry-run
+--verbose
+```
+
+Daemon setup also forwards Pane remote-host options:
+
+```bash
+--label
+--prefer-tunnel
+--print-only
+```
## Attribution
diff --git a/packages/runpane-py/src/runpane/cli.py b/packages/runpane-py/src/runpane/cli.py
index 18e5d547..bfe68098 100644
--- a/packages/runpane-py/src/runpane/cli.py
+++ b/packages/runpane-py/src/runpane/cli.py
@@ -1,7 +1,10 @@
from __future__ import annotations
from dataclasses import dataclass, field
-from typing import List, Optional
+import os
+import socket
+import sys
+from typing import Dict, List, Optional, TypeVar
from .doctor import run_doctor
from .download import download_artifact
@@ -18,7 +21,7 @@
SOURCE = "pip"
-COMMANDS = {"help", "install", "update", "version", "doctor"}
+COMMANDS = {"help", "setup", "install", "update", "version", "doctor"}
TARGETS = {"client", "daemon"}
FORMATS = {"auto", "appimage", "deb", "dmg", "zip", "exe"}
CHANNELS = {"stable", "nightly"}
@@ -60,13 +63,17 @@ class ParsedArgs:
def main(argv: Optional[List[str]] = None) -> int:
- import sys
-
try:
- parsed = parse_args(sys.argv[1:] if argv is None else argv)
+ effective_argv = sys.argv[1:] if argv is None else argv
+ if not effective_argv:
+ return run_no_args_entrypoint()
+
+ parsed = parse_args(effective_argv)
if parsed.command == "help":
print(help_text(parsed.help_topic))
return 0
+ if parsed.command == "setup":
+ return run_no_args_entrypoint()
if parsed.command == "version":
return print_version(parsed.pane_path)
if parsed.command == "doctor":
@@ -80,6 +87,121 @@ def main(argv: Optional[List[str]] = None) -> int:
return 1
+def run_no_args_entrypoint() -> int:
+ if not is_interactive_shell():
+ print(help_text(None))
+ return 0
+
+ return run_interactive_wizard()
+
+
+def is_interactive_shell() -> bool:
+ return bool(sys.stdin.isatty() and sys.stdout.isatty() and not os.environ.get("CI"))
+
+
+def run_interactive_wizard() -> int:
+ print("Pane setup")
+ print("Choose what this machine should do. You can rerun setup any time.")
+ print()
+ print("1) Install Pane desktop app on this machine")
+ print("2) Set up this machine as a remote host")
+ print("3) Update Pane desktop app")
+ print("4) Run diagnostics")
+ print()
+
+ action = ask_choice("Choose an action [1]: ", {
+ "": "client",
+ "1": "client",
+ "client": "client",
+ "install": "client",
+ "desktop": "client",
+ "2": "daemon",
+ "daemon": "daemon",
+ "remote": "daemon",
+ "host": "daemon",
+ "3": "update",
+ "update": "update",
+ "4": "doctor",
+ "doctor": "doctor",
+ "diagnostics": "doctor",
+ })
+
+ if action == "client":
+ print()
+ print("Installing Pane desktop app on this machine...")
+ return install_or_update(create_parsed_args("install", target="client"))
+ if action == "update":
+ print()
+ print("Updating Pane desktop app on this machine...")
+ return install_or_update(create_parsed_args("update", target="client"))
+ if action == "doctor":
+ print()
+ print("Running runpane diagnostics...")
+ return run_doctor(create_parsed_args("doctor"), SOURCE)
+
+ print()
+ print("A remote host runs your repos, terminals, agents, and git state.")
+ print("Your desktop Pane or browser client connects with the generated pane-remote:// code.")
+
+ default_label = socket.gethostname() or "Remote Host"
+ label = input(f"Remote host label [{default_label}]: ").strip() or default_label
+
+ print()
+ print("Connection method:")
+ print("1) auto")
+ print("2) tailscale")
+ print("3) ssh")
+ print("4) manual")
+ print()
+ print("Use auto unless you already know you want Tailscale, SSH, or a manual URL.")
+ print()
+
+ tunnel = ask_choice("Choose a connection method [1]: ", {
+ "": "auto",
+ "1": "auto",
+ "auto": "auto",
+ "2": "tailscale",
+ "tailscale": "tailscale",
+ "3": "ssh",
+ "ssh": "ssh",
+ "4": "manual",
+ "manual": "manual",
+ })
+
+ remote_setup_args = ["--label", label]
+ if tunnel != "auto":
+ remote_setup_args.extend(["--prefer-tunnel", tunnel])
+
+ print()
+ print("Setting up this machine as a Pane remote host...")
+ print("When setup finishes, paste the printed pane-remote:// code into Pane or runpane.com/app.")
+
+ return install_or_update(create_parsed_args(
+ "install",
+ target="daemon",
+ remote_setup_args=remote_setup_args,
+ ))
+
+
+ChoiceT = TypeVar("ChoiceT", bound=str)
+
+
+def ask_choice(prompt: str, choices: Dict[str, ChoiceT]) -> ChoiceT:
+ while True:
+ answer = input(prompt).strip().lower()
+ choice = choices.get(answer)
+ if choice:
+ return choice
+ print(f"Choose one of: {', '.join(key for key in choices.keys() if key)}")
+
+
+def create_parsed_args(command: str, **overrides: object) -> ParsedArgs:
+ parsed = ParsedArgs(command=command)
+ for key, value in overrides.items():
+ setattr(parsed, key, value)
+ return parsed
+
+
def parse_args(argv: List[str]) -> ParsedArgs:
args = list(argv)
if not args or args[0] in {"-h", "--help"}:
@@ -174,9 +296,12 @@ def install_or_update(parsed: ParsedArgs) -> int:
if not parsed.dry_run and should_reuse_existing_pane(parsed, target):
existing = resolve_existing_pane_path(parsed.pane_path)
if existing:
+ print(f"runpane: using existing Pane executable at {existing}")
+ print("runpane: starting remote setup...")
return spawn_pane(existing, ["--remote-setup", *parsed.remote_setup_args])
platform = detect_platform()
+ print(f"runpane: resolving Pane release {parsed.pane_version}...")
resolved = resolve_release(
version=parsed.pane_version,
channel=parsed.channel,
@@ -203,10 +328,16 @@ def install_or_update(parsed: ParsedArgs) -> int:
print(f"Pane command: --remote-setup {forwarded}".strip())
return 0
+ print(f"runpane: selected {resolved.artifact['name']}")
+ print(f"runpane: downloading {resolved.artifact['name']}...")
artifact = download_artifact(resolved, parsed.download_dir, parsed.verbose)
+ fallback = " from GitHub fallback" if artifact.used_fallback else ""
+ print(f"runpane: downloaded {artifact.file_name}{fallback}")
+ print("runpane: installing Pane...")
installed = install_pane_artifact(artifact, parsed, platform, resolved.format, target)
if target == "daemon":
+ print("runpane: starting remote setup...")
return spawn_pane(installed.executable_path, ["--remote-setup", *parsed.remote_setup_args])
if installed.install_kind == "installed":
@@ -253,6 +384,17 @@ def help_text(topic: Optional[str]) -> str:
if topic == "update":
return "Usage:\n runpane update [--version ] [--dry-run] [--yes]"
+ if topic == "setup":
+ return "\n".join([
+ "Usage:",
+ " runpane setup",
+ "",
+ "Opens the guided setup for desktop install, remote host setup, update, and diagnostics.",
+ "",
+ "Quick start:",
+ " pipx run runpane",
+ " python -m pip install runpane && python -m runpane setup",
+ ])
if topic == "version":
return "Usage:\n runpane version\n runpane --version"
if topic == "doctor":
@@ -260,16 +402,22 @@ def help_text(topic: Optional[str]) -> str:
return "\n".join([
"Usage:",
+ " runpane",
+ " runpane setup",
" runpane install [client|daemon] [options]",
" runpane update [options]",
" runpane version",
" runpane doctor",
" runpane help [command]",
"",
- "Package manager examples:",
+ "Quick start:",
+ " pipx run runpane",
+ " python -m pip install runpane && python -m runpane setup",
+ "",
+ "Advanced examples:",
+ " pipx run runpane install client",
' pipx run runpane install daemon --label "My Server"',
- ' uvx runpane@latest install daemon --label "My Server"',
- ' python -m runpane install daemon --label "My Server"',
+ " uvx runpane@latest",
"",
'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
index 659145c1..c826858f 100644
--- a/packages/runpane-py/src/runpane/doctor.py
+++ b/packages/runpane-py/src/runpane/doctor.py
@@ -34,5 +34,5 @@ def run_doctor(parsed, source: str = "pip") -> int:
else:
print("Installed Pane: not found")
- print('Remote setup: run "runpane install daemon --label " to configure a headless host.')
+ print('Remote setup: run "runpane setup" for guided setup, or "runpane install daemon --label " for scripting.')
return 0 if ok else 1
diff --git a/packages/runpane/README.md b/packages/runpane/README.md
index b5e3dbad..6bba43d2 100644
--- a/packages/runpane/README.md
+++ b/packages/runpane/README.md
@@ -1,40 +1,68 @@
# runpane
-Thin npm installer and remote setup CLI for Pane.
+Install or configure Pane from npm.
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
+## Quick Start
-One-shot install:
+Run the guided setup:
```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"
+npx --yes runpane@latest
```
Persistent install:
```bash
npm i -g runpane
-runpane install daemon --label "My Server"
+runpane setup
+```
-pnpm add -g runpane
-runpane install daemon --label "My Server"
+The wizard can install Pane on this machine, configure this machine as a remote
+host, update Pane, or run diagnostics.
+
+## Advanced
+
+### Explicit Commands
+
+```bash
+npx --yes runpane@latest setup
+npx --yes runpane@latest install client
+npx --yes runpane@latest install daemon --label "My Server"
+npx --yes runpane@latest update
+npx --yes runpane@latest doctor
```
-Compatible runners:
+`runpane install daemon` installs Pane and then invokes the installed executable
+with `--remote-setup`, preserving the `pane-remote://...` connection-code output.
+
+### Package Managers
+
+One-shot execution:
```bash
-yarn dlx runpane@latest install daemon --label "My Server"
-bunx runpane@latest install daemon --label "My Server"
+pnpm dlx runpane@latest
+yarn dlx runpane@latest
+bunx runpane@latest
```
-## Commands
+Persistent install:
```bash
+npm i -g runpane
+runpane setup
+
+pnpm add -g runpane
+runpane setup
+```
+
+### Commands
+
+```bash
+runpane
+runpane setup
runpane install
runpane install client
runpane install daemon
@@ -44,8 +72,24 @@ 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.
+### Common Options
+
+```bash
+--version
+--format
+--download-dir
+--pane-path
+--dry-run
+--verbose
+```
+
+Daemon setup also forwards Pane remote-host options:
+
+```bash
+--label
+--prefer-tunnel
+--print-only
+```
## Attribution
diff --git a/packages/runpane/src/cli.ts b/packages/runpane/src/cli.ts
index ac8c42c0..dbbbef34 100644
--- a/packages/runpane/src/cli.ts
+++ b/packages/runpane/src/cli.ts
@@ -1,4 +1,7 @@
#!/usr/bin/env node
+import * as os from 'node:os';
+import { stdin as input, stdout as output } from 'node:process';
+import { createInterface } from 'node:readline/promises';
import { helpText, parseRunpaneArgs, type ParsedArgs } from './commands';
import { downloadArtifact } from './download';
import { runDoctor } from './doctor';
@@ -16,6 +19,10 @@ import { printVersion } from './version';
const SOURCE = 'npm' as const;
export async function main(argv: string[]): Promise {
+ if (argv.length === 0) {
+ return runNoArgsEntrypoint();
+ }
+
const parsed = parseRunpaneArgs(argv);
if (parsed.command === 'help') {
@@ -23,6 +30,10 @@ export async function main(argv: string[]): Promise {
return 0;
}
+ if (parsed.command === 'setup') {
+ return runNoArgsEntrypoint();
+ }
+
if (parsed.command === 'version') {
return printVersion(parsed.panePath);
}
@@ -39,16 +50,157 @@ export async function main(argv: string[]): Promise {
return 0;
}
+async function runNoArgsEntrypoint(): Promise {
+ if (!isInteractiveShell()) {
+ console.log(helpText());
+ return 0;
+ }
+
+ return runInteractiveWizard();
+}
+
+function isInteractiveShell(): boolean {
+ return Boolean(input.isTTY && output.isTTY && !process.env.CI);
+}
+
+async function runInteractiveWizard(): Promise {
+ const rl = createInterface({ input, output });
+
+ try {
+ console.log('Pane setup');
+ console.log('Choose what this machine should do. You can rerun setup any time.');
+ console.log('');
+ console.log('1) Install Pane desktop app on this machine');
+ console.log('2) Set up this machine as a remote host');
+ console.log('3) Update Pane desktop app');
+ console.log('4) Run diagnostics');
+ console.log('');
+
+ const action = await askChoice(rl, 'Choose an action [1]: ', {
+ '': 'client',
+ '1': 'client',
+ client: 'client',
+ install: 'client',
+ desktop: 'client',
+ '2': 'daemon',
+ daemon: 'daemon',
+ remote: 'daemon',
+ host: 'daemon',
+ '3': 'update',
+ update: 'update',
+ '4': 'doctor',
+ doctor: 'doctor',
+ diagnostics: 'doctor'
+ });
+
+ if (action === 'client') {
+ console.log('');
+ console.log('Installing Pane desktop app on this machine...');
+ return installOrUpdate(createParsedArgs('install', { target: 'client' }));
+ }
+
+ if (action === 'update') {
+ console.log('');
+ console.log('Updating Pane desktop app on this machine...');
+ return installOrUpdate(createParsedArgs('update', { target: 'client' }));
+ }
+
+ if (action === 'doctor') {
+ console.log('');
+ console.log('Running runpane diagnostics...');
+ return runDoctor(createParsedArgs('doctor'), SOURCE);
+ }
+
+ console.log('');
+ console.log('A remote host runs your repos, terminals, agents, and git state.');
+ console.log('Your desktop Pane or browser client connects with the generated pane-remote:// code.');
+
+ const defaultLabel = os.hostname() || 'Remote Host';
+ const label = (await rl.question(`Remote host label [${defaultLabel}]: `)).trim() || defaultLabel;
+
+ console.log('');
+ console.log('Connection method:');
+ console.log('1) auto');
+ console.log('2) tailscale');
+ console.log('3) ssh');
+ console.log('4) manual');
+ console.log('');
+ console.log('Use auto unless you already know you want Tailscale, SSH, or a manual URL.');
+ console.log('');
+
+ const tunnel = await askChoice(rl, 'Choose a connection method [1]: ', {
+ '': 'auto',
+ '1': 'auto',
+ auto: 'auto',
+ '2': 'tailscale',
+ tailscale: 'tailscale',
+ '3': 'ssh',
+ ssh: 'ssh',
+ '4': 'manual',
+ manual: 'manual'
+ });
+
+ const remoteSetupArgs = ['--label', label];
+ if (tunnel !== 'auto') {
+ remoteSetupArgs.push('--prefer-tunnel', tunnel);
+ }
+
+ console.log('');
+ console.log('Setting up this machine as a Pane remote host...');
+ console.log('When setup finishes, paste the printed pane-remote:// code into Pane or runpane.com/app.');
+
+ return installOrUpdate(createParsedArgs('install', {
+ target: 'daemon',
+ remoteSetupArgs
+ }));
+ } finally {
+ rl.close();
+ }
+}
+
+async function askChoice(
+ rl: ReturnType,
+ prompt: string,
+ choices: Record
+): Promise {
+ while (true) {
+ const answer = (await rl.question(prompt)).trim().toLowerCase();
+ const choice = choices[answer];
+ if (choice) {
+ return choice;
+ }
+ console.log(`Choose one of: ${Object.keys(choices).filter(Boolean).join(', ')}`);
+ }
+}
+
+function createParsedArgs(command: ParsedArgs['command'], overrides: Partial = {}): ParsedArgs {
+ return {
+ command,
+ target: 'client',
+ paneVersion: 'latest',
+ channel: 'stable',
+ format: 'auto',
+ dryRun: false,
+ yes: false,
+ verbose: false,
+ remoteSetupArgs: [],
+ ...overrides
+ };
+}
+
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) {
+ console.log(`runpane: using existing Pane executable at ${existing}`);
+ console.log('runpane: starting remote setup...');
return spawnPane(existing, ['--remote-setup', ...parsed.remoteSetupArgs]);
}
}
const platform = detectPlatform();
+ console.log(`runpane: resolving Pane release ${parsed.paneVersion}...`);
const resolved = await resolveRelease({
version: parsed.paneVersion,
channel: parsed.channel,
@@ -63,7 +215,11 @@ export async function installOrUpdate(parsed: ParsedArgs): Promise {
return 0;
}
+ console.log(`runpane: selected ${resolved.artifact.name}`);
+ console.log(`runpane: downloading ${resolved.artifact.name}...`);
const artifact = await downloadArtifact(resolved, parsed.downloadDir, parsed.verbose);
+ console.log(`runpane: downloaded ${artifact.fileName}${artifact.usedFallback ? ' from GitHub fallback' : ''}`);
+ console.log('runpane: installing Pane...');
const installed = await installPaneArtifact(artifact, {
parsed,
platform,
@@ -72,6 +228,7 @@ export async function installOrUpdate(parsed: ParsedArgs): Promise {
});
if (target === 'daemon') {
+ console.log('runpane: starting remote setup...');
return spawnPane(installed.executablePath, ['--remote-setup', ...parsed.remoteSetupArgs]);
}
diff --git a/packages/runpane/src/commands.ts b/packages/runpane/src/commands.ts
index 3122e478..f773eb32 100644
--- a/packages/runpane/src/commands.ts
+++ b/packages/runpane/src/commands.ts
@@ -1,4 +1,4 @@
-export type RunpaneCommand = 'help' | 'install' | 'update' | 'version' | 'doctor';
+export type RunpaneCommand = 'help' | 'setup' | 'install' | 'update' | 'version' | 'doctor';
export type InstallTarget = 'client' | 'daemon';
export type ArtifactFormat = 'auto' | 'appimage' | 'deb' | 'dmg' | 'zip' | 'exe';
@@ -17,7 +17,7 @@ export interface ParsedArgs {
remoteSetupArgs: string[];
}
-const COMMANDS = new Set(['help', 'install', 'update', 'version', 'doctor']);
+const COMMANDS = new Set(['help', 'setup', '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']);
@@ -235,6 +235,19 @@ export function helpText(topic?: string): string {
].join('\n');
}
+ if (topic === 'setup') {
+ return [
+ 'Usage:',
+ ' runpane setup',
+ '',
+ 'Opens the guided setup for desktop install, remote host setup, update, and diagnostics.',
+ '',
+ 'Quick start:',
+ ' npx --yes runpane@latest',
+ ' npm i -g runpane && runpane setup'
+ ].join('\n');
+ }
+
if (topic === 'update') {
return [
'Usage:',
@@ -263,20 +276,23 @@ export function helpText(topic?: string): string {
return [
'Usage:',
+ ' runpane',
+ ' runpane setup',
' 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"',
+ 'Quick start:',
+ ' npx --yes runpane@latest',
+ ' npm i -g runpane && runpane setup',
'',
- 'Python package equivalents:',
- ' pipx run runpane install daemon --label "My Server"',
- ' uvx runpane@latest install daemon --label "My Server"',
+ 'Advanced examples:',
+ ' npx --yes runpane@latest install client',
+ ' npx --yes runpane@latest install daemon --label "My Server"',
+ ' pnpm dlx runpane@latest',
+ ' pipx run runpane',
'',
'Run "runpane help install" for install options.'
].join('\n');
diff --git a/packages/runpane/src/doctor.ts b/packages/runpane/src/doctor.ts
index 9219c650..a758dbcb 100644
--- a/packages/runpane/src/doctor.ts
+++ b/packages/runpane/src/doctor.ts
@@ -36,6 +36,6 @@ export async function runDoctor(parsed: ParsedArgs, source: 'npm' | 'pip' = 'npm
console.log('Installed Pane: not found');
}
- console.log('Remote setup: run "runpane install daemon --label " to configure a headless host.');
+ console.log('Remote setup: run "runpane setup" for guided setup, or "runpane install daemon --label " for scripting.');
return ok ? 0 : 1;
}
diff --git a/scripts/test-runpane-contract.js b/scripts/test-runpane-contract.js
index d6c553aa..06d284c4 100644
--- a/scripts/test-runpane-contract.js
+++ b/scripts/test-runpane-contract.js
@@ -10,6 +10,7 @@ const npmCli = path.join(rootDir, 'packages', 'runpane', 'dist', 'cli.js');
const pythonSource = path.join(rootDir, 'packages', 'runpane-py', 'src');
const parserSamples = [
+ ['setup'],
['install'],
['install', 'client', '--version', 'v2.2.8', '--format', 'dmg', '--download-dir', '/tmp/pane-downloads', '--dry-run', '--yes'],
[
@@ -439,7 +440,8 @@ try:
finally:
os.unlink(handle.name)
`);
- assert.deepStrictEqual(JSON.parse(pythonOutput), {
+ const pythonJson = pythonOutput.split(/\r?\n/).filter(Boolean).pop();
+ assert.deepStrictEqual(JSON.parse(pythonJson), {
code: 0,
captured: {
matchesExisting: true,
@@ -464,13 +466,15 @@ function checkHelpOutput() {
});
for (const output of [nodeHelp, pyHelp]) {
- for (const text of ['runpane install', 'runpane update', 'runpane version', 'runpane doctor']) {
+ for (const text of ['runpane setup', 'runpane install', 'runpane update', 'runpane version', 'runpane doctor']) {
assertIncludes(output, text);
}
}
assertIncludes(nodeHelp, 'pnpm dlx runpane@latest');
assertIncludes(pyHelp, 'pipx run runpane');
+ assertIncludes(nodeHelp, 'npx --yes runpane@latest');
+ assertIncludes(pyHelp, 'python -m pip install runpane && python -m runpane setup');
for (const output of [nodeInstallHelp, pyInstallHelp]) {
for (const text of [
@@ -487,6 +491,27 @@ function checkHelpOutput() {
}
}
+function checkNoArgsAndSetupFallback() {
+ const python = findPython();
+ const pythonEnv = {
+ ...process.env,
+ PYTHONPATH: pythonSource
+ };
+
+ const outputs = [
+ childProcess.execFileSync(process.execPath, [npmCli], { encoding: 'utf8' }),
+ childProcess.execFileSync(process.execPath, [npmCli, 'setup'], { encoding: 'utf8' }),
+ childProcess.execFileSync(python, ['-m', 'runpane'], { encoding: 'utf8', env: pythonEnv, cwd: rootDir }),
+ childProcess.execFileSync(python, ['-m', 'runpane', 'setup'], { encoding: 'utf8', env: pythonEnv, cwd: rootDir })
+ ];
+
+ for (const output of outputs) {
+ assertIncludes(output, 'Usage:');
+ assertIncludes(output, 'runpane setup');
+ assertIncludes(output, 'Quick start:');
+ }
+}
+
async function runChecks() {
ensureBuiltCli();
compareParserParity();
@@ -497,6 +522,7 @@ async function runChecks() {
checkPlatformMatchingEdgeCases();
await checkExistingDaemonShortCircuit();
checkHelpOutput();
+ checkNoArgsAndSetupFallback();
console.log('runpane CLI contract checks passed');
}
diff --git a/scripts/test-runpane-package-smoke.js b/scripts/test-runpane-package-smoke.js
index abbdc4e0..c5f80863 100644
--- a/scripts/test-runpane-package-smoke.js
+++ b/scripts/test-runpane-package-smoke.js
@@ -77,13 +77,17 @@ function smokeNpmPackage(tarball) {
fs.mkdirSync(pnpmInstallDir);
run('npx', ['--yes', '--package', tarball, 'runpane', '--help']);
+ run('npx', ['--yes', '--package', tarball, 'runpane'], { env: { CI: '1' } });
run('pnpm', ['--package', tarball, 'dlx', 'runpane', '--help']);
+ run('pnpm', ['--package', tarball, 'dlx', 'runpane'], { env: { CI: '1' } });
run('npm', ['install', '--prefix', npmInstallDir, tarball]);
run(packageBin(npmInstallDir), ['--help']);
+ run(packageBin(npmInstallDir), ['setup'], { env: { CI: '1' } });
run('pnpm', ['--dir', pnpmInstallDir, 'add', tarball]);
run(packageBin(pnpmInstallDir), ['--help']);
+ run(packageBin(pnpmInstallDir), ['setup'], { env: { CI: '1' } });
}
function smokePythonPackage() {
@@ -93,6 +97,8 @@ function smokePythonPackage() {
const isolatedPython = venvPython(venvDir);
run(isolatedPython, ['-m', 'pip', 'install', pythonPackageDir]);
run(isolatedPython, ['-m', 'runpane', '--help']);
+ run(isolatedPython, ['-m', 'runpane'], { env: { CI: '1' } });
+ run(isolatedPython, ['-m', 'runpane', 'setup'], { env: { CI: '1' } });
}
try {