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 - Download for macOS @@ -58,6 +62,8 @@ Download for Linux +
+

@@ -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 {