#!/bin/sh # # Installs the Roark CLI. # # curl -fsSL https://roark.ai/install.sh | sh # # The CLI is published to npm, but this script deliberately does not run # `npm install -g`. A global install writes into whatever prefix npm happens to # be configured with - often a root-owned one, which is why the usual advice # ends in `sudo` - and it puts the CLI at the mercy of the next `npm cache # clean` or Node upgrade. Instead this fetches the published tarball, checks it # against the integrity hash the registry serves, and unpacks it under # ~/.roark/versions/, with a `current` symlink and a small shim on # PATH. Nothing outside $ROARK_HOME and $ROARK_BIN_DIR is written, and no step # needs elevation. # # Node is still required (>= 20): the CLI is JavaScript, and npm - which ships # with Node - is what resolves the dependency tree into the version directory. # That resolution is local to the install directory, never global. # # Environment: # ROARK_VERSION version to install, or `latest` (default: latest) # ROARK_HOME where versions live (default: ~/.roark) # ROARK_BIN_DIR where the `roark` symlink goes (default: ~/.local/bin) # # Flags: --version , --uninstall, --help # The entire script is one function, invoked on the last line. # # `sh` executes a piped script as it reads it, so under `curl ... | sh` a # download that dies halfway through runs whatever arrived: half an installer, # silently, exiting 0. Wrapping the body in a function means a truncated copy # defines it and never reaches the call, so nothing happens at all - which is # the correct outcome for a partial download. This is why rustup, Homebrew, # Deno and nvm all do the same thing. # # Keep the closing `}` and the invocation as the last two meaningful lines. __roark_install() { set -eu PACKAGE='@roarkanalytics/cli' REGISTRY="${ROARK_REGISTRY:-https://registry.npmjs.org}" VERSION="${ROARK_VERSION:-latest}" ROARK_HOME="${ROARK_HOME:-$HOME/.roark}" BIN_DIR="${ROARK_BIN_DIR:-$HOME/.local/bin}" MIN_NODE_MAJOR=20 UNINSTALL=0 # ---------------------------------------------------------------- output ---- # Colour only when stderr is a terminal. Progress goes to stderr throughout so # that `curl ... | sh > log` still shows the user what is happening, and so the # script stays silent on stdout for anyone parsing it. if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then BOLD=$(printf '\033[1m') DIM=$(printf '\033[2m') RED=$(printf '\033[31m') GREEN=$(printf '\033[32m') RESET=$(printf '\033[0m') else BOLD='' DIM='' RED='' GREEN='' RESET='' fi say() { printf '%s\n' "$*" >&2; } step() { printf '%s==>%s %s\n' "$BOLD" "$RESET" "$*" >&2; } detail() { printf ' %s%s%s\n' "$DIM" "$*" "$RESET" >&2; } die() { printf '%serror:%s %s\n' "$RED" "$RESET" "$1" >&2 shift for line in "$@"; do printf ' %s\n' "$line" >&2; done exit 1 } usage() { cat >&2 < install a specific version (default: latest) --uninstall remove the CLI and everything this script installed --help show this message Environment: ROARK_VERSION same as --version ROARK_HOME install root (default: ~/.roark) ROARK_BIN_DIR directory for the roark symlink (default: ~/.local/bin) EOF } # ------------------------------------------------------------------ args ---- while [ $# -gt 0 ]; do case "$1" in --version) [ $# -ge 2 ] || die '--version needs a value, e.g. --version 0.1.1' VERSION="$2" shift 2 ;; --version=*) VERSION="${1#--version=}" shift ;; --uninstall) UNINSTALL=1 shift ;; --help | -h) usage exit 0 ;; *) die "unknown option: $1" 'Run with --help to see the supported flags.' ;; esac done # ------------------------------------------------------------- uninstall ---- if [ "$UNINSTALL" -eq 1 ]; then step "Removing the Roark CLI" # Only remove the symlink if it is ours. Someone with a `roark` from Homebrew # or npm on the same PATH should keep it. if [ -L "$BIN_DIR/roark" ]; then link=$(readlink "$BIN_DIR/roark") case "$link" in "$ROARK_HOME"/*) rm -f "$BIN_DIR/roark" && detail "removed $BIN_DIR/roark" ;; *) detail "left $BIN_DIR/roark alone: it points at $link, not at $ROARK_HOME" ;; esac fi if [ -d "$ROARK_HOME" ]; then rm -rf "$ROARK_HOME" detail "removed $ROARK_HOME" fi say '' say "Uninstalled. Your credential file, if you stored one elsewhere, was not touched." exit 0 fi # ------------------------------------------------------ required tooling ---- have() { command -v "$1" >/dev/null 2>&1; } if have curl; then DOWNLOAD='curl' elif have wget; then DOWNLOAD='wget' else die 'neither curl nor wget is installed' 'One of them is needed to reach the npm registry.' fi have tar || die 'tar is not installed' 'The published package is a tarball; tar unpacks it.' have node || die \ 'Node.js is not installed' \ "The Roark CLI is a Node program and needs Node $MIN_NODE_MAJOR or newer." \ 'Install it from https://nodejs.org, or with your package manager, then re-run this script.' node_version=$(node --version 2>/dev/null | sed 's/^v//') node_major=${node_version%%.*} case "$node_major" in '' | *[!0-9]*) die "could not read the Node version (got '$node_version')" ;; esac [ "$node_major" -ge "$MIN_NODE_MAJOR" ] || die \ "Node $node_version is too old" \ "The CLI needs Node $MIN_NODE_MAJOR or newer. Upgrade Node and re-run this script." have npm || die \ 'npm is not installed' \ 'npm normally ships with Node; it is used here to resolve the CLI dependencies' \ "into $ROARK_HOME. Nothing is installed globally." # --------------------------------------------------------------- fetch ----- # One helper for both downloaders so the rest of the script never branches. # `-` writes to stdout, matching curl's default. download() { url="$1" dest="${2:--}" if [ "$DOWNLOAD" = 'curl' ]; then if [ "$dest" = '-' ]; then curl -fsSL "$url"; else curl -fsSL "$url" -o "$dest"; fi else if [ "$dest" = '-' ]; then wget -qO- "$url"; else wget -qO "$dest" "$url"; fi fi } step "Resolving $PACKAGE@$VERSION" # The registry serves a per-version document at //, and `latest` # is a valid path segment there, so one request covers both cases. Node parses # the JSON: it is already a hard requirement, which makes jq one fewer thing to # depend on. metadata_url="$REGISTRY/$(printf '%s' "$PACKAGE" | sed 's|/|%2f|')/$VERSION" # stderr is dropped here, and only here: a 404 on this request is an expected # outcome (a version that does not exist), and the message below says more about # it than curl's does. metadata=$(download "$metadata_url" 2>/dev/null || true) [ -n "$metadata" ] || die \ "could not find $PACKAGE@$VERSION on the registry" \ "Tried $metadata_url" \ 'Check the version number, and that you can reach the network.' # Three fields on three lines, so the shell can read them without a JSON parser # of its own. A missing field is a hard error rather than an empty string that # would fail confusingly later. resolved=$(printf '%s' "$metadata" | node -e ' let raw = ""; process.stdin.on("data", (c) => (raw += c)); process.stdin.on("end", () => { let doc; try { doc = JSON.parse(raw); } catch { console.error("the registry did not return JSON"); process.exit(1); } const version = doc.version; const tarball = doc.dist && doc.dist.tarball; const integrity = (doc.dist && (doc.dist.integrity || doc.dist.shasum)) || ""; if (!version || !tarball) { console.error("the registry document has no version or tarball"); process.exit(1); } process.stdout.write([version, tarball, integrity].join("\n")); }); ') || die "could not read the registry response for $PACKAGE@$VERSION" RESOLVED_VERSION=$(printf '%s' "$resolved" | sed -n '1p') TARBALL_URL=$(printf '%s' "$resolved" | sed -n '2p') INTEGRITY=$(printf '%s' "$resolved" | sed -n '3p') detail "resolved to $RESOLVED_VERSION" TARGET="$ROARK_HOME/versions/$RESOLVED_VERSION" TMP=$(mktemp -d "${TMPDIR:-/tmp}/roark-install.XXXXXX") || die 'could not create a temporary directory' cleanup() { rm -rf "$TMP"; } trap cleanup EXIT INT TERM HUP step "Downloading $RESOLVED_VERSION" download "$TARBALL_URL" "$TMP/package.tgz" || die "could not download $TARBALL_URL" # --------------------------------------------------------------- verify ---- # npm publishes `integrity` as `-` (sha512 in practice) and # an older `shasum` (sha1). Verify whichever came back. This catches a corrupted # download and a tampered mirror; it is not a substitute for the registry's own # signatures, which npm checks when it installs the dependencies below. if [ -n "$INTEGRITY" ]; then step 'Verifying the download' # shellcheck disable=SC2016 # the single quotes are the point: this is JavaScript, not shell INTEGRITY="$INTEGRITY" TARBALL="$TMP/package.tgz" node -e ' const crypto = require("node:crypto"); const fs = require("node:fs"); const integrity = process.env.INTEGRITY; const file = fs.readFileSync(process.env.TARBALL); const dashed = integrity.indexOf("-"); // A bare hex digest is the legacy `shasum` field, which is always sha1. const [algorithm, expected] = dashed === -1 ? ["sha1", integrity] : [integrity.slice(0, dashed), integrity.slice(dashed + 1)]; const encoding = dashed === -1 ? "hex" : "base64"; let actual; try { actual = crypto.createHash(algorithm).update(file).digest(encoding); } catch { console.error(`unsupported integrity algorithm: ${algorithm}`); process.exit(1); } if (actual !== expected) { console.error(`${algorithm} mismatch\n expected ${expected}\n actual ${actual}`); process.exit(1); } ' || die \ 'the downloaded package does not match the checksum the registry published' \ 'Nothing was installed. This is usually a truncated download - try again.' \ 'If it keeps happening, do not use the file; report it to support@roark.ai.' detail "${INTEGRITY%%-*} matches" fi # -------------------------------------------------------------- install ---- step 'Unpacking' mkdir -p "$TMP/stage" # npm tarballs put everything under `package/`; strip that so the version # directory holds the package itself rather than a directory named after it. tar -xzf "$TMP/package.tgz" -C "$TMP/stage" --strip-components=1 || die 'could not unpack the downloaded package' [ -f "$TMP/stage/bin.js" ] || die 'the downloaded package has no bin.js' 'It is not a Roark CLI tarball.' step 'Installing dependencies' # The package ships an `npm-shrinkwrap.json` pinning its whole transitive tree, # so install exactly that: `npm ci` installs the lockfile verbatim and refuses # if the manifest and the lockfile disagree, which is the difference between # probably-pinned and provably-pinned. Every entry in that file carries its own # integrity hash, so the checksum check above extends past the entry package to # everything underneath it. # # Versions published before the shrinkwrap existed do not have one, and `npm ci` # requires a lockfile, so those fall back to resolving ranges. Say which one is # happening rather than leaving the reader to infer it from a version number. if [ -f "$TMP/stage/npm-shrinkwrap.json" ]; then NPM_SUBCOMMAND='ci' detail 'npm ci, from the lockfile this version pins' else NPM_SUBCOMMAND='install' detail 'npm install: this version ships no lockfile, so ranges resolve fresh' fi # --omit=dev: the tarball declares none, but a future one might. # --ignore-scripts: nothing in this tree needs to run code at install time, and # a curl-to-shell installer is the wrong place to start. # # stdin is redirected for this command alone, and for the same reason it is not # redirected for the script as a whole: under `curl | sh` the shell is reading # this script *from* stdin, so closing it globally truncates the script. Closing # it per-command still keeps npm from ever reading script text as a prompt # answer. ( cd "$TMP/stage" && npm_config_update_notifier=false npm "$NPM_SUBCOMMAND" \ --omit=dev --ignore-scripts --no-audit --no-fund --no-progress --loglevel=error \ "$ROARK_HOME/bin/roark" </dev/null /dev/null); then [ "$resolved_bin" = "$BIN_DIR/roark" ] && on_path=1 fi if [ "$on_path" -eq 1 ]; then say "Next:" say " roark auth login ${DIM}# store your API key${RESET}" say " roark call list --limit 5" say '' say "For \`man roark\`, add the man pages to MANPATH:" say " export MANPATH=\"$ROARK_HOME/share/man:\$MANPATH\"" else say "${BOLD}$BIN_DIR is not on your PATH.${RESET} Add it:" say '' # These paths are printed for the reader to copy into their own shell, where # the tilde does expand; expanding it here would only make the line longer. # shellcheck disable=SC2088 case "${SHELL##*/}" in zsh) rc='~/.zshrc' ;; bash) rc='~/.bashrc' ;; fish) rc='~/.config/fish/config.fish' ;; *) rc='your shell profile' ;; esac if [ "${SHELL##*/}" = 'fish' ]; then say " fish_add_path $BIN_DIR ${DIM}# then restart your shell${RESET}" else say " echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> $rc" say " source $rc" fi say '' say "Or run it by path: $BIN_DIR/roark --help" fi say '' say "Docs: https://docs.roark.ai/documentation/sdks/cli" } __roark_install "$@"