From ddb42b251f7aefab4e1a7b8c6184eccf877e186a Mon Sep 17 00:00:00 2001 From: -LAN- Date: Fri, 15 May 2026 04:23:57 +0800 Subject: [PATCH] feat(install): add release binary installer --- .github/workflows/release.yml | 2 + README.md | 17 ++- README.zh-CN.md | 16 ++- install.sh | 112 ++++++++++++++++ skills/confluence-cli/SKILL.md | 8 +- tests/install_script.rs | 225 +++++++++++++++++++++++++++++++++ 6 files changed, 377 insertions(+), 3 deletions(-) create mode 100755 install.sh create mode 100644 tests/install_script.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb28903..4ace1f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,6 +63,8 @@ jobs: include: - os: ubuntu-latest target: x86_64-unknown-linux-gnu + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu - os: macos-latest target: x86_64-apple-darwin - os: macos-latest diff --git a/README.md b/README.md index bbd4c69..09c3703 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,22 @@ ## Install -Install from a source checkout: +Install the latest release binary: + +```bash +curl -fsSL https://raw.githubusercontent.com/laipz8200/confluence-cli/main/install.sh | sh +``` + +The installer supports Linux x86_64, Linux arm64, macOS x86_64, and macOS arm64 +release artifacts. It installs to `~/.local/bin` by default. Override the +install directory or version with environment variables: + +```bash +curl -fsSL https://raw.githubusercontent.com/laipz8200/confluence-cli/main/install.sh | CONFLUENCE_CLI_INSTALL_DIR=/usr/local/bin sh +curl -fsSL https://raw.githubusercontent.com/laipz8200/confluence-cli/main/install.sh | CONFLUENCE_CLI_VERSION=0.1.0 sh +``` + +Install from a source checkout when you need a local build: ```bash cargo install --path . diff --git a/README.zh-CN.md b/README.zh-CN.md index f8094d5..f544a06 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -36,7 +36,21 @@ ## 安装 -从源码检出目录安装: +安装最新 release 二进制文件: + +```bash +curl -fsSL https://raw.githubusercontent.com/laipz8200/confluence-cli/main/install.sh | sh +``` + +安装脚本支持 Linux x86_64、Linux arm64、macOS x86_64 和 macOS arm64 +release 产物。默认安装到 `~/.local/bin`。可以通过环境变量覆盖安装目录或版本: + +```bash +curl -fsSL https://raw.githubusercontent.com/laipz8200/confluence-cli/main/install.sh | CONFLUENCE_CLI_INSTALL_DIR=/usr/local/bin sh +curl -fsSL https://raw.githubusercontent.com/laipz8200/confluence-cli/main/install.sh | CONFLUENCE_CLI_VERSION=0.1.0 sh +``` + +需要本地构建时,可以从源码检出目录安装: ```bash cargo install --path . diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..de4675c --- /dev/null +++ b/install.sh @@ -0,0 +1,112 @@ +#!/bin/sh +set -eu + +BIN_NAME="confluence-cli" +REPO="${CONFLUENCE_CLI_REPO:-laipz8200/confluence-cli}" +GITHUB_BASE_URL="${CONFLUENCE_CLI_GITHUB_BASE_URL:-https://github.com}" +GITHUB_API_URL="${CONFLUENCE_CLI_GITHUB_API_URL:-https://api.github.com}" +REQUESTED_VERSION="${CONFLUENCE_CLI_VERSION:-latest}" + +GITHUB_BASE_URL="${GITHUB_BASE_URL%/}" +GITHUB_API_URL="${GITHUB_API_URL%/}" + +fail() { + printf 'confluence-cli install: %s\n' "$*" >&2 + exit 1 +} + +need_command() { + command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1" +} + +detect_target() { + os=$(uname -s) + arch=$(uname -m) + + case "$os:$arch" in + Linux:x86_64 | Linux:amd64) + printf 'x86_64-unknown-linux-gnu\n' + ;; + Linux:aarch64 | Linux:arm64) + printf 'aarch64-unknown-linux-gnu\n' + ;; + Darwin:x86_64 | Darwin:amd64) + printf 'x86_64-apple-darwin\n' + ;; + Darwin:arm64 | Darwin:aarch64) + printf 'aarch64-apple-darwin\n' + ;; + *) + fail "Unsupported platform: $os/$arch. Release binaries are available for Linux x86_64, Linux arm64, macOS x86_64, and macOS arm64." + ;; + esac +} + +resolve_tag() { + if [ "$REQUESTED_VERSION" = "latest" ]; then + latest_json=$(curl -fsSL "$GITHUB_API_URL/repos/$REPO/releases/latest") \ + || fail "Failed to resolve the latest GitHub release." + tag=$(printf '%s\n' "$latest_json" \ + | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ + | head -n 1) + [ -n "$tag" ] || fail "Could not find a tag_name in the latest GitHub release response." + printf '%s\n' "$tag" + return + fi + + case "$REQUESTED_VERSION" in + v*) printf '%s\n' "$REQUESTED_VERSION" ;; + *) printf 'v%s\n' "$REQUESTED_VERSION" ;; + esac +} + +default_install_dir() { + if [ -n "${CONFLUENCE_CLI_INSTALL_DIR:-}" ]; then + printf '%s\n' "$CONFLUENCE_CLI_INSTALL_DIR" + return + fi + + [ -n "${HOME:-}" ] || fail "HOME is not set. Set CONFLUENCE_CLI_INSTALL_DIR to choose an install directory." + printf '%s/.local/bin\n' "$HOME" +} + +need_command curl +need_command head +need_command install +need_command mktemp +need_command sed +need_command tar +need_command uname + +target="${CONFLUENCE_CLI_TARGET:-$(detect_target)}" +tag=$(resolve_tag) +version="${tag#v}" +asset="confluence-cli-$version-$target.tar.gz" +package="confluence-cli-$version-$target" +url="$GITHUB_BASE_URL/$REPO/releases/download/$tag/$asset" +install_dir=$(default_install_dir) + +tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/confluence-cli.XXXXXX") \ + || fail "Failed to create a temporary directory." +cleanup() { + rm -rf "$tmpdir" +} +trap cleanup EXIT INT TERM + +archive="$tmpdir/$asset" +printf 'Downloading %s\n' "$url" +curl -fsSL -o "$archive" "$url" || fail "Failed to download $asset." + +tar -xzf "$archive" -C "$tmpdir" || fail "Failed to extract $asset." +binary="$tmpdir/$package/$BIN_NAME" +[ -f "$binary" ] || fail "Release archive did not contain $package/$BIN_NAME." + +mkdir -p "$install_dir" || fail "Failed to create install directory: $install_dir" +install -m 0755 "$binary" "$install_dir/$BIN_NAME" \ + || fail "Failed to install $BIN_NAME to $install_dir." + +printf 'Installed %s to %s\n' "$BIN_NAME" "$install_dir/$BIN_NAME" +case ":$PATH:" in + *":$install_dir:"*) ;; + *) printf 'Note: %s is not on PATH.\n' "$install_dir" ;; +esac diff --git a/skills/confluence-cli/SKILL.md b/skills/confluence-cli/SKILL.md index 412132e..7436520 100644 --- a/skills/confluence-cli/SKILL.md +++ b/skills/confluence-cli/SKILL.md @@ -25,7 +25,13 @@ confluence-cli --version `confluence-cli --version` prints normal CLI version text, not JSON. -If the command is missing, tell the user to install it from the `confluence-cli` repository root or another source checkout: +If the command is missing, tell the user to install the latest release binary: + +```bash +curl -fsSL https://raw.githubusercontent.com/laipz8200/confluence-cli/main/install.sh | sh +``` + +If they need a local source build, tell them to run this from the `confluence-cli` repository root or another source checkout: ```bash cargo install --path . diff --git a/tests/install_script.rs b/tests/install_script.rs new file mode 100644 index 0000000..e028d48 --- /dev/null +++ b/tests/install_script.rs @@ -0,0 +1,225 @@ +#![cfg(unix)] + +use std::ffi::OsString; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use tempfile::tempdir; + +fn install_script() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("install.sh") +} + +fn write_executable(path: &Path, contents: &str) { + fs::write(path, contents).unwrap(); + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); +} + +fn path_with_fake_bin(fake_bin: &Path) -> OsString { + let mut paths = vec![fake_bin.to_path_buf()]; + paths.extend(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + )); + std::env::join_paths(paths).unwrap() +} + +fn write_mock_curl(fake_bin: &Path) { + write_executable( + &fake_bin.join("curl"), + r#"#!/bin/sh +set -eu + +printf '%s\n' "$*" >> "$MOCK_LOG_DIR/curl.log" + +url= +output= +while [ "$#" -gt 0 ]; do + case "$1" in + -o) + output=$2 + shift 2 + ;; + -*) + shift + ;; + *) + url=$1 + shift + ;; + esac +done + +case "$url" in + "$CONFLUENCE_CLI_GITHUB_API_URL"/repos/laipz8200/confluence-cli/releases/latest) + printf '{"tag_name":"v1.2.3"}\n' + ;; + *) + if [ -z "$output" ]; then + printf 'download output path was not provided\n' >&2 + exit 1 + fi + printf '%s\n' "$url" > "$MOCK_LOG_DIR/download.url" + printf 'archive' > "$output" + ;; +esac +"#, + ); +} + +fn write_mock_tar_for_target(fake_bin: &Path, target: &str) { + write_executable( + &fake_bin.join("tar"), + &format!( + r#"#!/bin/sh +set -eu + +extract_dir= +while [ "$#" -gt 0 ]; do + case "$1" in + -C) + extract_dir=$2 + shift 2 + ;; + *) + shift + ;; + esac +done + +if [ -z "$extract_dir" ]; then + printf 'extract directory was not provided\n' >&2 + exit 1 +fi + +package="$extract_dir/confluence-cli-1.2.3-{target}" +mkdir -p "$package" +cat > "$package/confluence-cli" <<'BIN' +#!/bin/sh +printf 'confluence-cli 1.2.3\n' +BIN +chmod 755 "$package/confluence-cli" +"#, + target = target + ), + ); +} + +fn write_mock_tar(fake_bin: &Path) { + write_mock_tar_for_target(fake_bin, "x86_64-unknown-linux-gnu"); +} + +fn run_install_script(temp: &Path, fake_bin: &Path, install_dir: &Path) -> Output { + Command::new("sh") + .arg(install_script()) + .env("PATH", path_with_fake_bin(fake_bin)) + .env("HOME", temp) + .env("MOCK_LOG_DIR", temp) + .env("CONFLUENCE_CLI_GITHUB_API_URL", "https://api.github.test") + .env("CONFLUENCE_CLI_GITHUB_BASE_URL", "https://github.example.test") + .env("CONFLUENCE_CLI_INSTALL_DIR", install_dir) + .env("CONFLUENCE_CLI_TARGET", "x86_64-unknown-linux-gnu") + .output() + .unwrap() +} + +#[test] +fn install_script_downloads_latest_release_asset_for_target() { + let temp = tempdir().unwrap(); + let fake_bin = temp.path().join("bin"); + let install_dir = temp.path().join("install"); + fs::create_dir(&fake_bin).unwrap(); + write_mock_curl(&fake_bin); + write_mock_tar(&fake_bin); + + let output = run_install_script(temp.path(), &fake_bin, &install_dir); + + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + fs::read_to_string(temp.path().join("download.url")).unwrap().trim(), + "https://github.example.test/laipz8200/confluence-cli/releases/download/v1.2.3/confluence-cli-1.2.3-x86_64-unknown-linux-gnu.tar.gz" + ); + assert!(install_dir.join("confluence-cli").is_file()); +} + +#[test] +fn install_script_rejects_unsupported_platforms_before_download() { + let temp = tempdir().unwrap(); + let fake_bin = temp.path().join("bin"); + let install_dir = temp.path().join("install"); + fs::create_dir(&fake_bin).unwrap(); + write_mock_curl(&fake_bin); + write_executable( + &fake_bin.join("uname"), + r#"#!/bin/sh +case "$1" in + -s) printf 'FreeBSD\n' ;; + -m) printf 'x86_64\n' ;; +esac +"#, + ); + + let output = Command::new("sh") + .arg(install_script()) + .env("PATH", path_with_fake_bin(&fake_bin)) + .env("HOME", temp.path()) + .env("MOCK_LOG_DIR", temp.path()) + .env("CONFLUENCE_CLI_GITHUB_API_URL", "https://api.github.test") + .env("CONFLUENCE_CLI_GITHUB_BASE_URL", "https://github.example.test") + .env("CONFLUENCE_CLI_INSTALL_DIR", &install_dir) + .output() + .unwrap(); + + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("Unsupported platform")); + assert!(!temp.path().join("download.url").exists()); +} + +#[test] +fn install_script_detects_linux_arm64_release_target() { + let temp = tempdir().unwrap(); + let fake_bin = temp.path().join("bin"); + let install_dir = temp.path().join("install"); + fs::create_dir(&fake_bin).unwrap(); + write_mock_curl(&fake_bin); + write_mock_tar_for_target(&fake_bin, "aarch64-unknown-linux-gnu"); + write_executable( + &fake_bin.join("uname"), + r#"#!/bin/sh +case "$1" in + -s) printf 'Linux\n' ;; + -m) printf 'aarch64\n' ;; +esac +"#, + ); + + let output = Command::new("sh") + .arg(install_script()) + .env("PATH", path_with_fake_bin(&fake_bin)) + .env("HOME", temp.path()) + .env("MOCK_LOG_DIR", temp.path()) + .env("CONFLUENCE_CLI_GITHUB_API_URL", "https://api.github.test") + .env("CONFLUENCE_CLI_GITHUB_BASE_URL", "https://github.example.test") + .env("CONFLUENCE_CLI_INSTALL_DIR", &install_dir) + .output() + .unwrap(); + + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + fs::read_to_string(temp.path().join("download.url")).unwrap().trim(), + "https://github.example.test/laipz8200/confluence-cli/releases/download/v1.2.3/confluence-cli-1.2.3-aarch64-unknown-linux-gnu.tar.gz" + ); + assert!(install_dir.join("confluence-cli").is_file()); +}