| 1 | #!/usr/bin/env bash |
| 2 | # Installs the Diffinery CLI on Linux and macOS: |
| 3 | # |
| 4 | # curl -fsSL https://diffinery.com/install | bash |
| 5 | # |
| 6 | # The CLI goes in ~/.diffinery (set DIFFINERY_INSTALL to change that), and ~/.diffinery/bin is |
| 7 | # added to PATH in your shells' startup files, with a link in ~/.local/bin or ~/bin when one of |
| 8 | # those is on PATH already so it works at once (DIFFINERY_NO_MODIFY_PATH=1 skips both). It runs on |
| 9 | # Node.js 22 or later: the one on your PATH when it's new enough, otherwise a private copy of the |
| 10 | # Node.js LTS from nodejs.org, checked against its published SHA-256, in ~/.diffinery/node. |
| 11 | # Run it again, or `diffinery upgrade`, to update. Served by Diffinery from apps/cli/install/. |
| 12 | |
| 13 | # Piped into sh instead of bash, a shell like dash stops at `set -o pipefail` with no hint why. |
| 14 | if [ -z "${BASH_VERSION:-}" ]; then |
| 15 | echo "This installer needs bash: curl -fsSL __DIFFINERY_URL__/install | bash" >&2 |
| 16 | exit 1 |
| 17 | fi |
| 18 | set -euo pipefail |
| 19 | |
| 20 | server=${DIFFINERY_URL:-__DIFFINERY_URL__} |
| 21 | case $server in __*) server=https://diffinery.com ;; esac |
| 22 | server=${server%/} |
| 23 | root=${DIFFINERY_INSTALL:-$HOME/.diffinery} |
| 24 | bin=$root/bin |
| 25 | node_line=24 |
| 26 | |
| 27 | if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then |
| 28 | bold=$'\033[1m' dim=$'\033[2m' green=$'\033[32m' red=$'\033[31m' reset=$'\033[0m' |
| 29 | else |
| 30 | bold='' dim='' green='' red='' reset='' |
| 31 | fi |
| 32 | say() { printf '%s\n' "$*" >&2; } |
| 33 | die() { |
| 34 | say "${red}error${reset}: $*" |
| 35 | exit 1 |
| 36 | } |
| 37 | tilde() { case $1 in "$HOME"/*) printf '~%s' "${1#"$HOME"}" ;; *) printf '%s' "$1" ;; esac; } |
| 38 | |
| 39 | command -v curl >/dev/null || die "curl is required" |
| 40 | case $(uname -s) in |
| 41 | Linux) os=linux ;; |
| 42 | Darwin) os=darwin ;; |
| 43 | MINGW* | MSYS* | CYGWIN*) die "on Windows, run this in PowerShell instead: irm $server/install.ps1 | iex" ;; |
| 44 | *) die "$(uname -s) isn't supported by this script; install Node.js 22 or later and download $server/-/cli/diffinery.mjs" ;; |
| 45 | esac |
| 46 | case $(uname -m) in |
| 47 | x86_64 | amd64) arch=x64 ;; |
| 48 | arm64 | aarch64) arch=arm64 ;; |
| 49 | *) arch='' ;; |
| 50 | esac |
| 51 | # A shell under Rosetta on Apple silicon still gets the arm64 Node.js. |
| 52 | if [ "$os" = darwin ] && [ "$arch" = x64 ] && [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || true)" = 1 ]; then arch=arm64; fi |
| 53 | |
| 54 | tmp=$(mktemp -d) |
| 55 | trap 'rm -rf "$tmp"' EXIT |
| 56 | |
| 57 | node_ok() { "$1" -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 22 ? 0 : 1)' >/dev/null 2>&1; } |
| 58 | |
| 59 | # 1. Node.js for the CLI to run on. |
| 60 | private=$root/node/bin/node |
| 61 | if [ -x "$private" ] && node_ok "$private"; then |
| 62 | node=$private |
| 63 | elif command -v node >/dev/null && node_ok node; then |
| 64 | node='' |
| 65 | else |
| 66 | [ -n "$arch" ] || die "the CLI needs Node.js 22 or later, and nodejs.org has no build for $(uname -m); install Node.js and run this again" |
| 67 | if [ "$os" = linux ] && ldd --version 2>&1 | grep -qi musl; then die "the CLI needs Node.js 22 or later; on Alpine and other musl systems install it with your package manager (apk add nodejs) and run this again"; fi |
| 68 | say "Installing Node.js $node_line LTS for the CLI, from nodejs.org…" |
| 69 | dist=https://nodejs.org/dist/latest-v$node_line.x |
| 70 | sums=$(curl -fsSL "$dist/SHASUMS256.txt") || die "couldn't reach nodejs.org" |
| 71 | line=$(printf '%s\n' "$sums" | grep -E " node-v[0-9.]+-$os-$arch\.tar\.gz$" | head -n 1 || true) |
| 72 | [ -n "$line" ] || die "nodejs.org has no Node.js build for $os-$arch" |
| 73 | want=${line%% *} |
| 74 | file=${line##* } |
| 75 | curl -fsSL "$dist/$file" -o "$tmp/node.tar.gz" || die "couldn't download $file" |
| 76 | if command -v sha256sum >/dev/null; then got=$(sha256sum "$tmp/node.tar.gz"); else got=$(shasum -a 256 "$tmp/node.tar.gz"); fi |
| 77 | [ "${got%% *}" = "$want" ] || die "$file doesn't match its published SHA-256, so it wasn't installed" |
| 78 | mkdir -p "$tmp/node" "$root" |
| 79 | tar -xzf "$tmp/node.tar.gz" -C "$tmp/node" --strip-components 1 |
| 80 | rm -rf "$root/node" |
| 81 | mv "$tmp/node" "$root/node" |
| 82 | node=$private |
| 83 | fi |
| 84 | |
| 85 | # 2. The CLI itself: one JavaScript file. |
| 86 | curl -fsSL "$server/-/cli/diffinery.mjs" -o "$tmp/diffinery.mjs" || die "couldn't download the CLI from $server" |
| 87 | head -n 1 "$tmp/diffinery.mjs" | grep -q '^#!/usr/bin/env node' || die "$server/-/cli/diffinery.mjs isn't the CLI" |
| 88 | mkdir -p "$root/lib" "$bin" |
| 89 | chmod 755 "$tmp/diffinery.mjs" |
| 90 | mv -f "$tmp/diffinery.mjs" "$root/lib/diffinery.mjs" |
| 91 | |
| 92 | # 3. The diffinery command: the private Node.js when there is one, else the node on PATH. |
| 93 | q=$(printf '%q' "$root") |
| 94 | cat >"$tmp/diffinery" <<EOF |
| 95 | #!/bin/sh |
| 96 | # The Diffinery CLI, installed from $server/install. Update it with: diffinery upgrade |
| 97 | root=$q |
| 98 | node=\$root/node/bin/node |
| 99 | [ -x "\$node" ] || node=node |
| 100 | exec "\$node" "\$root/lib/diffinery.mjs" "\$@" |
| 101 | EOF |
| 102 | chmod 755 "$tmp/diffinery" |
| 103 | mv -f "$tmp/diffinery" "$bin/diffinery" |
| 104 | version=$("$bin/diffinery" --version) || die "the CLI was installed in $(tilde "$root") but doesn't start; run $bin/diffinery --version to see why" |
| 105 | |
| 106 | # 4. PATH: in startup files for new terminals, plus a link in a directory already on PATH (such as |
| 107 | # ~/.local/bin) so the command works right away, in this terminal and in agents' shells too. |
| 108 | added='' kept='' ready='' linked='' |
| 109 | # As written in startup files: $HOME/.diffinery/bin rather than the expanded path, when it's in $HOME. |
| 110 | case $bin in "$HOME"/*) rc_bin='$HOME'${bin#"$HOME"} ;; *) rc_bin=$bin ;; esac |
| 111 | on_path() { case ":$PATH:" in *":$1:"*) return 0 ;; esac; return 1; } |
| 112 | # Adds the line for this kind of file, once: sh syntax for bash, zsh and sh, fish's own for fish. |
| 113 | add_to() { |
| 114 | local rc=$1 line |
| 115 | case $rc in |
| 116 | *.fish) line="fish_add_path \"$rc_bin\"" ;; |
| 117 | *) line="case \":\$PATH:\" in *\":$rc_bin:\"*) ;; *) export PATH=\"$rc_bin:\$PATH\" ;; esac" ;; |
| 118 | esac |
| 119 | if grep -qsF -e "$bin" -e "$rc_bin" "$rc"; then kept=${kept:+$kept, }$(tilde "$rc"); return; fi |
| 120 | mkdir -p "$(dirname "$rc")" |
| 121 | printf '\n# Diffinery CLI\n%s\n' "$line" >>"$rc" |
| 122 | added=${added:+$added, }$(tilde "$rc") |
| 123 | } |
| 124 | if on_path "$bin"; then |
| 125 | ready=1 |
| 126 | elif [ -z "${DIFFINERY_NO_MODIFY_PATH:-}" ]; then |
| 127 | # The startup files of the login shell, created if missing, and of any other shell already set |
| 128 | # up here. Bash reads only the first of ~/.bash_profile, ~/.bash_login and ~/.profile in a login |
| 129 | # shell (every macOS terminal), so the line goes in the one it reads, never a new one that hides it. |
| 130 | shell=$(basename "${SHELL:-sh}") |
| 131 | zrc=${ZDOTDIR:-$HOME}/.zshrc |
| 132 | bash_login='' |
| 133 | for f in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do |
| 134 | if [ -f "$f" ]; then bash_login=$f; break; fi |
| 135 | done |
| 136 | rcs=() |
| 137 | case $shell in |
| 138 | zsh) rcs+=("$zrc") ;; |
| 139 | bash) rcs+=("$HOME/.bashrc" "${bash_login:-$([ "$os" = darwin ] && echo "$HOME/.bash_profile" || echo "$HOME/.profile")}") ;; |
| 140 | fish) rcs+=("$HOME/.config/fish/conf.d/diffinery.fish") ;; |
| 141 | *) rcs+=("$HOME/.profile") ;; |
| 142 | esac |
| 143 | [ "$shell" = zsh ] || [ ! -f "$zrc" ] || rcs+=("$zrc") |
| 144 | [ "$shell" = bash ] || [ ! -f "$HOME/.bashrc" ] || rcs+=("$HOME/.bashrc") |
| 145 | [ "$shell" = fish ] || [ ! -d "$HOME/.config/fish" ] || rcs+=("$HOME/.config/fish/conf.d/diffinery.fish") |
| 146 | for rc in "${rcs[@]}"; do add_to "$rc"; done |
| 147 | # A link from a personal bin directory that's already on PATH, unless something else lives there. |
| 148 | for dir in "$HOME/.local/bin" "$HOME/bin"; do |
| 149 | if on_path "$dir" && [ -d "$dir" ] && [ -w "$dir" ]; then |
| 150 | if [ "$(readlink "$dir/diffinery" 2>/dev/null || true)" = "$bin/diffinery" ] || { [ ! -e "$dir/diffinery" ] && [ ! -L "$dir/diffinery" ]; }; then |
| 151 | ln -sfn "$bin/diffinery" "$dir/diffinery" |
| 152 | linked=$dir/diffinery ready=1 |
| 153 | fi |
| 154 | break |
| 155 | fi |
| 156 | done |
| 157 | # In a workflow job, later steps get it on PATH too. |
| 158 | if [ -n "${GITHUB_PATH:-}" ] && [ -w "${GITHUB_PATH}" ]; then echo "$bin" >>"$GITHUB_PATH"; fi |
| 159 | fi |
| 160 | |
| 161 | say "${green}✓${reset} ${version} installed to $(tilde "$bin/diffinery")${node:+ ${dim}(with its own Node.js)${reset}}" |
| 162 | if [ -n "$added" ]; then |
| 163 | say "Added $(tilde "$bin") to PATH in $added." |
| 164 | elif [ -n "$kept" ]; then |
| 165 | say "New terminals already get $(tilde "$bin") on PATH from $kept." |
| 166 | fi |
| 167 | [ -z "$linked" ] || say "Linked it from $(tilde "$linked"), so diffinery works in this terminal now." |
| 168 | if [ -z "$ready" ]; then |
| 169 | if [ -n "${DIFFINERY_NO_MODIFY_PATH:-}" ]; then |
| 170 | say "Add $(tilde "$bin") to your PATH to run diffinery." |
| 171 | else |
| 172 | say "Open a new terminal, or run this to use diffinery in this one:" |
| 173 | say " ${bold}export PATH=\"$rc_bin:\$PATH\"${reset}" |
| 174 | fi |
| 175 | fi |
| 176 | other=$(command -v diffinery 2>/dev/null || true) |
| 177 | if [ -n "$other" ] && [ "$other" != "$bin/diffinery" ] && [ "$(readlink "$other" 2>/dev/null || true)" != "$bin/diffinery" ]; then say "${dim}Another diffinery at $(tilde "$other") comes first on your PATH; remove it to use this one.${reset}"; fi |
| 178 | setup="diffinery setup" |
| 179 | [ "$server" = https://diffinery.com ] || setup="diffinery setup --url $server" |
| 180 | say "" |
| 181 | say "Next, sign in through your browser and set up git (and gh, if you have it):" |
| 182 | say " ${bold}$setup${reset}" |