diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16eb7b0..a47a2ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,3 +56,27 @@ jobs: - name: Run default app run: nix run .#default -- version + + install-script: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-14] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: ShellCheck + if: runner.os == 'Linux' + run: | + shellcheck -s sh install.sh + shellcheck scripts/install-sh-test.sh + + - name: Test install.sh with sh + run: scripts/install-sh-test.sh + + - name: Test install.sh with dash + if: runner.os == 'Linux' + run: INSTALL_SH_SHELL=dash scripts/install-sh-test.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6185dca..887cfcb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,7 @@ on: - "Cargo.lock" - "package.json" - "scripts/install.js" + - "install.sh" permissions: contents: write @@ -88,6 +89,14 @@ jobs: if: github.event_name != 'pull_request' runs-on: ubuntu-latest steps: + - name: Check out install script + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + sparse-checkout: install.sh + sparse-checkout-cone-mode: false + path: src + - name: Download target artifacts uses: actions/download-artifact@v4 with: @@ -107,3 +116,4 @@ jobs: idx-darwin-arm64 idx-darwin-x64 SHA256SUMS + src/install.sh diff --git a/README.md b/README.md index b2a785f..27d7d75 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,17 @@ CLI tool for Indonesian stock market (IDX) analysis, built in Rust for humans an ## Installation +### Install script (Linux, macOS) + +```bash +curl -fsSL https://raw.githubusercontent.com/0xrsydn/idx-cli/main/install.sh | sh +``` + +Downloads the prebuilt `idx` binary for your platform from GitHub Releases, +verifies it against the release `SHA256SUMS`, and installs it into +`~/.local/bin` without sudo. Pin a version or change the directory with +`sh -s -- --version v0.2.3 --dir ~/bin`. See [docs/INSTALL.md](docs/INSTALL.md). + ### Cargo ```bash @@ -12,7 +23,7 @@ cargo install idx-cli This installs the `idx` binary. `idx-cli` currently requires Rust `1.85+`. -If you use the Cargo install path directly, some commands also require helper tools at runtime: +If you use the install script or the Cargo install path directly, some commands also require helper tools at runtime: - Yahoo-authenticated flows require a `curl_chrome*` binary from `curl-impersonate-chrome` in `PATH`, or `IDX_CURL_IMPERSONATE_BIN` pointing at that binary. - Ownership PDF import requires `mutool` from MuPDF in `PATH`. diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..7de16b6 --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,74 @@ +# Install script + +`install.sh` installs a prebuilt `idx` binary from GitHub Releases. It is the +fastest path on Linux and macOS and needs no Rust toolchain, Node, or sudo. + +```bash +curl -fsSL https://raw.githubusercontent.com/0xrsydn/idx-cli/main/install.sh | sh +``` + +Each release also carries a copy of the script as a release asset, so a +versioned URL stays stable: + +```bash +curl -fsSL https://github.com/0xrsydn/idx-cli/releases/download/v0.2.4/install.sh | sh +``` + +## What it does + +1. Detects the OS and CPU with `uname` (Rosetta shells on Apple Silicon get + the native arm64 build). +2. Resolves the newest `vX.Y.Z` release through the GitHub API. It does not use + `/releases/latest`, because this repo also publishes non-app releases such + as `ownership-snapshot-current`. +3. Downloads the binary and `SHA256SUMS`, and refuses to install on a checksum + mismatch. +4. Runs `idx version` from the download to confirm it executes here. +5. Moves the binary into the install directory in one step, replacing any + previous install. +6. Prints a PATH hint when needed, plus notes about optional helper tools. + +It never edits shell profiles and never calls a system package manager. On +NixOS it suggests the flake instead, and when `mutool` or `curl_chrome*` are +missing it prints the matching package-manager command so you can install +them yourself. + +## Options + +| Flag | Environment | Default | +| --- | --- | --- | +| `--version ` | `IDX_VERSION` | newest `vX.Y.Z` release | +| `--dir ` | `IDX_INSTALL_DIR` | `$HOME/.local/bin` | +| | `IDX_GITHUB_URL` | `https://github.com` (for mirrors) | +| | `IDX_GITHUB_API` | `https://api.github.com` (for mirrors) | + +Pass flags through the pipe with `sh -s --`: + +```bash +curl -fsSL https://raw.githubusercontent.com/0xrsydn/idx-cli/main/install.sh | sh -s -- --version v0.2.3 --dir ~/bin +``` + +## Supported platforms + +Same assets as the npm package: `idx-linux-x64`, `idx-linux-arm64` (static +musl builds), `idx-darwin-arm64`, `idx-darwin-x64`. Windows users can run the +script inside WSL. + +## Uninstall + +```bash +rm ~/.local/bin/idx +``` + +Config and cache live in `~/.config/idx/` and `~/.cache/idx/`. + +## Tests + +`scripts/install-sh-test.sh` runs the script against a local fake release +server, so it needs no network. CI runs it with `sh` and `dash` on Linux and +with `sh` on macOS, plus ShellCheck. + +```bash +scripts/install-sh-test.sh +INSTALL_SH_SHELL=dash scripts/install-sh-test.sh +``` diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..af4f8b6 --- /dev/null +++ b/install.sh @@ -0,0 +1,255 @@ +#!/bin/sh +# idx-cli installer: downloads a prebuilt `idx` binary from GitHub Releases, +# verifies it against the release SHA256SUMS, and installs it without sudo. +# +# curl -fsSL https://raw.githubusercontent.com/0xrsydn/idx-cli/main/install.sh | sh +# curl -fsSL https://raw.githubusercontent.com/0xrsydn/idx-cli/main/install.sh | sh -s -- --version v0.2.3 +# +# Environment (flags take precedence): +# IDX_VERSION release tag to install (default: latest vX.Y.Z release) +# IDX_INSTALL_DIR install directory (default: $HOME/.local/bin) +# IDX_GITHUB_URL GitHub base URL, for mirrors (default: https://github.com) +# IDX_GITHUB_API GitHub API base URL, for mirrors (default: https://api.github.com) + +set -eu + +REPO="0xrsydn/idx-cli" +BIN_NAME="idx" + +say() { + printf 'idx-install: %s\n' "$*" +} + +err() { + printf 'idx-install: error: %s\n' "$*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +Install the idx CLI from GitHub Releases. + +Usage: install.sh [--version ] [--dir ] + +Options: + --version Release tag to install, for example v0.2.3 (default: latest) + --dir Install directory (default: $HOME/.local/bin) + -h, --help Show this help + +Environment: IDX_VERSION, IDX_INSTALL_DIR, IDX_GITHUB_URL, IDX_GITHUB_API +EOF +} + +has() { + command -v "$1" >/dev/null 2>&1 +} + +# fetch +fetch() { + if has curl; then + curl --fail --silent --show-error --location --retry 3 --output "$2" "$1" + elif has wget; then + wget --quiet --tries=3 --output-document="$2" "$1" + else + err "need curl or wget to download files" + fi +} + +sha256_of() { + if has sha256sum; then + sha256sum "$1" | cut -d ' ' -f 1 + elif has shasum; then + shasum -a 256 "$1" | cut -d ' ' -f 1 + else + err "need sha256sum or shasum to verify the download" + fi +} + +detect_asset() { + os="$(uname -s)" + arch="$(uname -m)" + + case "$os" in + Linux) os=linux ;; + Darwin) os=darwin ;; + *) err "unsupported OS: $os (supported: Linux, macOS; on Windows use WSL)" ;; + esac + + case "$arch" in + x86_64 | amd64) arch=x64 ;; + aarch64 | arm64) arch=arm64 ;; + *) err "unsupported CPU architecture: $arch (supported: x86_64, arm64)" ;; + esac + + # An x64 shell under Rosetta on Apple Silicon should still get the native build. + if [ "$os" = darwin ] && [ "$arch" = x64 ] && + [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)" = 1 ]; then + arch=arm64 + fi + + echo "idx-${os}-${arch}" +} + +# Pick the newest vX.Y.Z tag. The repo also carries non-app releases such as +# ownership-snapshot-current, so /releases/latest is not reliable here. +resolve_latest_version() { + releases="$tmp_dir/releases.json" + fetch "$api_url/repos/$REPO/releases?per_page=30" "$releases" || + err "could not list releases; pass --version to skip the lookup" + + tag="$( + sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\(v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)".*/\1/p' "$releases" | + head -n 1 + )" + [ -n "$tag" ] || err "no vX.Y.Z release found for $REPO" + echo "$tag" +} + +os_release_id() { + if [ -r /etc/os-release ]; then + # shellcheck source=/dev/null + (. /etc/os-release && echo "${ID:-}") + fi +} + +mutool_hint() { + if [ "$(uname -s)" = Darwin ]; then + echo "brew install mupdf" + return + fi + case "$(os_release_id)" in + arch | manjaro | endeavouros) echo "sudo pacman -S mupdf-tools" ;; + debian | ubuntu | linuxmint | pop) echo "sudo apt install mupdf-tools" ;; + fedora) echo "sudo dnf install mupdf" ;; + alpine) echo "sudo apk add mupdf-tools" ;; + opensuse* | sles) echo "sudo zypper install mupdf" ;; + *) echo "install MuPDF's mutool with your package manager" ;; + esac +} + +print_notes() { + if [ -e /etc/NIXOS ] || [ "$(os_release_id)" = nixos ]; then + say "NixOS detected: the flake wraps idx with its helper tools and may suit you better:" + say " nix profile install github:$REPO#default" + fi + + if ! has mutool; then + say "optional: 'idx ownership import' needs mutool from MuPDF: $(mutool_hint)" + fi + + found_curl_impersonate= + for candidate in curl_chrome142 curl_chrome136 curl_chrome133a curl_chrome131 curl_chrome124 curl_chrome120 curl_chrome116; do + if has "$candidate"; then + found_curl_impersonate=1 + break + fi + done + if [ -z "$found_curl_impersonate" ] && [ -z "${IDX_CURL_IMPERSONATE_BIN:-}" ]; then + say "optional: Yahoo-backed commands need a curl_chrome* binary from curl-impersonate" + say " (https://github.com/lexiforest/curl-impersonate) or IDX_CURL_IMPERSONATE_BIN" + fi +} + +print_path_hint() { + case ":$PATH:" in + *":$install_dir:"*) return ;; + esac + + case "${SHELL:-}" in + */fish) line="fish_add_path $install_dir" ;; + */zsh) line="echo 'export PATH=\"$install_dir:\$PATH\"' >> ~/.zshrc" ;; + *) line="echo 'export PATH=\"$install_dir:\$PATH\"' >> ~/.bashrc" ;; + esac + say "$install_dir is not on your PATH. Add it with:" + say " $line" +} + +main() { + version="${IDX_VERSION:-}" + install_dir="${IDX_INSTALL_DIR:-${HOME:?HOME is not set}/.local/bin}" + github_url="${IDX_GITHUB_URL:-https://github.com}" + api_url="${IDX_GITHUB_API:-https://api.github.com}" + + while [ $# -gt 0 ]; do + case "$1" in + --version) + [ $# -ge 2 ] || err "--version needs a value" + version="$2" + shift 2 + ;; + --dir) + [ $# -ge 2 ] || err "--dir needs a value" + install_dir="$2" + shift 2 + ;; + -h | --help) + usage + return 0 + ;; + *) err "unknown argument: $1 (see --help)" ;; + esac + done + + # A quoted --dir "~/bin" or IDX_INSTALL_DIR="~/bin" reaches us unexpanded. + # shellcheck disable=SC2088 # matching a literal, unexpanded ~ on purpose + case "$install_dir" in + "~") install_dir="$HOME" ;; + "~/"*) install_dir="$HOME/${install_dir#"~/"}" ;; + esac + github_url="${github_url%/}" + api_url="${api_url%/}" + + asset="$(detect_asset)" + + tmp_dir="$(mktemp -d 2>/dev/null || mktemp -d -t idx-install)" + trap 'rm -rf "$tmp_dir"' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + + if [ -z "$version" ]; then + version="$(resolve_latest_version)" + fi + case "$version" in + v*) ;; + *) version="v$version" ;; + esac + + download_base="$github_url/$REPO/releases/download/$version" + say "installing idx $version ($asset) into $install_dir" + + fetch "$download_base/$asset" "$tmp_dir/$asset" || + err "download failed: $download_base/$asset (does release $version have binaries?)" + fetch "$download_base/SHA256SUMS" "$tmp_dir/SHA256SUMS" || + err "download failed: $download_base/SHA256SUMS" + + expected="$(awk -v name="$asset" '$2 == name || $2 == "*" name { print $1; exit }' "$tmp_dir/SHA256SUMS")" + [ -n "$expected" ] || err "SHA256SUMS for $version has no entry for $asset" + actual="$(sha256_of "$tmp_dir/$asset")" + [ "$actual" = "$expected" ] || + err "checksum mismatch for $asset: expected $expected, got $actual" + + chmod 0755 "$tmp_dir/$asset" + "$tmp_dir/$asset" version >/dev/null 2>&1 || + err "downloaded binary does not run on this system" + + mkdir -p "$install_dir" || err "cannot create $install_dir" + staged="$install_dir/.$BIN_NAME.tmp.$$" + if ! { cp "$tmp_dir/$asset" "$staged" && chmod 0755 "$staged" && mv -f "$staged" "$install_dir/$BIN_NAME"; }; then + rm -f "$staged" + err "cannot write $install_dir/$BIN_NAME" + fi + + say "installed $install_dir/$BIN_NAME" + + existing="$(command -v "$BIN_NAME" 2>/dev/null || true)" + if [ -n "$existing" ] && [ "$existing" != "$install_dir/$BIN_NAME" ]; then + say "warning: another idx at $existing comes first on your PATH" + fi + + print_path_hint + print_notes + say "done; run 'idx --help' to get started" +} + +# Everything runs from main so a truncated download of this script does nothing. +main "$@" diff --git a/scripts/install-sh-test.sh b/scripts/install-sh-test.sh new file mode 100755 index 0000000..b4ab006 --- /dev/null +++ b/scripts/install-sh-test.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# Tests install.sh against a local fake GitHub release server (no live network). +# scripts/install-sh-test.sh # runs install.sh with sh +# INSTALL_SH_SHELL=dash scripts/install-sh-test.sh +set -Eeuo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +installer="${repo_root}/install.sh" +test_shell="${INSTALL_SH_SHELL:-sh}" +work="$(mktemp -d "${TMPDIR:-/tmp}/idx-install-test.XXXXXX")" +server_pid= + +cleanup() { + if [[ -n "$server_pid" ]]; then + kill "$server_pid" 2>/dev/null || true + fi + rm -rf "$work" +} +trap cleanup EXIT + +failures=0 +pass() { echo "ok - $1"; } +fail() { + echo "FAIL - $1" >&2 + failures=$((failures + 1)) +} + +sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d ' ' -f 1 + else + shasum -a 256 "$1" | cut -d ' ' -f 1 + fi +} + +case "$(uname -s)" in + Linux) os=linux ;; + Darwin) os=darwin ;; + *) echo "unsupported test host" >&2; exit 1 ;; +esac +case "$(uname -m)" in + x86_64 | amd64) arch=x64 ;; + *) arch=arm64 ;; +esac +if [[ "$os" == darwin && "$arch" == x64 && "$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)" == 1 ]]; then + arch=arm64 +fi +asset="idx-${os}-${arch}" + +# Fake release layout served by python's http.server: +# /api/repos/0xrsydn/idx-cli/releases (query string is ignored) +# /gh/0xrsydn/idx-cli/releases/download//... +root="${work}/srv" +api_dir="${root}/api/repos/0xrsydn/idx-cli" +mkdir -p "$api_dir" +cat >"${api_dir}/releases" <<'EOF' +[ + { + "tag_name": "ownership-snapshot-current", + "name": "Ownership Snapshot Current" + }, + { + "tag_name": "v0.2.4", + "name": "v0.2.4" + }, + { + "tag_name": "v0.2.3", + "name": "v0.2.3" + } +] +EOF + +make_release() { + local tag="$1" dir="${root}/gh/0xrsydn/idx-cli/releases/download/$1" + mkdir -p "$dir" + printf '#!/bin/sh\necho "idx fake %s"\n' "$tag" >"${dir}/${asset}" + chmod +x "${dir}/${asset}" + (cd "$dir" && echo "$(sha256 "$asset") ${asset}" >SHA256SUMS) +} +make_release v0.2.3 +make_release v0.2.4 + +# v0.0.9: checksum does not match the binary. +bad="${root}/gh/0xrsydn/idx-cli/releases/download/v0.0.9" +mkdir -p "$bad" +printf '#!/bin/sh\necho tampered\n' >"${bad}/${asset}" +printf '%064d %s\n' 0 "$asset" >"${bad}/SHA256SUMS" + +port="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])')" +python3 -m http.server "$port" --bind 127.0.0.1 --directory "$root" >/dev/null 2>&1 & +server_pid=$! +for _ in $(seq 50); do + curl -fs "http://127.0.0.1:${port}/api/repos/0xrsydn/idx-cli/releases" >/dev/null 2>&1 && break + sleep 0.1 +done + +run_installer() { + env IDX_GITHUB_URL="http://127.0.0.1:${port}/gh" \ + IDX_GITHUB_API="http://127.0.0.1:${port}/api" \ + HOME="${work}/home" \ + "$@" +} + +# 1. latest resolves to the newest vX.Y.Z tag, skipping non-app releases. +dir="${work}/latest" +if run_installer "$test_shell" "$installer" --dir "$dir" >"${work}/out1" 2>&1 && + [[ "$("${dir}/idx")" == "idx fake v0.2.4" ]]; then + pass "installs latest vX.Y.Z release" +else + fail "installs latest vX.Y.Z release"; cat "${work}/out1" >&2 +fi + +# 2. pinned version via env, without the leading v. +dir="${work}/pinned" +if run_installer env IDX_VERSION=0.2.3 IDX_INSTALL_DIR="$dir" "$test_shell" "$installer" >"${work}/out2" 2>&1 && + [[ "$("${dir}/idx")" == "idx fake v0.2.3" ]]; then + pass "installs pinned version from IDX_VERSION" +else + fail "installs pinned version from IDX_VERSION"; cat "${work}/out2" >&2 +fi + +# 3. piped through stdin like `curl ... | sh -s -- --version v0.2.3`. +dir="${work}/piped" +if run_installer "$test_shell" -s -- --version v0.2.3 --dir "$dir" <"$installer" >"${work}/out3" 2>&1 && + [[ "$("${dir}/idx")" == "idx fake v0.2.3" ]]; then + pass "works when piped to sh -s --" +else + fail "works when piped to sh -s --"; cat "${work}/out3" >&2 +fi + +# 4. checksum mismatch is rejected and nothing is installed. +dir="${work}/bad" +if run_installer "$test_shell" "$installer" --version v0.0.9 --dir "$dir" >"${work}/out4" 2>&1; then + fail "rejects checksum mismatch (installer succeeded)" +elif grep -q "checksum mismatch" "${work}/out4" && [[ ! -e "${dir}/idx" ]]; then + pass "rejects checksum mismatch" +else + fail "rejects checksum mismatch"; cat "${work}/out4" >&2 +fi + +# 5. missing release reports a clear download error. +if run_installer "$test_shell" "$installer" --version v9.9.9 --dir "${work}/missing" >"${work}/out5" 2>&1; then + fail "reports missing release (installer succeeded)" +elif grep -q "download failed" "${work}/out5"; then + pass "reports missing release" +else + fail "reports missing release"; cat "${work}/out5" >&2 +fi + +# 6. unsupported platform, via a uname shim on PATH. +shim="${work}/shim" +mkdir -p "$shim" +# shellcheck disable=SC2016 # literal $1 belongs to the shim +printf '#!/bin/sh\ncase "$1" in -s) echo FreeBSD ;; *) echo amd64 ;; esac\n' >"${shim}/uname" +chmod +x "${shim}/uname" +if run_installer env PATH="${shim}:${PATH}" "$test_shell" "$installer" --dir "${work}/bsd" >"${work}/out6" 2>&1; then + fail "rejects unsupported OS (installer succeeded)" +elif grep -q "unsupported OS: FreeBSD" "${work}/out6"; then + pass "rejects unsupported OS" +else + fail "rejects unsupported OS"; cat "${work}/out6" >&2 +fi + +# 7. PATH hint when the install dir is not on PATH. +if grep -q "is not on your PATH" "${work}/out1"; then + pass "prints PATH hint" +else + fail "prints PATH hint"; cat "${work}/out1" >&2 +fi + +# 8. reinstall over an existing binary replaces it. +dir="${work}/latest" +if run_installer "$test_shell" "$installer" --version v0.2.3 --dir "$dir" >"${work}/out8" 2>&1 && + [[ "$("${dir}/idx")" == "idx fake v0.2.3" ]] && + [[ -z "$(find "$dir" -name '.idx.tmp.*')" ]]; then + pass "replaces an existing install cleanly" +else + fail "replaces an existing install cleanly"; cat "${work}/out8" >&2 +fi + +# 9. quoted ~ in --dir expands to HOME; trailing slash on mirror URLs is fine. +# shellcheck disable=SC2088 # the literal ~ is the input under test +if env IDX_GITHUB_URL="http://127.0.0.1:${port}/gh/" \ + IDX_GITHUB_API="http://127.0.0.1:${port}/api/" \ + HOME="${work}/home" \ + "$test_shell" "$installer" --dir "~/tilde-bin" >"${work}/out9" 2>&1 && + [[ -x "${work}/home/tilde-bin/idx" ]]; then + pass "expands quoted ~ and tolerates trailing slashes" +else + fail "expands quoted ~ and tolerates trailing slashes"; cat "${work}/out9" >&2 +fi + +if ((failures > 0)); then + echo "install.sh tests: ${failures} failure(s) with ${test_shell}" >&2 + exit 1 +fi +echo "install.sh tests: all passed with ${test_shell}"