#!/bin/sh # qinetic-cli one-line installer (ENG-1299). # # curl -fsSL https://get.levr.now | sh # production # curl -fsSL https://get.levr.now | sh # staging # # Installs a per-platform tarball into ~/.qinetic/versions//, points # ~/.qinetic/current at it, and writes a launcher to ~/.local/bin/qinetic. # # Requirements: Node >= 18 (no bundled runtime in v1) on a glibc Linux or macOS # host. Alpine/musl is NOT supported in v1 (the native modules ship glibc-only # prebuilds). curl OR wget, and tar. # # Environment overrides: # QINETIC_VERSION pin a specific version instead of `latest` # QINETIC_BASE_URL override the download origin (used by CI smoke tests and # the staging copy; defaults to the production host below) set -eu # The staging deploy step rewrites this default to https://get.levr.now before # uploading staging's copy. QINETIC_BASE_URL always takes precedence. DEFAULT_BASE_URL="https://get.levr.now" BASE_URL="${QINETIC_BASE_URL:-$DEFAULT_BASE_URL}" # Strip any trailing slash so URL joins below are clean. BASE_URL="${BASE_URL%/}" QINETIC_HOME="${HOME}/.qinetic" BIN_DIR="${HOME}/.local/bin" err() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; } info() { printf '%s\n' "$*"; } abort() { err "$1" exit 1 } # ── 1. Detect OS / arch ───────────────────────────────────────────────────── case "$(uname -s)" in Darwin) PLATFORM=darwin ;; Linux) PLATFORM=linux ;; *) abort "unsupported OS '$(uname -s)'. qinetic supports macOS and Linux only." ;; esac case "$(uname -m)" in arm64 | aarch64) ARCH=arm64 ;; x86_64 | amd64) ARCH=x64 ;; *) abort "unsupported architecture '$(uname -m)'. qinetic supports arm64 and x64." ;; esac # musl/Alpine is unsupported in v1 — the native prebuilds are glibc-only. if [ "$PLATFORM" = linux ] && command -v ldd >/dev/null 2>&1; then if ldd --version 2>&1 | grep -qi musl; then abort "musl libc (Alpine) is not supported in v1. qinetic requires a glibc Linux (Ubuntu 18+, RHEL 7+, Debian 9+)." fi fi # ── 2. Require Node >= 18 ─────────────────────────────────────────────────── if ! command -v node >/dev/null 2>&1; then err "Node.js >= 18 is required but 'node' was not found on your PATH." err "Install Node 18+ from https://nodejs.org/en/download and re-run this installer." exit 1 fi NODE_MAJOR=$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0) if [ "$NODE_MAJOR" -lt 18 ] 2>/dev/null; then err "Node.js >= 18 is required (found $(node -v))." err "Upgrade Node from https://nodejs.org/en/download and re-run this installer." exit 1 fi # ── Download helpers (curl or wget) ───────────────────────────────────────── if command -v curl >/dev/null 2>&1; then fetch_stdout() { curl -fsSL "$1"; } fetch_file() { curl -fsSL "$1" -o "$2"; } elif command -v wget >/dev/null 2>&1; then fetch_stdout() { wget -qO- "$1"; } fetch_file() { wget -qO "$2" "$1"; } else abort "neither curl nor wget is available; cannot download qinetic." fi sha256_of() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1 else shasum -a 256 "$1" | cut -d' ' -f1 fi } TMPDIR_INSTALL=$(mktemp -d 2>/dev/null || mktemp -d -t qinetic) cleanup() { rm -rf "$TMPDIR_INSTALL"; } trap cleanup EXIT INT TERM # ── 3. Resolve version + artifact URL + expected sha256 ───────────────────── if [ -n "${QINETIC_VERSION:-}" ]; then # Pinned version: construct the URL directly; read the sha from SHASUMS256.txt. VERSION="$QINETIC_VERSION" FILENAME="qinetic-${VERSION}-${PLATFORM}-${ARCH}.tar.gz" URL="${BASE_URL}/${VERSION}/${FILENAME}" info "Installing pinned qinetic ${VERSION} (${PLATFORM}-${ARCH})…" SHASUMS=$(fetch_stdout "${BASE_URL}/${VERSION}/SHASUMS256.txt") \ || abort "could not fetch SHASUMS256.txt for version ${VERSION} from ${BASE_URL}." EXPECTED_SHA=$(printf '%s\n' "$SHASUMS" | awk -v f="$FILENAME" '$2 == f {print $1; exit}') [ -n "$EXPECTED_SHA" ] || abort "no checksum for ${FILENAME} in SHASUMS256.txt (is ${PLATFORM}-${ARCH} published for ${VERSION}?)." else # Latest: read version + per-platform artifact from latest.json. Node is a # hard requirement (checked above), so parse with Node — no jq dependency. LATEST_FILE="${TMPDIR_INSTALL}/latest.json" fetch_file "${BASE_URL}/latest.json" "$LATEST_FILE" \ || abort "could not fetch ${BASE_URL}/latest.json (is the installer host reachable?)." RESOLVED=$(node -e ' const fs = require("fs"); const [file, platform, arch] = process.argv.slice(1); let j; try { j = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { console.error("latest.json is not valid JSON: " + e.message); process.exit(2); } const a = (j.artifacts || []).find((x) => x.platform === platform && x.arch === arch); if (!a) { console.error("no artifact for " + platform + "-" + arch + " in latest.json"); process.exit(3); } process.stdout.write([j.version || "", a.url || "", a.sha256 || ""].join("\n")); ' "$LATEST_FILE" "$PLATFORM" "$ARCH") \ || abort "no qinetic build available for ${PLATFORM}-${ARCH}." VERSION=$(printf '%s\n' "$RESOLVED" | sed -n '1p') URL=$(printf '%s\n' "$RESOLVED" | sed -n '2p') EXPECTED_SHA=$(printf '%s\n' "$RESOLVED" | sed -n '3p') [ -n "$VERSION" ] && [ -n "$URL" ] || abort "latest.json is missing version or artifact URL." FILENAME="qinetic-${VERSION}-${PLATFORM}-${ARCH}.tar.gz" info "Installing qinetic ${VERSION} (${PLATFORM}-${ARCH})…" fi # Defense-in-depth: VERSION flows into filesystem paths (rm -rf / mkdir / tar) # and the download URL. Reject anything that isn't a clean version token so a # compromised latest.json / SHASUMS / QINETIC_VERSION can't path-traverse # (e.g. `../../.bashrc`). Allows dev suffixes like `-local` / `-test`. # (ENG-1299 code review F-003.) if ! printf '%s' "$VERSION" | grep -qE '^[0-9A-Za-z][0-9A-Za-z._-]*$'; then abort "resolved version '${VERSION}' is not a valid version string; refusing to continue." fi # ── 4. Download + verify checksum ─────────────────────────────────────────── TARBALL="${TMPDIR_INSTALL}/${FILENAME}" fetch_file "$URL" "$TARBALL" || abort "download failed: ${URL}" if [ -n "$EXPECTED_SHA" ]; then # Guard against a truncated/garbled checksum line so the failure is a clear # "malformed checksum" rather than a confusing mismatch (F-009). if ! printf '%s' "$EXPECTED_SHA" | grep -qE '^[0-9a-fA-F]{64}$'; then abort "published checksum for ${FILENAME} is malformed (expected 64 hex chars): '${EXPECTED_SHA}'" fi ACTUAL_SHA=$(sha256_of "$TARBALL") if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then err "Checksum verification failed for ${FILENAME}." err " expected: ${EXPECTED_SHA}" err " actual: ${ACTUAL_SHA}" abort "refusing to install a tarball that does not match its published checksum." fi info "Checksum verified (sha256)." else err "no checksum available for ${FILENAME}; refusing to install unverified." exit 1 fi # ── 5. Extract to ~/.qinetic/versions// ──────────────────────────── DEST="${QINETIC_HOME}/versions/${VERSION}" rm -rf "$DEST" mkdir -p "$DEST" # Tarball top-level dir is `qinetic-/`; strip it so DEST holds dist/. tar -C "$DEST" --strip-components=1 -xzf "$TARBALL" \ || abort "failed to extract ${FILENAME}." [ -f "${DEST}/dist/cli.js" ] || abort "extracted tarball is missing dist/cli.js." # ── 6. Point ~/.qinetic/current at this version ───────────────────────────── ln -sfn "$DEST" "${QINETIC_HOME}/current" # ── 7. Write launcher (idempotent) ────────────────────────────────────────── mkdir -p "$BIN_DIR" cat > "${BIN_DIR}/qinetic" <<'LAUNCHER' #!/bin/sh # qinetic launcher (managed by the qinetic installer — edits will be overwritten). exec node "${HOME}/.qinetic/current/dist/cli.js" "$@" LAUNCHER chmod +x "${BIN_DIR}/qinetic" # ── 8. PATH guidance ──────────────────────────────────────────────────────── case ":${PATH}:" in *":${BIN_DIR}:"*) ON_PATH=1 ;; *) ON_PATH=0 ;; esac # ── 9. Smoke check ────────────────────────────────────────────────────────── SMOKE=$(node "${DEST}/dist/cli.js" --version 2>/dev/null || echo "") if [ -z "$SMOKE" ]; then abort "installation completed but 'qinetic --version' did not run. Check that Node 18+ works on this host." fi info "" info "qinetic ${SMOKE} installed → ${DEST}" info " launcher: ${BIN_DIR}/qinetic" if [ "$ON_PATH" -eq 0 ]; then info "" info "⚠ ${BIN_DIR} is not on your PATH. Add this to your shell profile:" info " export PATH=\"${BIN_DIR}:\$PATH\"" info " then restart your shell (or run the export now) and run: qinetic --help" else info " Run: qinetic --help" fi