diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c84869c346bc..e97923a014ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,3 +46,47 @@ jobs: run: cargo test --all-features --all-targets - name: Run clippy run: cargo clippy --all-features --all-targets -- --no-deps --deny warnings + + installer: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - name: Install Rust + run: rustup toolchain install nightly --no-self-update --profile minimal --component rust-src + - name: Run installer tests + run: bash tools/test-installer.sh + + installer-distros: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: ubuntu + image: ubuntu:24.04 + - name: fedora + image: fedora:42 + - name: archlinux + image: archlinux:latest + - name: opensuse + image: opensuse/tumbleweed:latest + container: + image: ${{ matrix.image }} + env: + CARGO_HOME: /github/home/.cargo + RUSTUP_HOME: /github/home/.rustup + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Run distro installer smoke test + run: bash tools/test-installer-distro.sh diff --git a/Cargo.lock b/Cargo.lock index 7cac8e8f3ad4..e1dec19021cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -259,6 +259,7 @@ dependencies = [ "criterion", "libc", "lsh", + "pkg-config", "stdext", "toml-span", "windows-sys", diff --git a/README.md b/README.md index 623c6dfd59ce..9b3a2302fecc 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,20 @@ You can install the latest version with WinGet: winget install Microsoft.Edit ``` +### Linux + +You can install the latest tagged release by pasting this into the Linux terminal: +```sh +curl -fsSL https://raw.githubusercontent.com/microsoft/edit/main/tools/install.sh | bash +``` +You can uninstall via: +```sh +curl -fsSL https://raw.githubusercontent.com/microsoft/edit/main/tools/uninstall.sh | bash +``` + + +The source installer is continuously tested on Ubuntu, Fedora, Arch Linux, and openSUSE Tumbleweed. Other Linux distributions are best-effort and may require manual dependency installation. + ## Build Instructions * [Install Rust](https://www.rust-lang.org/tools/install) diff --git a/crates/edit/Cargo.toml b/crates/edit/Cargo.toml index 3858b585cce7..8456ec9b4645 100644 --- a/crates/edit/Cargo.toml +++ b/crates/edit/Cargo.toml @@ -28,6 +28,7 @@ libc = "0.2" [build-dependencies] stdext.workspace = true lsh.workspace = true +pkg-config = "0.3" # The default toml crate bundles its dependencies with bad compile times. Thanks. # Thankfully toml-span exists. FWIW the alternative is yaml-rust (without the 2 suffix). toml-span = { version = "0.6", default-features = false } diff --git a/crates/edit/build/main.rs b/crates/edit/build/main.rs index 96ae2f201421..6b5bb24223d2 100644 --- a/crates/edit/build/main.rs +++ b/crates/edit/build/main.rs @@ -6,6 +6,9 @@ use stdext::arena::scratch_arena; use crate::helpers::env_opt; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; mod helpers; mod i18n; @@ -17,6 +20,94 @@ enum TargetOs { Unix, } +// ---- ICU discovery for installer & source builds --------------------------- +fn dedup_join(mut v: Vec) -> String { + v.sort(); + v.dedup(); + let parts: Vec = v.into_iter().map(|p| p.display().to_string()).collect(); + parts.join(":") +} + +fn try_pkg_config() -> Vec { + let mut dirs = Vec::new(); + for name in ["icu-uc", "icu-i18n", "icu-data"] { + match pkg_config::Config::new().print_system_libs(false).probe(name) { + Ok(lib) => dirs.extend(lib.link_paths.clone()), + Err(_) => {} + } + } + dirs +} + +fn try_fs_latest_for(stem: &str, roots: &[&str]) -> Option { + // Find lib.so.* and return its parent dir + for d in roots { + let dir = Path::new(d); + if !dir.is_dir() { continue; } + // A simple lexicographic sort is good enough for .so.N versions + let mut candidates: Vec = match fs::read_dir(dir) { + Ok(it) => it.filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.file_name() + .and_then(|s| s.to_str()) + .map(|n| n.starts_with(&format!("lib{stem}.so."))) + .unwrap_or(false)) + .collect(), + Err(_) => continue, + }; + candidates.sort(); + if let Some(path) = candidates.last() { + return path.parent().map(|p| p.to_path_buf()); + } + } + None +} + +fn try_fs_scan() -> Vec { + let roots = [ + "/usr/local/lib", "/usr/local/lib64", + "/usr/lib", "/usr/lib64", "/lib", "/lib64", + "/usr/lib32", + "/usr/lib/x86_64-linux-gnu", "/lib/x86_64-linux-gnu", + "/usr/lib/aarch64-linux-gnu", "/lib/aarch64-linux-gnu", + "/usr/lib/arm-linux-gnueabihf", "/lib/arm-linux-gnueabihf", + ]; + let mut dirs = Vec::new(); + for stem in ["icuuc", "icui18n", "icudata"] { + if let Some(d) = try_fs_latest_for(stem, &roots) { + dirs.push(d); + } + } + dirs +} + +fn write_icu_ldpath_artifact() { + // 1) gather ICU dirs (prefer pkg-config) + let mut dirs = try_pkg_config(); + if dirs.is_empty() { + dirs = try_fs_scan(); + } + + // 2) write ${OUT_DIR}/.edit.ldpath (empty file if not found) + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set"); + let ldfile = Path::new(&out_dir).join(".edit.ldpath"); + let joined = dedup_join(dirs); + // Create the file regardless (lets the installer detect the “not found” case) + let mut f = fs::File::create(&ldfile).expect("create .edit.ldpath"); + if !joined.is_empty() { + let _ = writeln!(f, "{}", joined); + // Also export for optional runtime hints + println!("cargo:rustc-env=EDIT_BUILD_ICU_LDPATH={}", joined); + println!("cargo:warning=edit: using ICU from {}", joined); + } else { + // Leave it empty; installer will fall back to its own detection + println!("cargo:warning=edit: ICU not found by build script"); + } + // Re-run if we change this file + println!("cargo:rerun-if-changed=build/main.rs"); + println!("cargo:rerun-if-env-changed=PKG_CONFIG_PATH"); +} + + fn main() { stdext::arena::init(128 * 1024 * 1024).unwrap(); @@ -26,6 +117,7 @@ fn main() { _ => TargetOs::Unix, }; + write_icu_ldpath_artifact(); compile_lsh(); compile_i18n(); configure_icu(target_os); diff --git a/tools/install.sh b/tools/install.sh new file mode 100755 index 000000000000..179908eaba35 --- /dev/null +++ b/tools/install.sh @@ -0,0 +1,637 @@ +#!/usr/bin/env bash + +# guard: ensure Linux + bash +[ "$(uname -s 2>/dev/null)" = "Linux" ] || { + printf '\033[1;31mxx\033[0m This installer targets Linux.\n' >&2; exit 1; } +command -v bash >/dev/null 2>&1 || { + printf '\033[1;31mxx\033[0m bash not found. Please install bash.\n' >&2; exit 1; } + + +set -Eeuo pipefail +umask 022 +export LC_ALL=C + +trap 'code=$?; line=${BASH_LINENO[0]:-}; cmd=${BASH_COMMAND:-}; printf "\033[1;31mxx\033[0m failed (exit %s) at line %s: %s\n" "$code" "$line" "$cmd" >&2' ERR + +# Edit (Microsoft.Edit) Linux installer +# - Installs deps (build + ICU) +# - Ensures ICU can be loaded (system-wide symlinks when possible; user wrapper otherwise) +# - Installs rustup + nightly, builds, and installs to /usr/local/bin or ~/.local/bin + +need_cmd() { command -v "$1" >/dev/null 2>&1; } +log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m!!\033[0m %s\n' "$*"; } +die() { printf '\033[1;31mxx\033[0m %s\n' "$*"; exit 1; } +is_root() { [ "${EUID:-$(id -u)}" -eq 0 ]; } +run_root() { + if is_root; then + "$@" + elif [ -n "${SUDO:-}" ]; then + $SUDO "$@" + else + die "This step requires root. Re-run as root, with sudo/doas, or set EDIT_SKIP_DEPS=1 after installing dependencies manually." + fi +} + +: "${EDIT_ALLOW_ELEVATION:=1}" +declare -a CREATED_ICU_LINKS=() + +SUDO="" +if [ "${EDIT_ALLOW_ELEVATION}" = "1" ] && [ "${EUID:-$(id -u)}" -ne 0 ]; then + if need_cmd sudo; then SUDO="sudo" + elif need_cmd doas; then SUDO="doas" + else SUDO="" + fi +fi + +HAVE_ROOT=0 +if is_root || [ -n "$SUDO" ]; then HAVE_ROOT=1; fi + +PM="" +USE_COLOR=1 +[ -t 1 ] && [ -z "${NO_COLOR:-}" ] || USE_COLOR=0 +if [ "$USE_COLOR" -eq 0 ]; then + log(){ printf '==> %s\n' "$*"; } + warn(){ printf '!! %s\n' "$*"; } + die(){ printf 'xx %s\n' "$*"; exit 1; } +fi + +if need_cmd apt-get; then PM=apt +elif need_cmd dnf; then PM=dnf +elif need_cmd yum; then PM=yum +elif need_cmd zypper; then PM=zypper +elif need_cmd pacman; then PM=pacman +elif need_cmd apk; then PM=apk +elif need_cmd xbps-install; then PM=xbps +else + warn "Unknown distro. Attempting best-effort build if prerequisites exist." +fi + +apt_update_if_stale() { + if [ -d /var/lib/apt/lists ]; then + local now=$(date +%s) newest=0 count=0 m + while IFS= read -r -d '' f; do + count=$((count+1)) + m=$(stat -c %Y "$f" 2>/dev/null || echo 0) + [ "$m" -gt "$newest" ] && newest="$m" + done < <(find /var/lib/apt/lists -type f -print0 2>/dev/null || true) + if [ "$count" -eq 0 ] || [ "$newest" -lt $(( now - 21600 )) ]; then + run_root env DEBIAN_FRONTEND=noninteractive apt-get update -y + fi + else + run_root env DEBIAN_FRONTEND=noninteractive apt-get update -y + fi +} + +install_pkgs() { + case "$PM" in + apt) + apt_update_if_stale + run_root env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \ + build-essential pkg-config curl ca-certificates git libicu-dev + ;; + dnf) + run_root dnf -y install @development-tools gcc gcc-c++ make \ + pkgconf-pkg-config curl ca-certificates git libicu-devel + ;; + yum) + run_root yum -y groupinstall "Development Tools" || true + run_root yum -y install gcc gcc-c++ make pkgconfig curl ca-certificates git libicu-devel + ;; + zypper) + run_root zypper --non-interactive ref + run_root zypper --non-interactive install -t pattern devel_basis || true + run_root zypper --non-interactive install --no-recommends \ + gcc gcc-c++ make pkg-config curl ca-certificates git libicu-devel + ;; + pacman) + # Full sync to avoid partial upgrades in scripted installs + run_root pacman -Syu --noconfirm --needed --noprogressbar \ + base-devel icu curl ca-certificates git pkgconf + ;; + apk) + # Alpine: icu-dev provides unversioned .so symlinks; keep it + run_root apk add --no-cache \ + build-base pkgconf curl ca-certificates git icu-dev + ;; + xbps) + run_root xbps-install -Sy -y \ + gcc clang make pkgconf curl ca-certificates git icu-devel + ;; + *) + warn "Unknown or unsupported package manager. Skipping dependency installation." + warn "Please ensure build tools, pkg-config, git, curl, and ICU dev/runtime are installed." + ;; + esac +} + +try_install_pkgs() { + set +e + install_pkgs + local rc=$? + set -e + return "$rc" +} + +is_edit_source_tree() { + local root="$1" + [ -n "$root" ] || return 1 + [ -f "$root/Cargo.toml" ] || return 1 + [ -f "$root/crates/edit/Cargo.toml" ] || return 1 + [ -f "$root/i18n/edit.toml" ] || return 1 + grep -q '^name = "edit"$' "$root/crates/edit/Cargo.toml" +} + +find_local_edit_checkout() { + local root + root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + [ -n "$root" ] || return 1 + is_edit_source_tree "$root" || return 1 + printf '%s' "$root" +} + +git_latest_release_tag() { + local source_url="$1" + git ls-remote --tags --refs "$source_url" 'v*' 2>/dev/null \ + | awk '{sub("refs/tags/", "", $2); print $2}' \ + | sort -V \ + | tail -n1 +} + +is_local_git_source() { + local source_url="$1" + case "$source_url" in + file://*) return 0 ;; + esac + [ -e "$source_url" ] +} + +looks_like_commit_ref() { + [[ "$1" =~ ^[0-9a-fA-F]{7,40}$ ]] +} + +git_clone_source() { + local source_url="$1" source_ref="$2" dest="$3" + export GIT_TERMINAL_PROMPT=0 + + if looks_like_commit_ref "$source_ref"; then + git clone "$source_url" "$dest" + (cd "$dest" && git checkout --detach "$source_ref") + return 0 + fi + + if is_local_git_source "$source_url"; then + if [ -n "$source_ref" ]; then + git clone --branch "$source_ref" "$source_url" "$dest" + else + git clone "$source_url" "$dest" + fi + return 0 + fi + + if [ -n "$source_ref" ]; then + git -c http.lowSpeedLimit=1 -c http.lowSpeedTime=30 \ + clone --filter=blob:none --depth=1 --branch "$source_ref" \ + "$source_url" "$dest" + else + git -c http.lowSpeedLimit=1 -c http.lowSpeedTime=30 \ + clone --filter=blob:none --depth=1 "$source_url" "$dest" + fi +} + +nightly_uses_real_immediate_abort() { + local cargo_bin="$1" + local version major rest minor + version="$("$cargo_bin" +nightly -V 2>/dev/null | awk '{print $2}')" + major="${version%%.*}" + rest="${version#*.}" + minor="${rest%%.*}" + + case "$major:$minor" in + ''|*[!0-9:]*) + return 0 + ;; + *) + if [ "$major" -gt 1 ] || { [ "$major" -eq 1 ] && [ "$minor" -ge 91 ]; }; then + return 0 + else + return 1 + fi + ;; + esac +} + +write_release_nightly_compat_config() { + local path="$1" + cat > "$path" <<'EOF' +[profile.release] +panic = "immediate-abort" + +[unstable] +panic-immediate-abort = true +build-std = ["std", "panic_abort"] +EOF +} + +select_release_config() { + local src_dir="$1" cargo_bin="$2" compat_path="$3" + if nightly_uses_real_immediate_abort "$cargo_bin"; then + if [ -f "$src_dir/.cargo/release-nightly.toml" ]; then + printf '%s' ".cargo/release-nightly.toml" + else + write_release_nightly_compat_config "$compat_path" + printf '%s' "$compat_path" + fi + else + printf '%s' ".cargo/release.toml" + fi +} + +check_build_tools() { + local missing=() + if ! need_cmd cc && ! need_cmd gcc; then + missing+=("cc/gcc") + fi + if ! need_cmd pkg-config && ! need_cmd pkgconf; then + missing+=("pkg-config/pkgconf") + fi + if [ "${#missing[@]}" -ne 0 ]; then + die "Missing build prerequisites: ${missing[*]}. Install dependencies first or rerun without EDIT_SKIP_DEPS=1." + fi +} + +write_manifest() { + local manifest_path="$1" use_root="$2" + shift 2 + local tmp + tmp="$(mktemp)" + printf '%s\n' "$@" > "$tmp" + if [ "$use_root" -eq 1 ]; then + run_root install -Dm644 "$tmp" "$manifest_path" + else + install -Dm644 "$tmp" "$manifest_path" + fi + rm -f "$tmp" +} + + +# -------- ICU discovery helpers -------- +# Return the directory containing the newest versioned lib for a given stem, or empty. +find_icu_libdir_for() { + local stem="$1" + if need_cmd ldconfig; then + local p + p="$(ldconfig -p 2>/dev/null | awk '/'"$stem"'\.so\./{print $NF}' | sort -V | tail -1 || true)" + [ -n "$p" ] && { dirname -- "$p"; return 0; } + fi + local d latest candidate + local -a matches=() + shopt -s nullglob + for d in /usr/local/lib /usr/local/lib64 /usr/lib /usr/lib64 /lib /lib64 /usr/lib/*-linux-gnu /lib/*-linux-gnu /usr/lib32; do + for candidate in "$d/$stem.so."*; do + matches+=("$candidate") + done + done + shopt -u nullglob + if [ "${#matches[@]}" -ne 0 ]; then + latest="$(printf '%s\n' "${matches[@]}" | sort -V | tail -1)" + dirname -- "$latest" + return 0 + fi + printf '' +} + +# Build a colon-joined LD_LIBRARY_PATH fragment with unique dirs for uc/i18n/data. +build_icu_ldpath() { + local dirs=() d seen="" + for stem in libicuuc libicui18n libicudata; do + d="$(find_icu_libdir_for "$stem")" + [ -z "$d" ] && continue + case ":$seen:" in *":$d:"*) : ;; *) dirs+=("$d"); seen="$seen:$d";; esac + done + (IFS=:; printf '%s' "${dirs[*]:-}") +} + +# Create unversioned symlinks system-wide if allowed; return 0 on success, 1 otherwise. +find_latest_icu_lib_in_dirs() { + local lib="$1" search_dirs="$2" latest candidate dir + local -a matches=() + [ -n "$search_dirs" ] || return 1 + local old_ifs="$IFS" + IFS=: + for dir in $search_dirs; do + [ -n "$dir" ] || continue + shopt -s nullglob + for candidate in "$dir/$lib.so."*; do + matches+=("$candidate") + done + shopt -u nullglob + done + IFS="$old_ifs" + [ "${#matches[@]}" -ne 0 ] || return 1 + latest="$(printf '%s\n' "${matches[@]}" | sort -V | tail -1)" + printf '%s' "$latest" +} + +icu_has_unversioned_lib_in_dirs() { + local lib="$1" search_dirs="$2" dir + [ -e "/usr/local/lib/$lib.so" ] && return 0 + [ -n "$search_dirs" ] || return 1 + local old_ifs="$IFS" + IFS=: + for dir in $search_dirs; do + if [ -e "$dir/$lib.so" ]; then + IFS="$old_ifs" + return 0 + fi + done + IFS="$old_ifs" + return 1 +} + +# Create unversioned symlinks system-wide if allowed; return 0 on success, 1 otherwise. +ensure_system_icu_symlinks() { + local icu_dirs="$1" ok_all=0 + [ -n "$icu_dirs" ] || return 1 + for lib in libicuuc libicui18n libicudata; do + # Already present? + if icu_has_unversioned_lib_in_dirs "$lib" "$icu_dirs"; then + continue + fi + # Find latest version + local latest target + latest="$(find_latest_icu_lib_in_dirs "$lib" "$icu_dirs" || true)" + if [ -n "$latest" ]; then + if [ "$HAVE_ROOT" -eq 1 ]; then + log "Creating unversioned symlink for $lib → $latest" + target="$(readlink -f "$latest" 2>/dev/null || echo "$latest")" + run_root install -d -m 0755 /usr/local/lib + run_root ln -sf "$target" "/usr/local/lib/$lib.so" + CREATED_ICU_LINKS+=("/usr/local/lib/$lib.so") + if need_cmd ldconfig && [ -z "${_EDIT_LDCONFIG_DONE:-}" ]; then + run_root ldconfig; _EDIT_LDCONFIG_DONE=1 + fi + else + ok_all=1 + fi + else + ok_all=1 + fi + done + return $ok_all +} + +# Create a user-local wrapper that exports LD_LIBRARY_PATH to ICU dir, then execs the binary +install_user_wrapper() { + local bin="$1" icudir="$2" dst="$3" + mkdir -p "$(dirname "$dst")" + cat > "$dst" </dev/null || true + + # Prefer rustup's cargo for +nightly + if [ -x "$HOME/.cargo/bin/cargo" ] && [ "$(command -v cargo)" != "$HOME/.cargo/bin/cargo" ]; then + export PATH="$HOME/.cargo/bin:$PATH" + hash -r 2>/dev/null || true + fi + + if ! rustup toolchain list 2>/dev/null | grep -q '^nightly'; then + log "Installing Rust nightly toolchain" + rustup toolchain install nightly --no-self-update --profile minimal --component rust-src + fi + + # final check: ensure '+nightly' actually resolves + if ! "$HOME/.cargo/bin/cargo" +nightly -V >/dev/null 2>&1; then + warn "cargo (+nightly) resolution failed; diagnostics:" + "$HOME/.cargo/bin/rustup" show 2>&1 | sed 's/^/ /' + die "rustup cargo +nightly not usable; check PATH and rustup installation" + fi +} + +# -------- Build & install -------- +build_and_install() { + : "${EDIT_FORCE_WRAPPER:=0}" # 1 = force user wrapper even with sudo + : "${EDIT_SOURCE_DIR:=}" # trusted local source tree override + : "${EDIT_SOURCE_URL:=https://github.com/microsoft/edit.git}" # allow testing forks + : "${EDIT_SOURCE_REF:=}" + + local SRC_DIR + local CLEANUP=0 + local LOCAL_CHECKOUT="" + local TEMP_RELEASE_CONFIG="" + _cleanup() { + # safe under `set -u` + if [ "${CLEANUP:-0}" -eq 1 ] && [ -n "${SRC_DIR:-}" ]; then + rm -rf "$SRC_DIR" + fi + if [ -n "${TEMP_RELEASE_CONFIG:-}" ] && [ -f "${TEMP_RELEASE_CONFIG:-}" ]; then + rm -f "$TEMP_RELEASE_CONFIG" + fi + } + trap _cleanup EXIT + + LOCAL_CHECKOUT="${EDIT_SOURCE_DIR:-}" + if [ -n "$LOCAL_CHECKOUT" ]; then + is_edit_source_tree "$LOCAL_CHECKOUT" || die "EDIT_SOURCE_DIR is not a valid edit source tree: $LOCAL_CHECKOUT" + SRC_DIR="$LOCAL_CHECKOUT" + log "Using explicit edit source tree at $SRC_DIR" + else + LOCAL_CHECKOUT="$(find_local_edit_checkout || true)" + fi + + if [ -n "${SRC_DIR:-}" ]; then + : + elif [ -n "$LOCAL_CHECKOUT" ]; then + SRC_DIR="$LOCAL_CHECKOUT" + log "Using existing edit checkout at $SRC_DIR" + else + local SOURCE_REF="${EDIT_SOURCE_REF:-}" + need_cmd git || die "git is required to clone the source" + SRC_DIR="$(mktemp -d)" + CLEANUP=1 + if [ -z "$SOURCE_REF" ]; then + SOURCE_REF="$(git_latest_release_tag "$EDIT_SOURCE_URL" || true)" + if [ -n "$SOURCE_REF" ]; then + log "Using latest release tag: $SOURCE_REF" + else + SOURCE_REF="main" + warn "Could not determine the latest release tag; falling back to $SOURCE_REF" + fi + fi + log "Cloning $EDIT_SOURCE_URL into $SRC_DIR" + git_clone_source "$EDIT_SOURCE_URL" "$SOURCE_REF" "$SRC_DIR" + fi + + check_build_tools + + log "Building Edit (release)" + local CARGO_BIN="${HOME}/.cargo/bin/cargo" + [ -x "$CARGO_BIN" ] || CARGO_BIN="$(command -v cargo || true)" + [ -x "$CARGO_BIN" ] || die "cargo not found" + TEMP_RELEASE_CONFIG="$(mktemp)" + local RELEASE_CONFIG + RELEASE_CONFIG="$(select_release_config "$SRC_DIR" "$CARGO_BIN" "$TEMP_RELEASE_CONFIG")" + if [ "$RELEASE_CONFIG" = "$TEMP_RELEASE_CONFIG" ]; then + log "Using compatibility release config for current nightly" + else + rm -f "$TEMP_RELEASE_CONFIG" + TEMP_RELEASE_CONFIG="" + log "Using release config: $RELEASE_CONFIG" + fi + local -a BUILD_ARGS=() + if [ -f "$SRC_DIR/crates/edit/Cargo.toml" ]; then + BUILD_ARGS=(-p edit) + fi + (cd "$SRC_DIR" && RUSTC_BOOTSTRAP=1 "$CARGO_BIN" +nightly \ + build "${BUILD_ARGS[@]}" --config "$RELEASE_CONFIG" --release ${EDIT_CARGO_ARGS:-}) + + local BIN="$SRC_DIR/target/release/edit" + [ -x "$BIN" ] || die "Build failed: $BIN not found" + + local DEST_SYS="${EDIT_PREFIX:-/usr/local}/bin" + local DEST_USER="${EDIT_USER_PREFIX:-$HOME/.local}/bin" + local SYSTEM_MANIFEST="${EDIT_PREFIX:-/usr/local}/share/edit/install-manifest" + local USER_MANIFEST="${EDIT_USER_PREFIX:-$HOME/.local}/share/edit/install-manifest" + local OUT_BIN="" WRAPPER_NEEDED=0 ICU_DIR="" + local -a MANIFEST_ENTRIES=() + + # Prefer build.rs artifact if present, else fall back to shell discovery + local LDPATH_FILE="" + LDPATH_FILE="$(find "$SRC_DIR/target" -type f -name '.edit.ldpath' | head -n1 || true)" + if [ -n "$LDPATH_FILE" ] && [ -s "$LDPATH_FILE" ]; then + ICU_DIR="$(tr -d '\n' < "$LDPATH_FILE" || true)" + log "ICU (from build.rs): ${ICU_DIR:-}" + else + ICU_DIR="$(build_icu_ldpath || true)" + [ -n "$ICU_DIR" ] && log "ICU (shell fallback): $ICU_DIR" + fi + + if [ -z "$ICU_DIR" ]; then + warn "ICU libraries not found; install ICU dev/runtime packages. Proceeding; wrapper will not help." + fi + + + # Try to make system-wide ICU symlinks if we can + if [ "$HAVE_ROOT" -eq 1 ] && [ -n "$ICU_DIR" ]; then + if ensure_system_icu_symlinks "$ICU_DIR"; then + log "System ICU symlinks OK." + else + warn "Could not create system ICU symlinks; will use user wrapper if installing locally." + WRAPPER_NEEDED=1 + fi + elif [ "$HAVE_ROOT" -eq 0 ] && [ -n "$ICU_DIR" ]; then + WRAPPER_NEEDED=1 + fi + + if [ "${EDIT_FORCE_WRAPPER:-0}" -eq 1 ] && [ -n "$ICU_DIR" ]; then + WRAPPER_NEEDED=1 + fi + + local DEST_SYS_DIR; DEST_SYS_DIR="$(dirname "$DEST_SYS")" + if [ "$HAVE_ROOT" -eq 1 ] && run_root test -w "$DEST_SYS_DIR" -a -d "$DEST_SYS_DIR"; then + log "Installing to $DEST_SYS" + if [ "$WRAPPER_NEEDED" -eq 1 ] && [ -n "$ICU_DIR" ]; then + # Could not install the system-wide ICU shim; use a system-wide wrapper + log "System ICU symlinks unavailable; installing wrapper that sets LD_LIBRARY_PATH" + run_root install -Dm755 "$BIN" "${EDIT_PREFIX:-/usr/local}/libexec/edit-real" + run_root bash -lc "cat > '$DEST_SYS/edit' <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +export LD_LIBRARY_PATH='"$ICU_DIR"':\${LD_LIBRARY_PATH:-} +exec -a edit '"${EDIT_PREFIX:-/usr/local}"'/libexec/edit-real "\$@" +EOF +chmod +x '$DEST_SYS/edit'" + run_root ln -sf "$DEST_SYS/edit" "$DEST_SYS/msedit" + OUT_BIN="$DEST_SYS/edit" + MANIFEST_ENTRIES+=("${EDIT_PREFIX:-/usr/local}/libexec/edit-real") + else + # Normal case: direct binary install (symlink shim present or not needed) + run_root install -Dm755 "$BIN" "$DEST_SYS/edit" + run_root ln -sf "$DEST_SYS/edit" "$DEST_SYS/msedit" + OUT_BIN="$DEST_SYS/edit" + fi + MANIFEST_ENTRIES+=("$DEST_SYS/edit" "$DEST_SYS/msedit") + if [ "${#CREATED_ICU_LINKS[@]}" -ne 0 ]; then + MANIFEST_ENTRIES+=("${CREATED_ICU_LINKS[@]}") + fi + write_manifest "$SYSTEM_MANIFEST" 1 "${MANIFEST_ENTRIES[@]}" + else + mkdir -p "$DEST_USER" + if [ "$WRAPPER_NEEDED" -eq 1 ] && [ -n "$ICU_DIR" ]; then + log "Installing user-local wrapper due to missing privileges for ICU shim" + install -Dm755 "$BIN" "$DEST_USER/.edit-real" + install_user_wrapper "$DEST_USER/.edit-real" "$ICU_DIR" "$DEST_USER/edit" + ln -sf "$DEST_USER/edit" "$DEST_USER/msedit" + OUT_BIN="$DEST_USER/edit" + MANIFEST_ENTRIES+=("$DEST_USER/.edit-real") + else + log "Installing to $DEST_USER (no sudo)" + install -Dm755 "$BIN" "$DEST_USER/edit" + ln -sf "$DEST_USER/edit" "$DEST_USER/msedit" + OUT_BIN="$DEST_USER/edit" + fi + MANIFEST_ENTRIES+=("$DEST_USER/edit" "$DEST_USER/msedit") + write_manifest "$USER_MANIFEST" 0 "${MANIFEST_ENTRIES[@]}" + if ! printf '%s' "$PATH" | tr ':' '\n' | grep -qx "$DEST_USER"; then + warn "Add $DEST_USER to your PATH to run 'edit' globally." + fi + fi + + CLEANUP=0 + trap - EXIT + + log "Installed: $OUT_BIN" + log "Launch it in a terminal with: ${OUT_BIN:-edit}" + + # PATH check hint + if [ -n "$OUT_BIN" ]; then + case ":$PATH:" in + *":$(dirname "$OUT_BIN"):"*) : ;; + *) warn "Note: $(dirname "$OUT_BIN") is not in PATH for non-login shells." ;; + esac + fi +} + +main() { + if [ "${EDIT_SKIP_DEPS:-0}" != "1" ]; then + log "Installing dependencies" + if ! try_install_pkgs; then + warn "Dependency installation failed or was skipped." + warn "Continuing with existing tools; the build will fail if prerequisites are still missing." + fi + else + log "Skipping dependency installation (EDIT_SKIP_DEPS=1)" + need_cmd curl || die "curl is required when EDIT_SKIP_DEPS=1" + if [ -z "$(find_local_edit_checkout || true)" ]; then + need_cmd git || die "git is required to clone the source when EDIT_SKIP_DEPS=1" + fi + fi + log "Ensuring Rust toolchain" + install_rust + log "Building and installing Edit" + build_and_install + log "Done. Run: edit (alias: msedit)" +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/tools/test-installer-distro.sh b/tools/test-installer-distro.sh new file mode 100755 index 000000000000..faed57ed9934 --- /dev/null +++ b/tools/test-installer-distro.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +prefix="${EDIT_PREFIX:-/usr/local}" +bin_dir="$prefix/bin" +libexec_dir="$prefix/libexec" +manifest="$prefix/share/edit/install-manifest" + +fail() { + printf 'test failure: %s\n' "$*" >&2 + exit 1 +} + +assert_exists() { + [ -e "$1" ] || fail "expected path to exist: $1" +} + +assert_not_exists() { + [ ! -e "$1" ] || fail "expected path to be absent: $1" +} + +assert_manifest_contains() { + local path="$1" + grep -Fxq "$path" "$manifest" || fail "expected manifest $manifest to contain $path" +} + +cleanup() { + bash "$repo_root/tools/uninstall.sh" --system-only >/dev/null 2>&1 || true +} + +main() { + trap cleanup EXIT + + bash -n "$repo_root/tools/install.sh" + bash -n "$repo_root/tools/uninstall.sh" + + EDIT_SOURCE_DIR="$repo_root" bash "$repo_root/tools/install.sh" + + assert_exists "$bin_dir/edit" + assert_exists "$bin_dir/msedit" + assert_exists "$manifest" + assert_manifest_contains "$bin_dir/edit" + assert_manifest_contains "$bin_dir/msedit" + + if [ -e "$libexec_dir/edit-real" ]; then + assert_manifest_contains "$libexec_dir/edit-real" + fi + + bash "$repo_root/tools/uninstall.sh" --system-only + + assert_not_exists "$bin_dir/edit" + assert_not_exists "$bin_dir/msedit" + assert_not_exists "$libexec_dir/edit-real" + assert_not_exists "$manifest" + + trap - EXIT + printf 'distro installer smoke test passed\n' +} + +main "$@" diff --git a/tools/test-installer.sh b/tools/test-installer.sh new file mode 100644 index 000000000000..66bab03f71a4 --- /dev/null +++ b/tools/test-installer.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +bash -n "$repo_root/tools/install.sh" +bash -n "$repo_root/tools/uninstall.sh" + +source "$repo_root/tools/install.sh" +source "$repo_root/tools/uninstall.sh" + +fail() { + printf 'test failure: %s\n' "$*" >&2 + exit 1 +} + +assert_eq() { + local actual="$1" expected="$2" message="$3" + [ "$actual" = "$expected" ] || fail "$message (expected '$expected', got '$actual')" +} + +assert_exists() { + [ -e "$1" ] || fail "expected path to exist: $1" +} + +assert_not_exists() { + [ ! -e "$1" ] || fail "expected path to be absent: $1" +} + +assert_manifest_contains() { + local manifest="$1" path="$2" + grep -Fxq "$path" "$manifest" || fail "expected manifest $manifest to contain $path" +} + +run_unit_tests() { + local actual tmp compat older release_cfg + + actual="$(cd "$repo_root" && find_local_edit_checkout)" + assert_eq "$actual" "$repo_root" "find_local_edit_checkout should detect the repository root" + is_edit_source_tree "$repo_root" || fail "is_edit_source_tree should accept the repository root" + + tmp="$(mktemp -d)" + if (cd "$tmp" && find_local_edit_checkout >/dev/null 2>&1); then + fail "find_local_edit_checkout should reject unrelated repositories" + fi + if is_edit_source_tree "$tmp"; then + fail "is_edit_source_tree should reject unrelated repositories" + fi + rm -rf "$tmp" + + tmp="$(mktemp -d)" + mkdir -p "$tmp/a" "$tmp/b" + : > "$tmp/a/libicutest.so.1" + : > "$tmp/b/libicutest.so.3" + actual="$(find_latest_icu_lib_in_dirs "libicutest" "$tmp/a:$tmp/b")" + assert_eq "$actual" "$tmp/b/libicutest.so.3" "find_latest_icu_lib_in_dirs should choose the newest candidate" + : > "$tmp/a/libicutest.so" + icu_has_unversioned_lib_in_dirs "libicutest" "$tmp/a:$tmp/b" || fail "icu_has_unversioned_lib_in_dirs should find unversioned libraries" + rm -rf "$tmp" + + compat="$(mktemp)" + older="$(mktemp -d)" + mkdir -p "$older/.cargo" + : > "$older/.cargo/release.toml" + release_cfg="$(select_release_config "$older" "$HOME/.cargo/bin/cargo" "$compat")" + if nightly_uses_real_immediate_abort "$HOME/.cargo/bin/cargo"; then + assert_eq "$release_cfg" "$compat" "older source trees should use a generated compatibility config on newer nightly toolchains" + grep -Fq 'panic = "immediate-abort"' "$compat" || fail "compatibility config should set panic = immediate-abort" + else + assert_eq "$release_cfg" ".cargo/release.toml" "older source trees should keep release.toml on older nightly toolchains" + fi + rm -f "$compat" + rm -rf "$older" + + if [ -f "$repo_root/.cargo/release-nightly.toml" ] && nightly_uses_real_immediate_abort "$HOME/.cargo/bin/cargo"; then + compat="$(mktemp)" + release_cfg="$(select_release_config "$repo_root" "$HOME/.cargo/bin/cargo" "$compat")" + assert_eq "$release_cfg" ".cargo/release-nightly.toml" "modern source trees should prefer release-nightly.toml" + rm -f "$compat" + fi + + tmp="$(mktemp -d)" + mkdir -p "$tmp/prefix/bin" "$tmp/prefix/share/edit" + : > "$tmp/prefix/bin/edit" + ln -s "$tmp/prefix/bin/edit" "$tmp/prefix/bin/msedit" + printf '%s\n%s\n' "$tmp/prefix/bin/edit" "$tmp/prefix/bin/msedit" > "$tmp/prefix/share/edit/install-manifest" + remove_manifest_entries "$tmp/prefix/share/edit/install-manifest" 0 + assert_not_exists "$tmp/prefix/bin/edit" + assert_not_exists "$tmp/prefix/bin/msedit" + assert_not_exists "$tmp/prefix/share/edit/install-manifest" + rm -rf "$tmp" +} + +assert_user_install_tree() { + local prefix="$1" + local manifest="$prefix/share/edit/install-manifest" + + assert_exists "$prefix/bin/edit" + assert_exists "$prefix/bin/msedit" + assert_exists "$manifest" + assert_manifest_contains "$manifest" "$prefix/bin/edit" + assert_manifest_contains "$manifest" "$prefix/bin/msedit" +} + +run_local_checkout_install_cycle() { + local tmp prefix + tmp="$(mktemp -d)" + prefix="$tmp/prefix" + + ( + cd "$repo_root" + EDIT_ALLOW_ELEVATION=0 \ + EDIT_SKIP_DEPS=1 \ + EDIT_USER_PREFIX="$prefix" \ + bash "$repo_root/tools/install.sh" + ) + + assert_user_install_tree "$prefix" + + EDIT_USER_PREFIX="$prefix" bash "$repo_root/tools/uninstall.sh" --user-only + assert_not_exists "$prefix/bin/edit" + assert_not_exists "$prefix/bin/msedit" + assert_not_exists "$prefix/share/edit/install-manifest" + + rm -rf "$tmp" +} + +run_fake_repo_install_cycle() { + local tmp fake prefix source_ref + tmp="$(mktemp -d)" + fake="$tmp/fake" + prefix="$tmp/prefix" + source_ref="$(git -C "$repo_root" rev-parse HEAD)" + + mkdir -p "$fake" + ( + cd "$fake" + git init -q + cat > Cargo.toml <<'EOF' +[package] +name = "fake" +version = "0.1.0" +edition = "2021" +EOF + EDIT_ALLOW_ELEVATION=0 \ + EDIT_SKIP_DEPS=1 \ + EDIT_SOURCE_URL="$repo_root" \ + EDIT_SOURCE_REF="$source_ref" \ + EDIT_USER_PREFIX="$prefix" \ + bash "$repo_root/tools/install.sh" + ) + + assert_user_install_tree "$prefix" + + EDIT_USER_PREFIX="$prefix" bash "$repo_root/tools/uninstall.sh" --user-only + assert_not_exists "$prefix/bin/edit" + assert_not_exists "$prefix/bin/msedit" + assert_not_exists "$prefix/share/edit/install-manifest" + + rm -rf "$tmp" +} + +main() { + run_unit_tests + run_local_checkout_install_cycle + run_fake_repo_install_cycle + printf 'installer tests passed\n' +} + +main "$@" diff --git a/tools/uninstall.sh b/tools/uninstall.sh new file mode 100755 index 000000000000..3d739ff18ed1 --- /dev/null +++ b/tools/uninstall.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +umask 022 + +log(){ printf '\033[1;34m==>\033[0m %s\n' "$*"; } +warn(){ printf '\033[1;33m!!\033[0m %s\n' "$*"; } +is_root(){ [ "${EUID:-$(id -u)}" -eq 0 ]; } +: "${EDIT_PREFIX:=/usr/local}" +: "${EDIT_USER_PREFIX:=$HOME/.local}" + +MODE="all" # all | user | system +DRYRUN=0 + +print_help() { + cat <<'EOF' +Usage: uninstall.sh [--user-only|--system-only] [--dry-run] + --user-only Remove only ~/.local installs + --system-only Remove only /usr/local installs (requires root/sudo/doas) + --dry-run Show what would be removed, without removing +EOF +} + +parse_args() { + MODE="all" + DRYRUN=0 + local a + for a in "$@"; do + case "$a" in + --user-only) MODE="user" ;; + --system-only) MODE="system" ;; + --dry-run) DRYRUN=1 ;; + -h|--help) + print_help + exit 0 + ;; + *) warn "Ignoring unknown argument: $a" ;; + esac + done +} + +# ----- elevation helper (sudo/doas if available) ----- +SUDO="" +if ! is_root; then + if command -v sudo >/dev/null 2>&1; then + SUDO="sudo" + elif command -v doas >/dev/null 2>&1; then + SUDO="doas" + fi +fi + +run_rm() { + # rm path... (with optional sudo) + if [ "$DRYRUN" -eq 1 ]; then + printf '[dry-run] rm -f %s\n' "$*" ; return 0 + fi + rm -f "$@" 2>/dev/null || true +} + +run_rm_root() { + # rm path... as root (if possible) + if [ "$DRYRUN" -eq 1 ]; then + printf '[dry-run] %s rm -f %s\n' "${SUDO:-(no-sudo)}" "$*" + return 0 + fi + if is_root; then + rm -f "$@" 2>/dev/null || true + elif [ -n "$SUDO" ]; then + $SUDO rm -f "$@" 2>/dev/null || true + else + warn "No sudo/doas; cannot remove: $*" + fi +} + +remove_manifest_entries() { + local manifest="$1" use_root="$2" + [ -f "$manifest" ] || return 1 + + while IFS= read -r path; do + [ -n "$path" ] || continue + if [ "$use_root" -eq 1 ]; then + run_rm_root "$path" + else + run_rm "$path" + fi + done < "$manifest" + + if [ "$use_root" -eq 1 ]; then + run_rm_root "$manifest" + else + run_rm "$manifest" + fi +} + +remove_user_install() { + log "Removing user-local binaries" + if ! remove_manifest_entries "$EDIT_USER_PREFIX/share/edit/install-manifest" 0; then + warn "No user install manifest found; falling back to legacy path cleanup" + run_rm "$EDIT_USER_PREFIX/bin/edit" \ + "$EDIT_USER_PREFIX/bin/msedit" \ + "$EDIT_USER_PREFIX/bin/.edit-real" + fi +} + +remove_system_install() { + if ! is_root && [ -z "$SUDO" ]; then + warn "Skipping system-wide removal: need root, sudo, or doas" + return 0 + fi + + log "Removing system-wide binaries" + if ! remove_manifest_entries "$EDIT_PREFIX/share/edit/install-manifest" 1; then + warn "No system install manifest found; falling back to legacy binary cleanup only" + run_rm_root "$EDIT_PREFIX/bin/edit" "$EDIT_PREFIX/bin/msedit" + run_rm_root "$EDIT_PREFIX/libexec/edit-real" + fi + + if command -v ldconfig >/dev/null 2>&1; then + if [ "$DRYRUN" -eq 1 ]; then + printf '[dry-run] %s ldconfig\n' "${SUDO:-(no-sudo)}" + else + if is_root; then ldconfig || true + else $SUDO ldconfig || true + fi + fi + fi +} + +main() { + parse_args "$@" + + if [ "$MODE" = "all" ] || [ "$MODE" = "user" ]; then + remove_user_install + fi + + if [ "$MODE" = "all" ] || [ "$MODE" = "system" ]; then + remove_system_install + fi + + log "Done." +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi