#!/bin/bash # Raq.com CLI installer. # Usage: # curl -fsSL https://raq.com/cli/install | bash # # Verifies the signed release policy with the trusted root key before downloading # any files, mirroring the local-agent install pattern. set -eo pipefail umask 077 INSTALL_DIR="${RAQ_CLI_INSTALL_DIR:-$HOME/.raq-cli}" SERVER_URL="${RAQ_CLI_SERVER:-https://raq.com}" SYMLINK_TARGETS=("/usr/local/bin/raq" "$HOME/.local/bin/raq") RELEASE_POLICY_PATH="/cli/releases/policy.json" TRUSTED_ROOT_KEYS_JSON='[{"id":"root-2026-04","algorithm":"Ed25519","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA86z8lWuoWqUkuX7dhOegh0G1fsjgFWOc6OW/u/rXw50=\n-----END PUBLIC KEY-----\n"}]' while [[ $# -gt 0 ]]; do case "$1" in --server) SERVER_URL="${2:-}" shift 2 ;; --install-dir) INSTALL_DIR="${2:-}" shift 2 ;; *) shift ;; esac done if ! command -v node &>/dev/null; then echo "Error: Node.js 22+ is required (https://nodejs.org)." exit 1 fi NODE_MAJOR=$(node -v | sed 's/v//' | cut -d. -f1) if [[ "$NODE_MAJOR" -lt 22 ]]; then echo "Error: Node.js 22+ is required (found v$NODE_MAJOR)." exit 1 fi echo "Installing the raq CLI..." echo " Install dir: $INSTALL_DIR" echo " Server: $SERVER_URL" echo " Node: $(node -v)" mkdir -p "$INSTALL_DIR" chmod 700 "$INSTALL_DIR" install_verified_release() { SERVER_URL="$SERVER_URL" \ INSTALL_DIR="$INSTALL_DIR" \ RELEASE_POLICY_PATH="$RELEASE_POLICY_PATH" \ TRUSTED_ROOT_KEYS_JSON="$TRUSTED_ROOT_KEYS_JSON" \ node <<'NODE' const fs = require("fs") const path = require("path") const { createHash, verify } = require("crypto") const serverUrl = String(process.env.SERVER_URL || "").replace(/\/$/, "") const installDir = String(process.env.INSTALL_DIR || "") const policyPath = String(process.env.RELEASE_POLICY_PATH || "") const trustedRootKeys = JSON.parse(process.env.TRUSTED_ROOT_KEYS_JSON || "[]") function verifyDetachedSignature(dataText, signatureText, publicKeyPem) { const signature = Buffer.from(String(signatureText).trim(), "base64") return verify(null, Buffer.from(dataText, "utf8"), publicKeyPem, signature) } async function fetchText(url) { const response = await fetch(url, { signal: AbortSignal.timeout(15000) }) if (!response.ok) throw new Error(`Failed to download ${url}: ${response.status}`) return await response.text() } async function fetchBuffer(url) { const response = await fetch(url, { signal: AbortSignal.timeout(60000) }) if (!response.ok) throw new Error(`Failed to download ${url}: ${response.status}`) const buf = Buffer.from(await response.arrayBuffer()) return buf } ;(async () => { const policyUrl = `${serverUrl}${policyPath}` const policySigUrl = policyUrl.replace(/\.json$/, ".sig") const [policyText, policySignature] = await Promise.all([fetchText(policyUrl), fetchText(policySigUrl)]) const verifiedBy = trustedRootKeys.find((key) => verifyDetachedSignature(policyText, policySignature, key.publicKeyPem) ) if (!verifiedBy) throw new Error("Release policy signature verification failed.") const policy = JSON.parse(policyText) const latestVersion = policy.latest_version if (!latestVersion) throw new Error("Release policy is missing latest_version.") const release = policy.releases?.[latestVersion] if (!release) throw new Error(`Release metadata for ${latestVersion} is missing.`) const manifestPath = release.manifest_path || `/cli/releases/v${latestVersion}/manifest.json` const manifestUrl = `${serverUrl}${manifestPath}` const manifestSigUrl = manifestUrl.replace(/\.json$/, ".sig") const [manifestText, manifestSignature] = await Promise.all([ fetchText(manifestUrl), fetchText(manifestSigUrl), ]) const signingKeyId = release.signing_key_id const releasePublicKeyPem = signingKeyId && policy.signing_keys?.[signingKeyId]?.public_key_pem ? policy.signing_keys[signingKeyId].public_key_pem : release.signing_key?.public_key_pem if (!releasePublicKeyPem) throw new Error("Release manifest is missing release signing key.") if (!verifyDetachedSignature(manifestText, manifestSignature, releasePublicKeyPem)) { throw new Error("Manifest signature verification failed.") } const manifest = JSON.parse(manifestText) const files = manifest.files if (!Array.isArray(files) || files.length === 0) throw new Error("Manifest has no files.") const releaseBasePath = release.base_path || `/cli/releases/v${latestVersion}` for (const fileMeta of files) { const relativePath = fileMeta?.path if (!relativePath || typeof relativePath !== "string") { throw new Error("Manifest contains a file entry without a path.") } const fileUrl = `${serverUrl}${releaseBasePath}/${relativePath}` const buf = await fetchBuffer(fileUrl) const sha = createHash("sha256").update(buf).digest("hex") if (sha !== fileMeta.sha256) { throw new Error(`SHA mismatch for ${relativePath}: expected ${fileMeta.sha256}, got ${sha}`) } const dest = path.join(installDir, relativePath) fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 }) fs.writeFileSync(dest, buf, { mode: fileMeta.executable ? 0o755 : 0o600 }) } process.stdout.write(`Installed raq CLI ${latestVersion}\n`) })().catch((err) => { process.stderr.write(`Installer failed: ${err.message}\n`) process.exit(1) }) NODE } install_verified_release chmod +x "$INSTALL_DIR/raq" 2>/dev/null || true # Try to add `raq` to PATH by symlinking. LINKED="" for target in "${SYMLINK_TARGETS[@]}"; do target_dir="$(dirname "$target")" if [[ -w "$target_dir" ]] || [[ "$target_dir" == "$HOME/.local/bin" ]]; then mkdir -p "$target_dir" ln -sf "$INSTALL_DIR/raq" "$target" LINKED="$target" break fi done if [[ -n "$LINKED" ]]; then echo "" echo " Linked $LINKED -> $INSTALL_DIR/raq" fi echo "" echo " Run: raq login" echo ""