Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ include = [
"/src/**/*.jsonc",
"/README.md",
"/LICENSE",
"/install.sh",
"/crabcode-logo.txt",
"/favicon.png",
"/mascot.txt",
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ cargo install crabcode # or cargo (build from source)
curl -sSL https://raw.githubusercontent.com/Blankeos/crabcode/main/install.sh | sh # or linux/macos (via curl)
```

The shell installer downloads a prebuilt release, verifies its SHA256 checksum, and installs to `~/.local/bin`. Set `CRABCODE_INSTALL_DIR` for a different destination, or pass a version with `sh -s -- 0.0.14`. Installation and upgrades use compact version labels and amber progress in a terminal; redirected output stays plain.

### Upgrade

Detects how you installed (brew / npm / bun / cargo / install.sh) and upgrades in place:
Expand Down
292 changes: 238 additions & 54 deletions install.sh
Original file line number Diff line number Diff line change
@@ -1,64 +1,248 @@
#!/bin/bash
#!/bin/sh
# Prebuilt release installer. Also works with: curl .../install.sh | sh -s -- [version]
set -eu

set -e

VERSION="latest"
REPO="Blankeos/crabcode"
INSTALL_DIR="$HOME/.local/bin"
BINARY_NAME="crabcode"

echo "🦀 Installing crabcode..."

# Check if cargo is available
if command -v cargo &> /dev/null; then
echo "📦 Installing via cargo..."
cargo install crabcode
echo "✓ crabcode installed successfully via cargo"
echo ""
echo "Run: crabcode"
exit 0
fi

# Fall back to downloading pre-built binary
echo "⬇️ Downloading pre-built binary..."

# Determine platform
OS="$(uname -s)"
ARCH="$(uname -m)"

case "$OS" in
Linux*) OS="linux";;
Darwin*) OS="macos";;
*) echo "❌ Unsupported OS: $OS"; exit 1;;
say() { printf '%s\n' "$*" >&2; }
die() { say "Error: $*"; exit 1; }
valid_version() {
case "$1" in ''|*[!0-9A-Za-z.+-]*) return 1 ;; esac
printf '%s\n' "$1" | LC_ALL=C grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$'
}
[ "$#" -le 1 ] || die 'Usage: install.sh [version|latest]'
version=${1-latest}
case "$version" in
latest) ;;
*) version=${version#v}
# Keep tags out of URLs until validated (no options, paths, or whitespace).
valid_version "$version" || die 'Invalid version; expected a semantic version or latest' ;;
esac
for tool in curl tar awk od mktemp; do
command -v "$tool" >/dev/null 2>&1 || die "Required tool not found: $tool"
done
if command -v sha256sum >/dev/null 2>&1; then
hash_tool=sha256sum
elif command -v shasum >/dev/null 2>&1; then
hash_tool=shasum
else
die 'SHA256 verification requires sha256sum or shasum'
fi
os=$(uname -s)
arch=$(uname -m)
case "$arch" in arm64|aarch64) arch=aarch64 ;; x86_64) ;; *) die "Unsupported architecture: $arch" ;; esac
case "$os" in Darwin) target=$arch-apple-darwin ;; Linux) target=$arch-unknown-linux-gnu ;; *) die "Unsupported OS: $os" ;; esac
: "${HOME:?HOME must be set}"
install_dir=${CRABCODE_INSTALL_DIR:-$HOME/.local/bin}
case "$install_dir" in /*) ;; *) install_dir=$PWD/$install_dir ;; esac
work=$(mktemp -d "${TMPDIR:-/tmp}/crabcode-install.XXXXXXXX")
pending=
parser_pid=
probe_pid=
watchdog_pid=
cleanup() {
[ -z "$probe_pid" ] || kill -KILL "$probe_pid" 2>/dev/null || :
[ -z "$watchdog_pid" ] || kill "$watchdog_pid" 2>/dev/null || :
[ -z "$parser_pid" ] || kill "$parser_pid" 2>/dev/null || :
[ -z "$probe_pid" ] || wait "$probe_pid" 2>/dev/null || :
[ -z "$watchdog_pid" ] || wait "$watchdog_pid" 2>/dev/null || :
[ -z "$parser_pid" ] || wait "$parser_pid" 2>/dev/null || :
[ -z "$pending" ] || rm -f "$pending"
rm -rf "$work"
}
trap cleanup 0
trap 'exit 130' INT
trap 'exit 143' TERM HUP

case "$ARCH" in
x86_64) ARCH="x86_64";;
aarch64) ARCH="aarch64";;
*) echo "❌ Unsupported architecture: $ARCH"; exit 1;;
esac
repo=https://github.com/blankeos/crabcode
# Follow GitHub's HTTPS latest redirect; no JSON parser or API rate-limit dependency.
if [ "$version" = latest ]; then
if ! curl --proto '=https' --proto-redir '=https' --fail --location --silent --show-error \
--connect-timeout 10 --max-time 20 --output "$work/latest" --write-out '%{url_effective}' "$repo/releases/latest" >"$work/url"; then
die 'Could not resolve latest release'
fi
tag=$(cat "$work/url")
tag=${tag##*/}
version=${tag#v}
valid_version "$version" || die 'Latest release returned an invalid version'
fi
current='not installed'
installed=
if [ -x "$install_dir/crabcode" ]; then
installed=$install_dir/crabcode
elif command -v crabcode >/dev/null 2>&1; then
installed=$(command -v crabcode)
fi
if [ -n "$installed" ]; then
current=unknown
# No timeout utility on stock macOS. Probe only our child, with no access
# to the installer's stdin (which may itself be a piped shell script).
"$installed" --version </dev/null >"$work/version" 2>/dev/null &
probe_pid=$!
(
sleep 2 & timer=$!
trap 'kill "$timer" 2>/dev/null || :; wait "$timer" 2>/dev/null || :; exit 0' TERM INT HUP
wait "$timer"
kill -KILL "$probe_pid" 2>/dev/null || :
) &
watchdog_pid=$!
if wait "$probe_pid" 2>/dev/null; then
# Accept one compact line only; never echo arbitrary terminal controls.
candidate=$(LC_ALL=C awk 'NR==1 && length($0)<=128 { line=$0 } END { if(NR==1) print line }' "$work/version")
candidate=${candidate#crabcode }
if valid_version "$candidate"; then current=$candidate; fi
fi
probe_pid=
kill "$watchdog_pid" 2>/dev/null || :
wait "$watchdog_pid" 2>/dev/null || :
watchdog_pid=
fi
say "Installed version: $current"
say ''
say "Installing crabcode version: $version"

# Create install directory
mkdir -p "$INSTALL_DIR"
ui=0
color=0
width=30
if [ -t 2 ] && [ "${TERM:-dumb}" != dumb ]; then
ui=1
[ -n "${NO_COLOR+x}" ] || color=1
columns=${COLUMNS-}
if [ -z "${COLUMNS+x}" ]; then
size=$(stty size <&2 2>/dev/null || :)
columns=${size##* }
fi
case "$columns" in ''|*[!0-9]*) columns=80 ;; esac
[ "$columns" -gt 0 ] || columns=80
available=$((columns - 1))
width=$((available - 6))
[ "$width" -le 50 ] || width=50
[ "$width" -ge 0 ] || width=0
fi

# Download binary
BINARY_URL="https://github.com/${REPO}/releases/download/${VERSION}/crabcode-${OS}-${ARCH}"
# curl runs directly, not in a pipeline: its exit status and HTTP status survive.
# Parse its real, CR-delimited progress updates concurrently. Unknown-length
# transfers stay textual rather than inventing a percentage.
download() {
url=$1
destination=$2
: >"$work/errors"
phase=${3-archive}
max_time=300
if [ "$phase" = checksum ]; then
say 'Verifying download...'
max_time=20
else
say 'Downloading...'
fi
if [ "$ui" = 1 ] && [ "$phase" = archive ]; then
rm -f "$work/progress"
mkfifo "$work/progress"
LC_ALL=C awk -v width="$width" -v available="$available" -v color="$color" -v errors="$work/errors" '
BEGIN { RS="\r|\n"; shown=0 }
/[0-9]+(\.[0-9]+)?%/ {
match($0, /[0-9]+(\.[0-9]+)?%/)
percent=substr($0, RSTART, RLENGTH); value=percent+0
if (value<0 || value>100) next
filled=int(width*value/100); bar=""; rest=""
for(i=0;i<filled;i++) bar=bar "\342\226\210"
for(i=filled;i<width;i++) rest=rest "."
if(available<1) next
printf "\r"
if(color) printf "\033[33m"
percent=sprintf("%3d%%", int(value))
if(width>0) printf "%s%s %s", bar, rest, percent
else printf "%s", substr(percent, 1, available)
if(color) printf "\033[0m"
fflush(); shown=1; next
}
NF && $0 !~ /^[ #=-]+$/ { print $0 >> errors }
END { if(shown) printf "\n" }
' <"$work/progress" >&2 &
parser_pid=$!
if curl --proto '=https' --proto-redir '=https' --fail --location --show-error --progress-bar \
--connect-timeout 10 --max-time "$max_time" --output "$destination" --write-out '%{http_code}' "$url" >"$work/status" 2>"$work/progress"; then
result=0
else result=$?; fi
wait "$parser_pid" || :
parser_pid=
else
if curl --proto '=https' --proto-redir '=https' --fail --location --silent --show-error \
--connect-timeout 10 --max-time "$max_time" --output "$destination" --write-out '%{http_code}' "$url" >"$work/status" 2>"$work/errors"; then
result=0
else result=$?; fi
fi
http_status=$(cat "$work/status")
[ "$result" = 0 ] || cat "$work/errors" >&2
return "$result"
}

if curl -L "$BINARY_URL" -o "$INSTALL_DIR/$BINARY_NAME"; then
chmod +x "$INSTALL_DIR/$BINARY_NAME"
echo "✓ crabcode installed successfully to $INSTALL_DIR/$BINARY_NAME"
archive=crabcode-$target.tar.gz
base=$repo/releases/download/v$version
if ! download "$base/$archive" "$work/archive"; then
# Older releases used xz. Only missing assets justify switching formats.
[ "$http_status" = 404 ] || die 'Archive download failed; existing installation was not changed'
archive=crabcode-$target.tar.xz
download "$base/$archive" "$work/archive" || die 'Legacy archive download failed'
fi
download "$base/$archive.sha256" "$work/checksum" checksum || die 'Required checksum download failed'
expected=$(awk 'NR==1 { print $1 }' "$work/checksum")
[ "${#expected}" = 64 ] || die 'Invalid SHA256 checksum'
case "$expected" in *[!0-9a-fA-F]*) die 'Invalid SHA256 checksum' ;; esac
if [ "$hash_tool" = sha256sum ]; then
actual=$(sha256sum "$work/archive" | awk '{print $1}')
else
echo "❌ Failed to download binary. Please install via cargo: cargo install crabcode"
exit 1
actual=$(shasum -a 256 "$work/archive" | awk '{print $1}')
fi
expected=$(printf '%s' "$expected" | tr 'A-F' 'a-f')
[ "$actual" = "$expected" ] || die 'SHA256 checksum mismatch; existing installation was not changed'

# Add to PATH if not already there
if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
echo ""
echo "⚠️ Add $INSTALL_DIR to your PATH:"
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""
echo " Add this to your ~/.bashrc or ~/.zshrc"
fi
# Never unpack untrusted paths into HOME. Validate the inventory, then stream
# exactly one regular binary into staging (symlinks and hardlinks are refused).
tar -tf "$work/archive" >"$work/members" || die 'Invalid release archive'
member=$(awk '
/^\// || /^-/ || /(^|\/)\.\.(\/|$)/ { bad=1 }
/(^|\/)crabcode$/ { candidate=$0; count++ }
END { if(bad || count!=1) exit 1; print candidate }
' "$work/members") || die 'Unsafe archive or missing/duplicate crabcode binary'
tar -tvf "$work/archive" "$member" >"$work/type" || die 'Could not inspect binary'
case "$(head -c 1 "$work/type")" in -) ;; *) die 'Archive binary is not a regular file' ;; esac
tar -xOf "$work/archive" "$member" >"$work/crabcode" || die 'Could not extract binary'
[ -s "$work/crabcode" ] || die 'Archive binary is empty'
chmod 755 "$work/crabcode"

echo ""
echo "Run: $BINARY_NAME"
# JSON-escape arbitrary prefix bytes, including quotes, backslashes and control
# characters, without relying on Python, Node, or unsafe shell interpolation.
json_string() {
printf '%s' "$1" | od -An -v -tu1 | LC_ALL=C awk '
BEGIN { printf "\"" }
{ for(i=1;i<=NF;i++) {
n=$i+0
if(n==34 || n==92) printf "\\%c", n
else if(n<32) printf "\\u%04x", n
else printf "%c", n
} }
END { printf "\"" }
'
}
mkdir -p "$install_dir"
install_dir=$(cd "$install_dir" && pwd -P)
receipt_dir=${XDG_CONFIG_HOME:-$HOME/.config}/crabcode
mkdir -p "$receipt_dir"
{
printf '{"binaries":["crabcode"],"binary_aliases":{},"install_prefix":'
json_string "$install_dir"
printf ',"install_layout":"flat","version":'
json_string "$version"
printf '}\n'
} >"$work/receipt"
# Copy beside the destination and rename atomically, even across filesystems.
pending=$(mktemp "$install_dir/.crabcode.XXXXXXXX")
cp "$work/crabcode" "$pending"
chmod 755 "$pending"
mv -f "$pending" "$install_dir/crabcode"
pending=
pending=$(mktemp "$receipt_dir/.receipt.XXXXXXXX")
cp "$work/receipt" "$pending"
mv -f "$pending" "$receipt_dir/crabcode-receipt.json"
pending=
say "Installed crabcode $version to $install_dir/crabcode"
case ":$PATH:" in *":$install_dir:"*) ;; *) say "Add $install_dir to your PATH to run crabcode." ;; esac
9 changes: 7 additions & 2 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@ default:
check-aisdk-boundary:
bash scripts/check-aisdk-boundary.sh

[doc('Check registry publishing workflow and npm archive compatibility')]
[doc('Check release publishing and offline shell/npm installer behavior')]
test-release:
bun test scripts/release-workflow.test.ts npm/install.test.js
bun test --timeout 30000 scripts/release-workflow.test.ts scripts/install-ui.test.ts npm/install.test.js

[doc('Check the compiled upgrade CLI, install routing, and terminal rendering offline')]
test-upgrade-ui:
cargo build --bin crabcode
bun test --timeout 30000 scripts/upgrade-ui.test.ts

dev:
cargo r
Expand Down
Loading
Loading