From 943d23edcbf8a948d09d4aa328b6072d90f1be4d Mon Sep 17 00:00:00 2001 From: bigph00t Date: Wed, 17 Jun 2026 15:17:43 -0700 Subject: [PATCH 1/2] feat: add native uninstaller (closes #38) Adds `mobilecli uninstall`, which reverses everything install.sh and the setup wizard put in place: - stops the running daemon - removes daemon autostart (systemd / launchd / Task Scheduler) - strips the shell auto-launch hook from rc files - deletes the config dir (~/.mobilecli, incl. paired credentials) - removes the binary itself (Unix unlink-while-running; Windows best-effort) Flags: --yes (skip prompt), --keep-config, --keep-binary. Also adds a standalone uninstall.sh for parity with install.sh: it delegates to `mobilecli uninstall` when the binary is present and falls back to manual cleanup otherwise. README documents both paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 19 +++++ cli/src/main.rs | 11 +++ cli/src/uninstall.rs | 172 +++++++++++++++++++++++++++++++++++++++++++ uninstall.sh | 110 +++++++++++++++++++++++++++ 4 files changed, 312 insertions(+) create mode 100644 cli/src/uninstall.rs create mode 100755 uninstall.sh diff --git a/README.md b/README.md index 830b1b8..59d205e 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ Setup and management: mobilecli credentials revoke Revoke one paired mobile credential mobilecli status Show daemon status, active sessions, connections mobilecli stop Stop the daemon + mobilecli uninstall Remove MobileCLI completely (daemon, autostart, hook, config, binary) Daemon lifecycle: mobilecli daemon [--port PORT] Start daemon manually (default port: 9847) @@ -275,6 +276,22 @@ This adds a one-liner to your `.bashrc`, `.zshrc`, `config.fish`, or PowerShell MOBILECLI_NO_AUTO_LAUNCH=1 bash ``` +### Uninstall + +To remove MobileCLI completely, run the built-in uninstaller: + +```bash +mobilecli uninstall +``` + +This stops the daemon, removes daemon autostart (systemd / launchd / Task Scheduler), strips the shell auto-launch hook from your shell config, deletes the config directory (`~/.mobilecli`, including paired credentials), and removes the binary. Use `--keep-config` to preserve `~/.mobilecli`, `--keep-binary` to leave the binary in place, and `-y`/`--yes` to skip the confirmation prompt. + +If the binary is missing or broken, you can run the standalone uninstall script instead, which delegates to `mobilecli uninstall` when available and otherwise performs the same cleanup manually: + +```bash +curl -fsSL https://raw.githubusercontent.com/MobileCLI/mobilecli/main/uninstall.sh | bash +``` +
## Platform support @@ -384,6 +401,7 @@ MobileCLI/ │ ├── setup.rs # Interactive setup wizard + QR code │ ├── shell_hook.rs # Shell auto-launch integration │ ├── autostart.rs # OS-level daemon autostart +│ ├── uninstall.rs # Native uninstaller (reverses install + setup) │ ├── link.rs # Session attachment (tmux-style) │ ├── session.rs # Session metadata structures │ ├── platform.rs # Cross-platform utilities @@ -391,6 +409,7 @@ MobileCLI/ ├── mobile/ # React Native app (separate git repo) ├── website/ # Marketing site (Astro + Tailwind) ├── install.sh # One-line installer script +├── uninstall.sh # One-line uninstaller script ├── .github/workflows/ # CI, release packaging, Claude Code review └── docs/ # Architecture docs + screenshots ``` diff --git a/cli/src/main.rs b/cli/src/main.rs index 0a50a86..f64720c 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -23,6 +23,7 @@ mod session; mod setup; mod shell_hook; mod tmux; +mod uninstall; use clap::{Parser, Subcommand}; use colored::Colorize; @@ -109,6 +110,9 @@ enum Commands { #[command(subcommand)] command: shell_hook::ShellHookCommand, }, + + /// Remove MobileCLI: stop the daemon, remove autostart, shell hook, config, and binary + Uninstall(uninstall::UninstallArgs), } #[derive(Subcommand)] @@ -228,6 +232,13 @@ async fn main() -> ExitCode { ExitCode::FAILURE } }, + Commands::Uninstall(uninstall_args) => match uninstall::run(uninstall_args.clone()) { + Ok(_) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("{}: {}", "Uninstall error".red().bold(), e); + ExitCode::FAILURE + } + }, }; } diff --git a/cli/src/uninstall.rs b/cli/src/uninstall.rs new file mode 100644 index 0000000..d816a79 --- /dev/null +++ b/cli/src/uninstall.rs @@ -0,0 +1,172 @@ +//! Native uninstaller for MobileCLI. +//! +//! Reverses everything `install.sh` and the setup wizard put in place: +//! - Stops the running daemon +//! - Removes daemon autostart (systemd / launchd / Task Scheduler) +//! - Removes the shell auto-launch hook from rc files +//! - Deletes the config directory (`~/.mobilecli`), including paired credentials +//! - Removes the `mobilecli` binary itself +//! +//! Use `--yes` to skip the confirmation prompt and `--keep-config` to preserve +//! `~/.mobilecli` (paired credentials, sessions, logs). + +use crate::{autostart, daemon, platform, shell_hook}; +use clap::Args; +use colored::Colorize; +use std::io::Write; +use std::path::PathBuf; + +#[derive(Debug, Clone, Default, Args)] +pub struct UninstallArgs { + /// Skip the confirmation prompt + #[arg(long, short = 'y')] + pub yes: bool, + + /// Keep the config directory (~/.mobilecli) and paired credentials + #[arg(long = "keep-config")] + pub keep_config: bool, + + /// Don't remove the mobilecli binary itself + #[arg(long = "keep-binary")] + pub keep_binary: bool, +} + +pub fn run(args: UninstallArgs) -> Result<(), Box> { + let config_dir = platform::config_dir(); + let binary = std::env::current_exe().ok(); + + println!( + "{}", + "This will remove MobileCLI from your system:".bold() + ); + println!(" • Stop the background daemon"); + println!(" • Remove daemon autostart (login service)"); + println!(" • Remove the shell auto-launch hook from your shell config"); + if !args.keep_config { + println!( + " • Delete {} {}", + config_dir.display().to_string().cyan(), + "(paired credentials, sessions, logs)".dimmed() + ); + } + if !args.keep_binary { + if let Some(ref exe) = binary { + println!(" • Remove the binary at {}", exe.display().to_string().cyan()); + } + } + println!(); + + if !args.yes && !confirm("Proceed with uninstall?")? { + println!("{} Uninstall cancelled.", "○".dimmed()); + return Ok(()); + } + + // 1. Stop the daemon so no process keeps the port or config files busy. + if let Some(pid) = daemon::get_pid() { + if platform::terminate_process(pid) { + println!("{} Stopped daemon (PID: {})", "✓".green(), pid); + } else { + eprintln!("{} Could not stop daemon (PID: {})", "!".yellow(), pid); + } + } else { + println!("{} Daemon not running", "·".dimmed()); + } + + // 2. Remove autostart (best-effort; reuses platform-specific logic). + if let Err(e) = autostart::run(autostart::AutostartCommand::Uninstall) { + eprintln!("{} Autostart cleanup: {}", "!".yellow(), e); + } + + // 3. Remove the shell auto-launch hook from rc files. + if let Err(e) = shell_hook::run(shell_hook::ShellHookCommand::Uninstall) { + eprintln!("{} Shell hook cleanup: {}", "!".yellow(), e); + } + + // 4. Delete the config directory (credentials, sessions, logs, uploads). + if args.keep_config { + println!( + "{} Kept config directory: {}", + "·".dimmed(), + config_dir.display().to_string().dimmed() + ); + } else if config_dir.exists() { + match std::fs::remove_dir_all(&config_dir) { + Ok(_) => println!( + "{} Removed config directory: {}", + "✓".green(), + config_dir.display() + ), + Err(e) => eprintln!( + "{} Failed to remove {}: {}", + "✗".red(), + config_dir.display(), + e + ), + } + } else { + println!("{} Config directory already gone", "·".dimmed()); + } + + // 5. Remove the binary itself. + if !args.keep_binary { + if let Some(exe) = binary { + remove_binary(&exe); + } + } + + println!(); + println!("{}", "MobileCLI has been uninstalled. Thanks for trying it!".green()); + Ok(()) +} + +/// Remove the running binary. On Unix the file can be unlinked while the process +/// is still executing (the inode survives until exit). On Windows a running +/// executable cannot be deleted, so we fall back to printing manual instructions. +fn remove_binary(exe: &PathBuf) { + #[cfg(windows)] + { + // Windows locks the running executable; schedule a best-effort delete and + // tell the user how to finish if it fails. + match std::fs::remove_file(exe) { + Ok(_) => println!("{} Removed binary: {}", "✓".green(), exe.display()), + Err(_) => { + println!( + "{} Could not remove the binary while it is running.", + "!".yellow() + ); + println!( + " Delete it manually after this process exits: {}", + exe.display().to_string().cyan() + ); + } + } + } + + #[cfg(not(windows))] + { + match std::fs::remove_file(exe) { + Ok(_) => println!("{} Removed binary: {}", "✓".green(), exe.display()), + Err(e) => { + eprintln!("{} Failed to remove {}: {}", "✗".red(), exe.display(), e); + println!( + " Remove it manually: {}", + format!("rm {}", exe.display()).cyan() + ); + } + } + } +} + +/// Prompt the user for a yes/no confirmation. Defaults to "no" on EOF or empty input. +fn confirm(question: &str) -> std::io::Result { + print!("{} [y/N] ", question); + std::io::stdout().flush()?; + + let mut answer = String::new(); + if std::io::stdin().read_line(&mut answer)? == 0 { + return Ok(false); + } + + let answer = answer.trim().to_lowercase(); + Ok(answer == "y" || answer == "yes") +} diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..5f8872c --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,110 @@ +#!/bin/bash +# MobileCLI Uninstaller (Linux & macOS) +# Usage: curl -fsSL https://raw.githubusercontent.com/MobileCLI/mobilecli/main/uninstall.sh | bash +# +# If the `mobilecli` binary is on your PATH this simply delegates to the built-in +# `mobilecli uninstall`, which is the authoritative cleanup path. If the binary is +# missing or broken, this script falls back to removing everything the installer +# and setup wizard create. + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +BINARY_NAME="mobilecli" +CONFIG_DIR="${HOME}/.mobilecli" + +info() { echo -e "${CYAN}$1${NC}"; } +success() { echo -e "${GREEN}✓ $1${NC}"; } +warn() { echo -e "${YELLOW}⚠ $1${NC}"; } + +info "╔══════════════════════════════════════════════════════════════╗" +info "║ 📱 MobileCLI Uninstaller ║" +info "╚══════════════════════════════════════════════════════════════╝" +echo + +# Preferred path: let the binary uninstall itself (handles autostart, shell hook, +# config, and the binary in one consistent place). +if command -v "$BINARY_NAME" >/dev/null 2>&1; then + info "Found ${BINARY_NAME} on PATH — running its built-in uninstaller..." + if "$BINARY_NAME" uninstall --yes; then + exit 0 + fi + warn "Built-in uninstaller did not finish cleanly; running manual cleanup..." +fi + +# Fallback: manual cleanup when the binary is missing or failed. +warn "Performing manual cleanup." + +# Stop any running daemon by removing its socket/pid is handled by the binary; without +# it we just leave the process to exit. Best-effort kill via the recorded PID file. +if [ -f "${CONFIG_DIR}/daemon.pid" ]; then + pid="$(cat "${CONFIG_DIR}/daemon.pid" 2>/dev/null || true)" + if [ -n "${pid}" ] && kill -0 "${pid}" 2>/dev/null; then + kill "${pid}" 2>/dev/null && success "Stopped daemon (PID: ${pid})" + fi +fi + +# Remove daemon autostart (Linux systemd user unit). +if command -v systemctl >/dev/null 2>&1; then + systemctl --user disable --now mobilecli.service >/dev/null 2>&1 || true +fi +SYSTEMD_UNIT="${HOME}/.config/systemd/user/mobilecli.service" +if [ -f "${SYSTEMD_UNIT}" ]; then + rm -f "${SYSTEMD_UNIT}" + success "Removed systemd unit: ${SYSTEMD_UNIT}" + systemctl --user daemon-reload >/dev/null 2>&1 || true +fi + +# Remove daemon autostart (macOS launchd agent). +LAUNCHD_PLIST="${HOME}/Library/LaunchAgents/com.mobilecli.daemon.plist" +if [ -f "${LAUNCHD_PLIST}" ]; then + launchctl unload -w "${LAUNCHD_PLIST}" >/dev/null 2>&1 || true + rm -f "${LAUNCHD_PLIST}" + success "Removed launchd agent: ${LAUNCHD_PLIST}" +fi + +# Remove the shell auto-launch hook from rc files (sentinel-delimited block). +BEGIN_MARKER="# >>> mobilecli auto-launch >>>" +END_MARKER="# <<< mobilecli auto-launch <<<" +for rc in "${HOME}/.bashrc" "${HOME}/.bash_profile" "${HOME}/.profile" "${HOME}/.zshrc" "${HOME}/.config/fish/config.fish"; do + if [ -f "${rc}" ] && grep -qF "${BEGIN_MARKER}" "${rc}"; then + tmp="$(mktemp)" + # Delete everything between (and including) the sentinel markers. + awk -v b="${BEGIN_MARKER}" -v e="${END_MARKER}" ' + $0 == b {skip=1; next} + skip && $0 == e {skip=0; next} + !skip {print} + ' "${rc}" > "${tmp}" + mv "${tmp}" "${rc}" + success "Removed shell hook from ${rc}" + fi +done + +# Remove the config directory (paired credentials, sessions, logs). +if [ -d "${CONFIG_DIR}" ]; then + rm -rf "${CONFIG_DIR}" + success "Removed config directory: ${CONFIG_DIR}" +fi + +# Remove the binary from common install locations. +for dir in "${HOME}/.local/bin" "${HOME}/bin" "/usr/local/bin"; do + target="${dir}/${BINARY_NAME}" + if [ -f "${target}" ]; then + if rm -f "${target}" 2>/dev/null; then + success "Removed binary: ${target}" + elif command -v sudo >/dev/null 2>&1; then + sudo rm -f "${target}" && success "Removed binary: ${target}" + else + warn "Could not remove ${target} (insufficient permissions)" + fi + fi +done + +echo +success "MobileCLI has been uninstalled." From 167c7901b4964f0392d6f11710bb3fba43cf7d78 Mon Sep 17 00:00:00 2001 From: bigph00t Date: Wed, 17 Jun 2026 15:40:21 -0700 Subject: [PATCH 2/2] ci: syntax-check uninstall.sh in release gates Mirror the existing install.sh `bash -n` gate so the uninstaller script is validated in the same release pipeline. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 3 +++ cli/src/uninstall.rs | 15 +++++++++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 34874aa..4333811 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,6 +42,9 @@ jobs: - name: Verify installer script run: bash -n install.sh + - name: Verify uninstaller script + run: bash -n uninstall.sh + - name: Test installer checksum verification run: bash scripts/test-installer-checksum.sh diff --git a/cli/src/uninstall.rs b/cli/src/uninstall.rs index d816a79..2937f82 100644 --- a/cli/src/uninstall.rs +++ b/cli/src/uninstall.rs @@ -35,10 +35,7 @@ pub fn run(args: UninstallArgs) -> Result<(), Box> { let config_dir = platform::config_dir(); let binary = std::env::current_exe().ok(); - println!( - "{}", - "This will remove MobileCLI from your system:".bold() - ); + println!("{}", "This will remove MobileCLI from your system:".bold()); println!(" • Stop the background daemon"); println!(" • Remove daemon autostart (login service)"); println!(" • Remove the shell auto-launch hook from your shell config"); @@ -51,7 +48,10 @@ pub fn run(args: UninstallArgs) -> Result<(), Box> { } if !args.keep_binary { if let Some(ref exe) = binary { - println!(" • Remove the binary at {}", exe.display().to_string().cyan()); + println!( + " • Remove the binary at {}", + exe.display().to_string().cyan() + ); } } println!(); @@ -115,7 +115,10 @@ pub fn run(args: UninstallArgs) -> Result<(), Box> { } println!(); - println!("{}", "MobileCLI has been uninstalled. Thanks for trying it!".green()); + println!( + "{}", + "MobileCLI has been uninstalled. Thanks for trying it!".green() + ); Ok(()) }