diff --git a/install b/install index f995e2d4306..3b9e6d68c56 100755 --- a/install +++ b/install @@ -16,16 +16,19 @@ Usage: install.sh [options] Options: -h, --help Display this help message -v, --version Install a specific version (e.g., 1.0.180) + -b, --binary Install from a local binary instead of downloading --no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.) Examples: curl -fsSL https://opencode.ai/install | bash curl -fsSL https://opencode.ai/install | bash -s -- --version 1.0.180 + ./install --binary /path/to/opencode EOF } requested_version=${VERSION:-} no_modify_path=false +binary_path="" while [[ $# -gt 0 ]]; do case "$1" in @@ -42,6 +45,15 @@ while [[ $# -gt 0 ]]; do exit 1 fi ;; + -b|--binary) + if [[ -n "${2:-}" ]]; then + binary_path="$2" + shift 2 + else + echo -e "${RED}Error: --binary requires a path argument${NC}" + exit 1 + fi + ;; --no-modify-path) no_modify_path=true shift @@ -53,119 +65,128 @@ while [[ $# -gt 0 ]]; do esac done -raw_os=$(uname -s) -os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]') -case "$raw_os" in - Darwin*) os="darwin" ;; - Linux*) os="linux" ;; - MINGW*|MSYS*|CYGWIN*) os="windows" ;; -esac - -arch=$(uname -m) -if [[ "$arch" == "aarch64" ]]; then - arch="arm64" -fi -if [[ "$arch" == "x86_64" ]]; then - arch="x64" -fi +INSTALL_DIR=$HOME/.opencode/bin +mkdir -p "$INSTALL_DIR" -if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then - rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0) - if [ "$rosetta_flag" = "1" ]; then - arch="arm64" - fi -fi +# If --binary is provided, skip all download/detection logic +if [ -n "$binary_path" ]; then + if [ ! -f "$binary_path" ]; then + echo -e "${RED}Error: Binary not found at ${binary_path}${NC}" + exit 1 + fi + specific_version="local" +else + raw_os=$(uname -s) + os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]') + case "$raw_os" in + Darwin*) os="darwin" ;; + Linux*) os="linux" ;; + MINGW*|MSYS*|CYGWIN*) os="windows" ;; + esac -combo="$os-$arch" -case "$combo" in - linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64) - ;; - *) - echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}" - exit 1 - ;; -esac + arch=$(uname -m) + if [[ "$arch" == "aarch64" ]]; then + arch="arm64" + fi + if [[ "$arch" == "x86_64" ]]; then + arch="x64" + fi -archive_ext=".zip" -if [ "$os" = "linux" ]; then - archive_ext=".tar.gz" -fi + if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then + rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0) + if [ "$rosetta_flag" = "1" ]; then + arch="arm64" + fi + fi -is_musl=false -if [ "$os" = "linux" ]; then - if [ -f /etc/alpine-release ]; then - is_musl=true - fi + combo="$os-$arch" + case "$combo" in + linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64) + ;; + *) + echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}" + exit 1 + ;; + esac - if command -v ldd >/dev/null 2>&1; then - if ldd --version 2>&1 | grep -qi musl; then - is_musl=true + archive_ext=".zip" + if [ "$os" = "linux" ]; then + archive_ext=".tar.gz" fi - fi -fi -needs_baseline=false -if [ "$arch" = "x64" ]; then - if [ "$os" = "linux" ]; then - if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then - needs_baseline=true - fi - fi + is_musl=false + if [ "$os" = "linux" ]; then + if [ -f /etc/alpine-release ]; then + is_musl=true + fi - if [ "$os" = "darwin" ]; then - avx2=$(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 0) - if [ "$avx2" != "1" ]; then - needs_baseline=true + if command -v ldd >/dev/null 2>&1; then + if ldd --version 2>&1 | grep -qi musl; then + is_musl=true + fi + fi fi - fi -fi -target="$os-$arch" -if [ "$needs_baseline" = "true" ]; then - target="$target-baseline" -fi -if [ "$is_musl" = "true" ]; then - target="$target-musl" -fi - -filename="$APP-$target$archive_ext" + needs_baseline=false + if [ "$arch" = "x64" ]; then + if [ "$os" = "linux" ]; then + if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then + needs_baseline=true + fi + fi + if [ "$os" = "darwin" ]; then + avx2=$(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 0) + if [ "$avx2" != "1" ]; then + needs_baseline=true + fi + fi + fi -if [ "$os" = "linux" ]; then - if ! command -v tar >/dev/null 2>&1; then - echo -e "${RED}Error: 'tar' is required but not installed.${NC}" - exit 1 + target="$os-$arch" + if [ "$needs_baseline" = "true" ]; then + target="$target-baseline" fi -else - if ! command -v unzip >/dev/null 2>&1; then - echo -e "${RED}Error: 'unzip' is required but not installed.${NC}" - exit 1 + if [ "$is_musl" = "true" ]; then + target="$target-musl" fi -fi -INSTALL_DIR=$HOME/.opencode/bin -mkdir -p "$INSTALL_DIR" + filename="$APP-$target$archive_ext" -if [ -z "$requested_version" ]; then - url="https://github.com/anomalyco/opencode/releases/latest/download/$filename" - specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p') - if [[ $? -ne 0 || -z "$specific_version" ]]; then - echo -e "${RED}Failed to fetch version information${NC}" - exit 1 + if [ "$os" = "linux" ]; then + if ! command -v tar >/dev/null 2>&1; then + echo -e "${RED}Error: 'tar' is required but not installed.${NC}" + exit 1 + fi + else + if ! command -v unzip >/dev/null 2>&1; then + echo -e "${RED}Error: 'unzip' is required but not installed.${NC}" + exit 1 + fi fi -else - # Strip leading 'v' if present - requested_version="${requested_version#v}" - url="https://github.com/anomalyco/opencode/releases/download/v${requested_version}/$filename" - specific_version=$requested_version - - # Verify the release exists before downloading - http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/opencode/releases/tag/v${requested_version}") - if [ "$http_status" = "404" ]; then - echo -e "${RED}Error: Release v${requested_version} not found${NC}" - echo -e "${MUTED}Available releases: https://github.com/anomalyco/opencode/releases${NC}" - exit 1 + + if [ -z "$requested_version" ]; then + url="https://github.com/anomalyco/opencode/releases/latest/download/$filename" + specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p') + + if [[ $? -ne 0 || -z "$specific_version" ]]; then + echo -e "${RED}Failed to fetch version information${NC}" + exit 1 + fi + else + # Strip leading 'v' if present + requested_version="${requested_version#v}" + url="https://github.com/anomalyco/opencode/releases/download/v${requested_version}/$filename" + specific_version=$requested_version + + # Verify the release exists before downloading + http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/opencode/releases/tag/v${requested_version}") + if [ "$http_status" = "404" ]; then + echo -e "${RED}Error: Release v${requested_version} not found${NC}" + echo -e "${MUTED}Available releases: https://github.com/anomalyco/opencode/releases${NC}" + exit 1 + fi fi fi @@ -267,11 +288,11 @@ download_with_progress() { { local length=0 local bytes=0 - + while IFS=" " read -r -a line; do [ "${#line[@]}" -lt 2 ] && continue local tag="${line[0]} ${line[1]}" - + if [ "$tag" = "0000: content-length:" ]; then length="${line[2]}" length=$(echo "$length" | tr -d '\r') @@ -296,7 +317,7 @@ download_and_install() { print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}version: ${NC}$specific_version" local tmp_dir="${TMPDIR:-/tmp}/opencode_install_$$" mkdir -p "$tmp_dir" - + if [[ "$os" == "windows" ]] || ! [ -t 2 ] || ! download_with_progress "$url" "$tmp_dir/$filename"; then # Fallback to standard curl on Windows, non-TTY environments, or if custom progress fails curl -# -L -o "$tmp_dir/$filename" "$url" @@ -307,14 +328,24 @@ download_and_install() { else unzip -q "$tmp_dir/$filename" -d "$tmp_dir" fi - + mv "$tmp_dir/opencode" "$INSTALL_DIR" chmod 755 "${INSTALL_DIR}/opencode" rm -rf "$tmp_dir" } -check_version -download_and_install +install_from_binary() { + print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}from: ${NC}$binary_path" + cp "$binary_path" "${INSTALL_DIR}/opencode" + chmod 755 "${INSTALL_DIR}/opencode" +} + +if [ -n "$binary_path" ]; then + install_from_binary +else + check_version + download_and_install +fi add_to_path() { @@ -416,4 +447,3 @@ echo -e "" echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/docs" echo -e "" echo -e "" - diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 11afce91e25..cd7a4226c24 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2777,6 +2777,7 @@ version = "0.0.0" dependencies = [ "gtk", "listeners", + "semver", "serde", "serde_json", "tauri", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index b7c238f064f..9afeee94597 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -35,6 +35,7 @@ serde_json = "1" tokio = "1.48.0" listeners = "0.3" tauri-plugin-os = "2" +semver = "1.0.27" [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18.2" diff --git a/packages/desktop/src-tauri/src/cli.rs b/packages/desktop/src-tauri/src/cli.rs new file mode 100644 index 00000000000..6b86cbcd2c3 --- /dev/null +++ b/packages/desktop/src-tauri/src/cli.rs @@ -0,0 +1,116 @@ +const CLI_INSTALL_DIR: &str = ".opencode/bin"; +const CLI_BINARY_NAME: &str = "opencode"; + +fn get_cli_install_path() -> Option { + std::env::var("HOME").ok().map(|home| { + std::path::PathBuf::from(home) + .join(CLI_INSTALL_DIR) + .join(CLI_BINARY_NAME) + }) +} + +pub fn get_sidecar_path() -> std::path::PathBuf { + tauri::utils::platform::current_exe() + .expect("Failed to get current exe") + .parent() + .expect("Failed to get parent dir") + .join("opencode-cli") +} + +fn is_cli_installed() -> bool { + get_cli_install_path() + .map(|path| path.exists()) + .unwrap_or(false) +} + +const INSTALL_SCRIPT: &str = include_str!("../../../../install"); + +#[tauri::command] +pub fn install_cli() -> Result { + if cfg!(not(unix)) { + return Err("CLI installation is only supported on macOS & Linux".to_string()); + } + + let sidecar = get_sidecar_path(); + if !sidecar.exists() { + return Err("Sidecar binary not found".to_string()); + } + + let temp_script = std::env::temp_dir().join("opencode-install.sh"); + std::fs::write(&temp_script, INSTALL_SCRIPT) + .map_err(|e| format!("Failed to write install script: {}", e))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&temp_script, std::fs::Permissions::from_mode(0o755)) + .map_err(|e| format!("Failed to set script permissions: {}", e))?; + } + + let output = std::process::Command::new(&temp_script) + .arg("--binary") + .arg(&sidecar) + .output() + .map_err(|e| format!("Failed to run install script: {}", e))?; + + let _ = std::fs::remove_file(&temp_script); + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Install script failed: {}", stderr)); + } + + let install_path = + get_cli_install_path().ok_or_else(|| "Could not determine install path".to_string())?; + + Ok(install_path.to_string_lossy().to_string()) +} + +pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> { + if cfg!(debug_assertions) { + println!("Skipping CLI sync for debug build"); + return Ok(()); + } + + if !is_cli_installed() { + println!("No CLI installation found, skipping sync"); + return Ok(()); + } + + let cli_path = + get_cli_install_path().ok_or_else(|| "Could not determine CLI install path".to_string())?; + + let output = std::process::Command::new(&cli_path) + .arg("--version") + .output() + .map_err(|e| format!("Failed to get CLI version: {}", e))?; + + if !output.status.success() { + return Err("Failed to get CLI version".to_string()); + } + + let cli_version_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let cli_version = semver::Version::parse(&cli_version_str) + .map_err(|e| format!("Failed to parse CLI version '{}': {}", cli_version_str, e))?; + + let app_version = app.package_info().version.clone(); + + if cli_version >= app_version { + println!( + "CLI version {} is up to date (app version: {}), skipping sync", + cli_version, app_version + ); + return Ok(()); + } + + println!( + "CLI version {} is older than app version {}, syncing", + cli_version, app_version + ); + + install_cli()?; + + println!("Synced installed CLI"); + + Ok(()) +} diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 46c0ab256db..4012fe1a587 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -1,12 +1,16 @@ +mod cli; mod window_customizer; +use cli::{get_sidecar_path, install_cli, sync_cli}; use std::{ collections::VecDeque, net::{SocketAddr, TcpListener}, sync::{Arc, Mutex}, time::{Duration, Instant}, }; -use tauri::{AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow, path::BaseDirectory}; +use tauri::{ + path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow, +}; use tauri_plugin_clipboard_manager::ClipboardExt; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult}; use tauri_plugin_shell::process::{CommandChild, CommandEvent}; @@ -116,11 +120,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild { #[cfg(not(target_os = "windows"))] let (mut rx, child) = { - let sidecar_path = tauri::utils::platform::current_exe() - .expect("Failed to get current exe") - .parent() - .expect("Failed to get parent dir") - .join("opencode-cli"); + let sidecar = get_sidecar_path(); let shell = get_user_shell(); app.shell() .command(&shell) @@ -130,7 +130,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild { .args([ "-il", "-c", - &format!("{} serve --port={}", sidecar_path.display(), port), + &format!("{} serve --port={}", sidecar.display(), port), ]) .spawn() .expect("Failed to spawn opencode") @@ -203,7 +203,8 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ kill_sidecar, copy_logs_to_clipboard, - get_logs + get_logs, + install_cli ]) .setup(move |app| { let app = app.handle().clone(); @@ -211,83 +212,95 @@ pub fn run() { // Initialize log state app.manage(LogState(Arc::new(Mutex::new(VecDeque::new())))); - tauri::async_runtime::spawn(async move { - let port = get_sidecar_port(); - - let should_spawn_sidecar = !is_server_running(port).await; - - let child = if should_spawn_sidecar { - let child = spawn_sidecar(&app, port); - - let timestamp = Instant::now(); - loop { - if timestamp.elapsed() > Duration::from_secs(7) { - let res = app.dialog() - .message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.") - .title("Startup Failed") - .buttons(MessageDialogButtons::OkCancelCustom("Copy Logs And Exit".to_string(), "Exit".to_string())) - .blocking_show_with_result(); - - if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") { - match copy_logs_to_clipboard(app.clone()).await { - Ok(()) => println!("Logs copied to clipboard successfully"), - Err(e) => println!("Failed to copy logs to clipboard: {}", e), - } - } - - app.exit(1); - - return; - } - - tokio::time::sleep(Duration::from_millis(10)).await; - - if is_server_running(port).await { - // give the server a little bit more time to warm up - tokio::time::sleep(Duration::from_millis(10)).await; - - break; - } - } + { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let port = get_sidecar_port(); + + let should_spawn_sidecar = !is_server_running(port).await; + + let child = if should_spawn_sidecar { + let child = spawn_sidecar(&app, port); + + let timestamp = Instant::now(); + loop { + if timestamp.elapsed() > Duration::from_secs(7) { + let res = app.dialog() + .message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.") + .title("Startup Failed") + .buttons(MessageDialogButtons::OkCancelCustom("Copy Logs And Exit".to_string(), "Exit".to_string())) + .blocking_show_with_result(); + + if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") { + match copy_logs_to_clipboard(app.clone()).await { + Ok(()) => println!("Logs copied to clipboard successfully"), + Err(e) => println!("Failed to copy logs to clipboard: {}", e), + } + } + + app.exit(1); + + return; + } + + tokio::time::sleep(Duration::from_millis(10)).await; + + if is_server_running(port).await { + // give the server a little bit more time to warm up + tokio::time::sleep(Duration::from_millis(10)).await; + + break; + } + } + + println!("Server ready after {:?}", timestamp.elapsed()); + + Some(child) + } else { + None + }; + + let primary_monitor = app.primary_monitor().ok().flatten(); + let size = primary_monitor + .map(|m| m.size().to_logical(m.scale_factor())) + .unwrap_or(LogicalSize::new(1920, 1080)); + + let mut window_builder = + WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into())) + .title("OpenCode") + .inner_size(size.width as f64, size.height as f64) + .decorations(true) + .zoom_hotkeys_enabled(true) + .disable_drag_drop_handler() + .initialization_script(format!( + r#" + window.__OPENCODE__ ??= {{}}; + window.__OPENCODE__.updaterEnabled = {updater_enabled}; + window.__OPENCODE__.port = {port}; + "# + )); + + #[cfg(target_os = "macos")] + { + window_builder = window_builder + .title_bar_style(tauri::TitleBarStyle::Overlay) + .hidden_title(true); + } + + window_builder.build().expect("Failed to create window"); + + app.manage(ServerState(Arc::new(Mutex::new(child)))); + }); + } - println!("Server ready after {:?}", timestamp.elapsed()); - - Some(child) - } else { - None - }; - - let primary_monitor = app.primary_monitor().ok().flatten(); - let size = primary_monitor - .map(|m| m.size().to_logical(m.scale_factor())) - .unwrap_or(LogicalSize::new(1920, 1080)); - - let mut window_builder = - WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into())) - .title("OpenCode") - .inner_size(size.width as f64, size.height as f64) - .decorations(true) - .zoom_hotkeys_enabled(true) - .disable_drag_drop_handler() - .initialization_script(format!( - r#" - window.__OPENCODE__ ??= {{}}; - window.__OPENCODE__.updaterEnabled = {updater_enabled}; - window.__OPENCODE__.port = {port}; - "# - )); - - #[cfg(target_os = "macos")] - { - window_builder = window_builder - .title_bar_style(tauri::TitleBarStyle::Overlay) - .hidden_title(true); + { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = sync_cli(app) { + eprintln!("Failed to sync CLI: {e}"); } - - window_builder.build().expect("Failed to create window"); - - app.manage(ServerState(Arc::new(Mutex::new(child)))); - }); + }); + } Ok(()) }); diff --git a/packages/desktop/src/cli.ts b/packages/desktop/src/cli.ts new file mode 100644 index 00000000000..965ed6ddc00 --- /dev/null +++ b/packages/desktop/src/cli.ts @@ -0,0 +1,13 @@ +import { invoke } from "@tauri-apps/api/core" +import { message } from "@tauri-apps/plugin-dialog" + +export async function installCli(): Promise { + try { + const path = await invoke("install_cli") + await message(`CLI installed to ${path}\n\nRestart your terminal to use the 'opencode' command.`, { + title: "CLI Installed", + }) + } catch (e) { + await message(`Failed to install CLI: ${e}`, { title: "Installation Failed" }) + } +} diff --git a/packages/desktop/src/menu.ts b/packages/desktop/src/menu.ts index d1a5fba8e3a..bf9ca4b8a8b 100644 --- a/packages/desktop/src/menu.ts +++ b/packages/desktop/src/menu.ts @@ -2,6 +2,7 @@ import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/men import { type as ostype } from "@tauri-apps/plugin-os" import { runUpdater, UPDATER_ENABLED } from "./updater" +import { installCli } from "./cli" export async function createMenu() { if (ostype() !== "macos") return @@ -19,6 +20,10 @@ export async function createMenu() { action: () => runUpdater({ alertOnFail: true }), text: "Check For Updates...", }), + await MenuItem.new({ + action: () => installCli(), + text: "Install CLI...", + }), await PredefinedMenuItem.new({ item: "Separator", }),