diff --git a/crates/plano-cli/src/commands/mod.rs b/crates/plano-cli/src/commands/mod.rs index 188b577f..380f2a64 100644 --- a/crates/plano-cli/src/commands/mod.rs +++ b/crates/plano-cli/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod cli_agent; pub mod down; pub mod init; pub mod logs; +pub mod self_update; pub mod up; use clap::{Parser, Subcommand}; @@ -132,6 +133,14 @@ pub enum Command { #[arg(long)] list_templates: bool, }, + + /// Update planoai to the latest version + #[command(name = "self-update")] + SelfUpdate { + /// Update to a specific version instead of latest + #[arg(long)] + version: Option, + }, } #[derive(Subcommand)] @@ -252,6 +261,7 @@ pub async fn run(cli: Cli) -> anyhow::Result<()> { force, list_templates, }) => init::run(template, clean, output, force, list_templates).await, + Some(Command::SelfUpdate { version }) => self_update::run(version.as_deref()).await, } } diff --git a/crates/plano-cli/src/commands/self_update.rs b/crates/plano-cli/src/commands/self_update.rs new file mode 100644 index 00000000..6d9b28ea --- /dev/null +++ b/crates/plano-cli/src/commands/self_update.rs @@ -0,0 +1,139 @@ +use std::fs; +use std::os::unix::fs::PermissionsExt; + +use anyhow::{bail, Result}; + +use crate::consts::{PLANO_GITHUB_REPO, PLANO_VERSION}; + +pub async fn run(target_version: Option<&str>) -> Result<()> { + let green = console::Style::new().green(); + let bold = console::Style::new().bold(); + let dim = console::Style::new().dim(); + let cyan = console::Style::new().cyan(); + + println!( + "\n{} {}", + bold.apply_to("planoai"), + dim.apply_to("self-update") + ); + + // Determine target version + let version = if let Some(v) = target_version { + v.to_string() + } else { + println!(" {}", dim.apply_to("Checking for latest version...")); + fetch_latest_version() + .await? + .ok_or_else(|| anyhow::anyhow!("Could not determine latest version"))? + }; + + let current = PLANO_VERSION; + if version == current && target_version.is_none() { + println!( + "\n {} Already up to date ({})", + green.apply_to("✓"), + cyan.apply_to(current) + ); + return Ok(()); + } + + println!( + " {} → {}", + dim.apply_to(format!("Current: {current}")), + cyan.apply_to(&version) + ); + + // Detect platform + let platform = get_platform_slug()?; + + // Download URL + let url = format!( + "https://github.com/{PLANO_GITHUB_REPO}/releases/download/{version}/planoai-{platform}.gz" + ); + + println!(" {}", dim.apply_to(format!("Downloading from {url}..."))); + + let client = reqwest::Client::new(); + let resp = client.get(&url).send().await?; + + if !resp.status().is_success() { + bail!( + "Download failed: HTTP {}. Version {} may not exist for platform {}.", + resp.status(), + version, + platform + ); + } + + let gz_bytes = resp.bytes().await?; + + // Decompress + let mut decoder = flate2::read::GzDecoder::new(&gz_bytes[..]); + let mut binary_data = Vec::new(); + std::io::copy(&mut decoder, &mut binary_data)?; + + // Find current binary path + let current_exe = std::env::current_exe()?; + let exe_path = current_exe.canonicalize()?; + + println!( + " {}", + dim.apply_to(format!("Installing to {}", exe_path.display())) + ); + + // Write to a temp file next to the binary, then atomically rename + let tmp_path = exe_path.with_extension("update-tmp"); + fs::write(&tmp_path, &binary_data)?; + fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o755))?; + + // Atomic replace + fs::rename(&tmp_path, &exe_path)?; + + println!( + "\n {} Updated planoai to {}\n", + green.apply_to("✓"), + bold.apply_to(&version) + ); + + Ok(()) +} + +async fn fetch_latest_version() -> Result> { + let url = format!("https://api.github.com/repos/{PLANO_GITHUB_REPO}/releases/latest"); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build()?; + + let resp = client + .get(&url) + .header("User-Agent", "planoai-cli") + .send() + .await?; + + if !resp.status().is_success() { + return Ok(None); + } + + let json: serde_json::Value = resp.json().await?; + let tag = json + .get("tag_name") + .and_then(|v| v.as_str()) + .map(|s| s.strip_prefix('v').unwrap_or(s).to_string()); + + Ok(tag) +} + +fn get_platform_slug() -> Result<&'static str> { + let os = std::env::consts::OS; + let arch = std::env::consts::ARCH; + + match (os, arch) { + ("linux", "x86_64") => Ok("linux-amd64"), + ("linux", "aarch64") => Ok("linux-arm64"), + ("macos", "aarch64") => Ok("darwin-arm64"), + ("macos", "x86_64") => { + bail!("macOS x86_64 (Intel) is not supported.") + } + _ => bail!("Unsupported platform: {os}/{arch}"), + } +} diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..019bcfbe --- /dev/null +++ b/install.sh @@ -0,0 +1,132 @@ +#!/bin/bash +set -euo pipefail + +# Plano CLI installer +# Usage: curl -fsSL https://raw.githubusercontent.com/katanemo/plano/main/install.sh | bash + +REPO="katanemo/archgw" +BINARY_NAME="planoai" +INSTALL_DIR="${PLANO_INSTALL_DIR:-$HOME/.plano/bin}" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +DIM='\033[2m' +BOLD='\033[1m' +RESET='\033[0m' + +info() { echo -e "${GREEN}✓${RESET} $*"; } +error() { echo -e "${RED}✗${RESET} $*" >&2; } +dim() { echo -e "${DIM}$*${RESET}"; } + +# Detect platform +detect_platform() { + local os arch + os="$(uname -s)" + arch="$(uname -m)" + + case "$os" in + Linux) os="linux" ;; + Darwin) os="darwin" ;; + *) error "Unsupported OS: $os"; exit 1 ;; + esac + + case "$arch" in + x86_64) arch="amd64" ;; + aarch64) arch="arm64" ;; + arm64) arch="arm64" ;; + *) error "Unsupported architecture: $arch"; exit 1 ;; + esac + + if [ "$os" = "darwin" ] && [ "$arch" = "amd64" ]; then + error "macOS x86_64 (Intel) is not supported. Pre-built binaries are only available for Apple Silicon (arm64)." + exit 1 + fi + + echo "${os}-${arch}" +} + +# Get latest version from GitHub releases +get_latest_version() { + local version + version=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep '"tag_name"' \ + | sed -E 's/.*"([^"]+)".*/\1/') + echo "$version" +} + +main() { + echo -e "\n${BOLD}Plano CLI Installer${RESET}\n" + + # Detect platform + local platform + platform="$(detect_platform)" + dim " Platform: $platform" + + # Get version + local version="${PLANO_VERSION:-}" + if [ -z "$version" ]; then + dim " Fetching latest version..." + version="$(get_latest_version)" + fi + if [ -z "$version" ]; then + error "Could not determine version. Set PLANO_VERSION or check your internet connection." + exit 1 + fi + dim " Version: $version" + + # Download URL + local url="https://github.com/${REPO}/releases/download/${version}/planoai-${platform}.gz" + dim " URL: $url" + echo "" + + # Create install directory + mkdir -p "$INSTALL_DIR" + + # Download and extract + local tmp_gz + tmp_gz="$(mktemp)" + echo -e " ${DIM}Downloading planoai ${version}...${RESET}" + + if ! curl -fSL --progress-bar "$url" -o "$tmp_gz"; then + error "Download failed. Check that version $version exists for platform $platform." + rm -f "$tmp_gz" + exit 1 + fi + + # Decompress + echo -e " ${DIM}Installing to ${INSTALL_DIR}/${BINARY_NAME}...${RESET}" + gzip -d -c "$tmp_gz" > "${INSTALL_DIR}/${BINARY_NAME}" + chmod +x "${INSTALL_DIR}/${BINARY_NAME}" + rm -f "$tmp_gz" + + info "Installed planoai ${version} to ${INSTALL_DIR}/${BINARY_NAME}" + + # Check if install dir is in PATH + if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then + echo "" + echo -e " ${CYAN}Add to your PATH:${RESET}" + local shell_name + shell_name="$(basename "${SHELL:-/bin/bash}")" + local rc_file + case "$shell_name" in + zsh) rc_file="$HOME/.zshrc" ;; + fish) rc_file="$HOME/.config/fish/config.fish" ;; + *) rc_file="$HOME/.bashrc" ;; + esac + + if [ "$shell_name" = "fish" ]; then + echo -e " ${BOLD}set -gx PATH ${INSTALL_DIR} \$PATH${RESET}" + else + echo -e " ${BOLD}export PATH=\"${INSTALL_DIR}:\$PATH\"${RESET}" + fi + echo -e " ${DIM}Add this line to ${rc_file} to make it permanent.${RESET}" + fi + + echo "" + info "Run ${BOLD}planoai --help${RESET} to get started." + echo "" +} + +main "$@"