| 1 | #!/bin/sh |
| 2 | set -eu |
| 3 | |
| 4 | repo="Hmbown/CodeWhale" |
| 5 | version="${CODEWHALE_VERSION:-latest}" |
| 6 | release_base="${CODEWHALE_RELEASE_BASE_URL:-${DEEPSEEK_TUI_RELEASE_BASE_URL:-}}" |
| 7 | |
| 8 | usage() { |
| 9 | cat <<'USAGE' |
| 10 | Codewhale GitHub release installer for new macOS and Linux installations. |
| 11 | For an existing direct install, run its codewhale update command. |
| 12 | |
| 13 | Usage: |
| 14 | curl -fsSL https://codewhale.net/install.sh | sh |
| 15 | |
| 16 | Environment: |
| 17 | CODEWHALE_INSTALL_DIR Install directory. Default: $HOME/.local/bin |
| 18 | CODEWHALE_VERSION Release tag for a fresh directory. Default: latest |
| 19 | CODEWHALE_RELEASE_BASE_URL |
| 20 | Custom release asset base URL ending in /download |
| 21 | CODEWHALE_SKIP_GLIBC_CHECK=1 |
| 22 | Skip Linux arm64 glibc compatibility preflight |
| 23 | |
| 24 | Examples: |
| 25 | curl -fsSL https://codewhale.net/install.sh | CODEWHALE_INSTALL_DIR="$HOME/.local/codewhale/bin" sh |
| 26 | curl -fsSL https://codewhale.net/install.sh | CODEWHALE_VERSION=vX.Y.Z sh |
| 27 | USAGE |
| 28 | } |
| 29 | |
| 30 | case "${1:-}" in |
| 31 | -h|--help) |
| 32 | usage |
| 33 | exit 0 |
| 34 | ;; |
| 35 | esac |
| 36 | |
| 37 | say() { |
| 38 | printf '%s\n' "$*" |
| 39 | } |
| 40 | |
| 41 | fail() { |
| 42 | printf 'codewhale install: %s\n' "$*" >&2 |
| 43 | exit 1 |
| 44 | } |
| 45 | |
| 46 | if [ -n "${CODEWHALE_INSTALL_DIR:-}" ]; then |
| 47 | install_dir="$CODEWHALE_INSTALL_DIR" |
| 48 | else |
| 49 | [ -n "${HOME:-}" ] || fail "HOME is not set; set CODEWHALE_INSTALL_DIR" |
| 50 | install_dir="$HOME/.local/bin" |
| 51 | fi |
| 52 | |
| 53 | need_cmd() { |
| 54 | command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1" |
| 55 | } |
| 56 | |
| 57 | download() { |
| 58 | url="$1" |
| 59 | out="$2" |
| 60 | if command -v curl >/dev/null 2>&1; then |
| 61 | curl -fsSL "$url" -o "$out" |
| 62 | elif command -v wget >/dev/null 2>&1; then |
| 63 | wget -q "$url" -O "$out" |
| 64 | else |
| 65 | fail "curl or wget is required" |
| 66 | fi |
| 67 | } |
| 68 | |
| 69 | sha256_file() { |
| 70 | file="$1" |
| 71 | if command -v sha256sum >/dev/null 2>&1; then |
| 72 | sha256sum "$file" | awk '{print $1}' |
| 73 | elif command -v shasum >/dev/null 2>&1; then |
| 74 | shasum -a 256 "$file" | awk '{print $1}' |
| 75 | else |
| 76 | fail "sha256sum or shasum is required to verify downloads" |
| 77 | fi |
| 78 | } |
| 79 | |
| 80 | verify_asset() { |
| 81 | asset="$1" |
| 82 | file="$2" |
| 83 | manifest="$3" |
| 84 | expected="$( |
| 85 | awk -v name="$asset" ' |
| 86 | { |
| 87 | digest = tolower($1) |
| 88 | file = $2 |
| 89 | sub(/^\*/, "", file) |
| 90 | if (file == name && digest ~ /^[0-9a-f]{64}$/) { |
| 91 | print digest |
| 92 | exit |
| 93 | } |
| 94 | } |
| 95 | ' "$manifest" |
| 96 | )" |
| 97 | [ -n "$expected" ] || fail "checksum not found for $asset" |
| 98 | actual="$(sha256_file "$file" | tr '[:upper:]' '[:lower:]')" |
| 99 | [ "$actual" = "$expected" ] || fail "checksum mismatch for $asset" |
| 100 | } |
| 101 | |
| 102 | glibc_version() { |
| 103 | if command -v getconf >/dev/null 2>&1; then |
| 104 | getconf GNU_LIBC_VERSION 2>/dev/null | awk '{ print $NF; exit }' |
| 105 | return |
| 106 | fi |
| 107 | if command -v ldd >/dev/null 2>&1; then |
| 108 | ldd --version 2>/dev/null | awk 'NR == 1 { |
| 109 | for (i = 1; i <= NF; i++) { |
| 110 | if ($i ~ /^[0-9]+\.[0-9]+/) { |
| 111 | print $i |
| 112 | exit |
| 113 | } |
| 114 | } |
| 115 | }' |
| 116 | fi |
| 117 | } |
| 118 | |
| 119 | version_at_least() { |
| 120 | have="$1" |
| 121 | need="$2" |
| 122 | awk -v have="$have" -v need="$need" ' |
| 123 | BEGIN { |
| 124 | split(have, h, ".") |
| 125 | split(need, n, ".") |
| 126 | for (i = 1; i <= 3; i++) { |
| 127 | hv = h[i] + 0 |
| 128 | nv = n[i] + 0 |
| 129 | if (hv > nv) exit 0 |
| 130 | if (hv < nv) exit 1 |
| 131 | } |
| 132 | exit 0 |
| 133 | } |
| 134 | ' |
| 135 | } |
| 136 | |
| 137 | check_glibc() { |
| 138 | case "$target" in |
| 139 | linux-arm64) ;; |
| 140 | *) return ;; |
| 141 | esac |
| 142 | |
| 143 | # Linux arm64 assets became static musl builds in v0.9.6. `latest` and |
| 144 | # explicit v0.9.6+ installs therefore have no glibc floor. Keep the |
| 145 | # preflight only for explicitly requested older releases, whose arm64 |
| 146 | # assets were linked against GNU libc on Ubuntu 24.04. |
| 147 | if [ "$version" = "latest" ]; then |
| 148 | return |
| 149 | fi |
| 150 | numeric_version="${version#v}" |
| 151 | if awk -v have="$numeric_version" ' |
| 152 | BEGIN { |
| 153 | if (have !~ /^[0-9]+\.[0-9]+\.[0-9]+$/) exit 1 |
| 154 | split(have, h, ".") |
| 155 | if (h[1] > 0) exit 0 |
| 156 | if (h[1] < 0) exit 1 |
| 157 | if (h[2] > 9) exit 0 |
| 158 | if (h[2] < 9) exit 1 |
| 159 | exit !(h[3] >= 6) |
| 160 | } |
| 161 | '; then |
| 162 | return |
| 163 | fi |
| 164 | |
| 165 | [ "${CODEWHALE_SKIP_GLIBC_CHECK:-}" = "1" ] && return |
| 166 | [ "${DEEPSEEK_TUI_SKIP_GLIBC_CHECK:-}" = "1" ] && return |
| 167 | [ "${DEEPSEEK_SKIP_GLIBC_CHECK:-}" = "1" ] && return |
| 168 | |
| 169 | required="2.39" |
| 170 | host="$(glibc_version || true)" |
| 171 | if [ -z "$host" ] || ! version_at_least "$host" "$required"; then |
| 172 | cat >&2 <<EOF |
| 173 | codewhale install: Codewhale $version $target assets require glibc $required or newer. |
| 174 | This system reports glibc ${host:-unavailable}. |
| 175 | |
| 176 | Linux arm64 assets before v0.9.6 were GNU libc builds from Ubuntu 24.04. |
| 177 | Current v0.9.6+ assets are static musl builds. Build this older release from |
| 178 | source with Cargo or set |
| 179 | CODEWHALE_SKIP_GLIBC_CHECK=1 to bypass this check at your own risk. |
| 180 | EOF |
| 181 | exit 1 |
| 182 | fi |
| 183 | } |
| 184 | |
| 185 | detect_platform() { |
| 186 | os="$(uname -s)" |
| 187 | arch="$(uname -m)" |
| 188 | |
| 189 | if [ -n "${TERMUX_VERSION:-}" ] || [ "$(uname -o 2>/dev/null || true)" = "Android" ]; then |
| 190 | fail "Android/Termux needs the Android arm64 preview archive, not a Linux binary. See https://github.com/Hmbown/CodeWhale/blob/main/docs/INSTALL.md" |
| 191 | fi |
| 192 | |
| 193 | case "$os" in |
| 194 | Darwin) platform="macos" ;; |
| 195 | Linux) platform="linux" ;; |
| 196 | *) fail "unsupported OS: $os. Use the matching asset at https://github.com/Hmbown/CodeWhale/releases/latest; npm and Cargo are secondary options." ;; |
| 197 | esac |
| 198 | |
| 199 | case "$arch" in |
| 200 | x86_64|amd64) cpu="x64" ;; |
| 201 | arm64|aarch64) cpu="arm64" ;; |
| 202 | riscv64) fail "Linux riscv64 prebuilt assets are temporarily unavailable because the locked rquickjs-sys dependency does not ship riscv64gc bindings." ;; |
| 203 | *) fail "unsupported CPU architecture: $arch. Use Cargo or build from source." ;; |
| 204 | esac |
| 205 | |
| 206 | printf '%s-%s' "$platform" "$cpu" |
| 207 | } |
| 208 | |
| 209 | if [ -z "$release_base" ]; then |
| 210 | if [ "$version" = "latest" ]; then |
| 211 | release_base="https://github.com/$repo/releases/latest/download" |
| 212 | else |
| 213 | release_base="https://github.com/$repo/releases/download/$version" |
| 214 | fi |
| 215 | fi |
| 216 | |
| 217 | target="$(detect_platform)" |
| 218 | check_glibc |
| 219 | cli_asset="codewhale-$target" |
| 220 | shim_asset="codew-$target" |
| 221 | manifest_asset="codewhale-artifacts-sha256.txt" |
| 222 | |
| 223 | tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t codewhale-install)" |
| 224 | trap 'rm -rf "$tmpdir"' EXIT INT TERM |
| 225 | |
| 226 | say "Installing Codewhale for $target" |
| 227 | say "Release assets: $release_base" |
| 228 | say "Install dir: $install_dir" |
| 229 | |
| 230 | download "$release_base/$manifest_asset" "$tmpdir/$manifest_asset" |
| 231 | download "$release_base/$cli_asset" "$tmpdir/codewhale" |
| 232 | download "$release_base/$shim_asset" "$tmpdir/codew" |
| 233 | |
| 234 | verify_asset "$cli_asset" "$tmpdir/codewhale" "$tmpdir/$manifest_asset" |
| 235 | verify_asset "$shim_asset" "$tmpdir/codew" "$tmpdir/$manifest_asset" |
| 236 | say "Checksums verified" |
| 237 | |
| 238 | chmod 755 "$tmpdir/codewhale" "$tmpdir/codew" |
| 239 | if command -v xattr >/dev/null 2>&1; then |
| 240 | xattr -d com.apple.quarantine "$tmpdir/codewhale" "$tmpdir/codew" 2>/dev/null || true |
| 241 | fi |
| 242 | |
| 243 | # Resolve the real directory before applying managed-prefix checks. Never use |
| 244 | # sudo or allow an install directory symlink to obscure which files will change. |
| 245 | case "$install_dir" in |
| 246 | /*) ;; |
| 247 | *) fail "CODEWHALE_INSTALL_DIR must be an absolute path" ;; |
| 248 | esac |
| 249 | [ ! -L "$install_dir" ] || fail "install directory is a symlink: $install_dir; choose a fresh user directory" |
| 250 | mkdir -p "$install_dir" || fail "cannot create $install_dir; choose a writable user directory (no sudo is used)" |
| 251 | install_dir="$(cd -P "$install_dir" && pwd)" |
| 252 | case "$install_dir/" in |
| 253 | /bin/*|/sbin/*|/usr/bin/*|/usr/sbin/*|/nix/store/*|/gnu/store/*|*/node_modules/*|*/Cellar/*|*/.linuxbrew/*|*/linuxbrew/*|*/.cargo/bin/*) |
| 254 | fail "refusing managed/system directory $install_dir; use a fresh user directory" |
| 255 | ;; |
| 256 | esac |
| 257 | [ -w "$install_dir" ] || fail "$install_dir is not writable; choose a user directory (no sudo is used)" |
| 258 | |
| 259 | check_destination() { |
| 260 | destination="$1" |
| 261 | source="$2" |
| 262 | destination_exists=0 |
| 263 | if [ -e "$destination" ] || [ -L "$destination" ]; then |
| 264 | if [ ! -L "$destination" ] && [ -f "$destination" ] && [ -x "$destination" ] && cmp -s "$source" "$destination"; then |
| 265 | destination_exists=1 |
| 266 | return |
| 267 | fi |
| 268 | cat >&2 <<EOF |
| 269 | codewhale install: refusing to replace existing $destination. |
| 270 | It may be a newer build, another installation, or a symlink. No existing file was changed. |
| 271 | For an existing direct Codewhale install, run its full path with 'update'. |
| 272 | To migrate safely from a package manager or mixed installation, create a fresh directory: |
| 273 | mkdir -p "\$HOME/.local" |
| 274 | codewhale_install_dir="\$(mktemp -d "\$HOME/.local/codewhale-release.XXXXXX")" |
| 275 | curl -fsSL https://codewhale.net/install.sh | CODEWHALE_INSTALL_DIR="\$codewhale_install_dir" sh |
| 276 | "\$codewhale_install_dir/codewhale" --version |
| 277 | export PATH="\$codewhale_install_dir:\$PATH" |
| 278 | hash -r |
| 279 | command -v codewhale codew |
| 280 | EOF |
| 281 | exit 1 |
| 282 | fi |
| 283 | } |
| 284 | |
| 285 | # Check every command before publishing any of them. Existing identical release |
| 286 | # files are an idempotent install; anything different uses the canonical updater. |
| 287 | check_destination "$install_dir/codewhale" "$tmpdir/codewhale" |
| 288 | check_destination "$install_dir/codew" "$tmpdir/codew" |
| 289 | legacy_tui="$install_dir/codewhale-tui" |
| 290 | if [ -e "$legacy_tui" ] || [ -L "$legacy_tui" ]; then |
| 291 | check_destination "$legacy_tui" "$tmpdir/codewhale" |
| 292 | fi |
| 293 | |
| 294 | stage="" |
| 295 | stage_dir="" |
| 296 | trap 'rm -rf "$tmpdir"; if [ -n "$stage" ]; then rm -f "$stage"; fi; if [ -n "$stage_dir" ]; then rmdir "$stage_dir"; fi' EXIT INT TERM |
| 297 | install_binary() { |
| 298 | source="$1" |
| 299 | destination="$2" |
| 300 | # Recheck immediately before publication. Never replace a file another |
| 301 | # process created since preflight: linking the staged inode is no-clobber. |
| 302 | check_destination "$destination" "$source" |
| 303 | if [ "$destination_exists" -eq 1 ]; then |
| 304 | say "Already installed: $destination" |
| 305 | return |
| 306 | fi |
| 307 | stage_dir="$(mktemp -d "$install_dir/.codewhale-install.XXXXXX")" |
| 308 | stage="$stage_dir/$(basename "$destination")" |
| 309 | cp "$source" "$stage" |
| 310 | chmod 755 "$stage" |
| 311 | # Pass the intended parent as the directory operand. Passing destination |
| 312 | # itself would make ln treat a raced-in directory/symlink as a container. |
| 313 | ln "$stage" "$install_dir/" || fail "destination appeared during install: $destination; it was not replaced" |
| 314 | [ ! -L "$destination" ] && [ -f "$destination" ] && cmp -s "$stage" "$destination" || fail "installed path changed during publication: $destination" |
| 315 | rm -f "$stage" |
| 316 | rmdir "$stage_dir" |
| 317 | stage="" |
| 318 | stage_dir="" |
| 319 | } |
| 320 | |
| 321 | install_binary "$tmpdir/codewhale" "$install_dir/codewhale" |
| 322 | install_binary "$tmpdir/codew" "$install_dir/codew" |
| 323 | |
| 324 | say "Installed checksummed release commands:" |
| 325 | say " $install_dir/codewhale" |
| 326 | say " $install_dir/codew" |
| 327 | |
| 328 | say "" |
| 329 | say "Use this installation: \"$install_dir/codewhale\"" |
| 330 | say "Future updates: \"$install_dir/codewhale\" update" |
| 331 | for command_name in codewhale codew; do |
| 332 | resolved="$(command -v "$command_name" 2>/dev/null || true)" |
| 333 | if [ "$resolved" != "$install_dir/$command_name" ]; then |
| 334 | say "PATH selects ${resolved:-no $command_name command}; this install is $install_dir/$command_name" |
| 335 | fi |
| 336 | done |
| 337 | say "To use this directory in the current shell, then verify the commands:" |
| 338 | say " export PATH=\"$install_dir:\$PATH\"" |
| 339 | say " hash -r" |
| 340 | say " command -v codewhale codew" |
| 341 | say "Keep the directory first in your shell profile after verifying it." |
| 342 | if ! command -v node >/dev/null 2>&1; then |
| 343 | say "Computer Use is included and needs Node.js 20 or newer on PATH." |
| 344 | say "Install Node.js from https://nodejs.org/, then restart Codewhale to enable Computer Use." |
| 345 | fi |
| 346 |