diff --git a/docs/update.md b/docs/update.md new file mode 100644 index 0000000..2f8d634 --- /dev/null +++ b/docs/update.md @@ -0,0 +1,49 @@ +# Update + +Self-update the CLI to the latest version. + +## Usage + +```bash +# Update to the latest version +jup update + +# Check for updates without installing +jup update --check +``` + +## How it works + +1. Checks the latest release on GitHub +2. Compares with the installed version +3. If a newer version is available, fetches and runs `install.sh` which handles the update (Volta → npm → standalone binary fallback) +4. Outputs the result + +## JSON output + +```js +// Up to date +{ + "currentVersion": "0.4.0", + "latestVersion": "0.4.0", + "status": "up_to_date" +} + +// Update available (--check) +{ + "currentVersion": "0.3.0", + "latestVersion": "0.4.0", + "status": "update_available" +} + +// Updated +{ + "currentVersion": "0.3.0", + "latestVersion": "0.4.0", + "status": "updated" +} +``` + +## Notes + +- Supported platforms: `linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64`. diff --git a/scripts/install.sh b/scripts/install.sh index da4b7ea..5f38d19 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4,7 +4,6 @@ set -euo pipefail PACKAGE="@jup-ag/cli" BINARY="jup" REPO="jup-ag/cli" - info() { printf '\033[1;34m%s\033[0m\n' "$*"; } error() { printf '\033[1;31merror: %s\033[0m\n' "$*" >&2; exit 1; } @@ -46,26 +45,32 @@ URL="https://github.com/${REPO}/releases/latest/download/${ASSET}" CHECKSUM_URL="https://github.com/${REPO}/releases/latest/download/checksums.txt" +TMP_DIR=$(mktemp -d) +TMP_BINARY="${TMP_DIR}/${BINARY}" +TMP_CHECKSUMS="${TMP_DIR}/checksums.txt" +trap 'rm -rf "$TMP_DIR"' EXIT + info "Downloading $URL..." -curl -fsSL "$URL" -o "/tmp/${BINARY}" -curl -fsSL "$CHECKSUM_URL" -o "/tmp/checksums.txt" +curl -fsSL "$URL" -o "$TMP_BINARY" +curl -fsSL "$CHECKSUM_URL" -o "$TMP_CHECKSUMS" info "Verifying checksum..." -EXPECTED=$(grep "$ASSET" /tmp/checksums.txt | awk '{print $1}') -ACTUAL=$(sha256sum "/tmp/${BINARY}" | awk '{print $1}') +EXPECTED=$(grep "$ASSET" "$TMP_CHECKSUMS" | awk '{print $1}') || true +if [ -z "$EXPECTED" ]; then + error "No checksum found for $ASSET in checksums.txt" +fi +ACTUAL=$(sha256sum "$TMP_BINARY" | awk '{print $1}') if [ "$EXPECTED" != "$ACTUAL" ]; then - rm -f "/tmp/${BINARY}" "/tmp/checksums.txt" error "Checksum verification failed. Expected $EXPECTED, got $ACTUAL" fi -rm -f /tmp/checksums.txt -chmod +x "/tmp/${BINARY}" +chmod +x "$TMP_BINARY" if [ -w "$INSTALL_DIR" ]; then - mv "/tmp/${BINARY}" "${INSTALL_DIR}/${BINARY}" + mv "$TMP_BINARY" "${INSTALL_DIR}/${BINARY}" else info "Elevated permissions required to install to $INSTALL_DIR" - sudo mv "/tmp/${BINARY}" "${INSTALL_DIR}/${BINARY}" + sudo mv "$TMP_BINARY" "${INSTALL_DIR}/${BINARY}" fi info "Installed $BINARY to ${INSTALL_DIR}/${BINARY}" diff --git a/src/commands/UpdateCommand.ts b/src/commands/UpdateCommand.ts new file mode 100644 index 0000000..e6d2e24 --- /dev/null +++ b/src/commands/UpdateCommand.ts @@ -0,0 +1,126 @@ +import { execFileSync } from "child_process"; +import { mkdtemp, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; + +import chalk from "chalk"; +import ky from "ky"; +import type { Command } from "commander"; + +import { version as currentVersion } from "../../package.json"; +import { Output } from "../lib/Output.ts"; + +export class UpdateCommand { + public static register(program: Command): void { + program + .command("update") + .description("Update the CLI to the latest version") + .option("--check", "Check for updates without installing") + .action((opts: { check?: boolean }) => this.update(opts)); + } + + private static async update(opts: { check?: boolean }): Promise { + const latestVersion = await this.getLatestVersion(); + const isUpToDate = !this.isNewer(latestVersion, currentVersion); + + if (isUpToDate) { + return this.output({ + json: { currentVersion, latestVersion, status: "up_to_date" }, + rows: [ + { label: "Version", value: `v${currentVersion}` }, + { label: "Status", value: chalk.green("Already up to date") }, + ], + }); + } + + if (opts.check) { + return this.output({ + json: { currentVersion, latestVersion, status: "update_available" }, + rows: [ + { label: "Current Version", value: `v${currentVersion}` }, + { + label: "Latest Version", + value: chalk.green(`v${latestVersion}`), + }, + { label: "Status", value: "Update available" }, + ], + }); + } + + if (!Output.isJson()) { + console.log(`Updating to v${latestVersion}...`); + } + + await this.runInstallScript(); + + this.output({ + json: { currentVersion, latestVersion, status: "updated" }, + rows: [ + { label: "Previous Version", value: `v${currentVersion}` }, + { + label: "New Version", + value: chalk.green(`v${latestVersion}`), + }, + { label: "Status", value: chalk.green("Updated successfully") }, + ], + }); + } + + private static output(opts: { + json: Record; + rows: { label: string; value: string }[]; + }): void { + if (Output.isJson()) { + Output.json(opts.json); + } else { + Output.table({ type: "vertical", rows: opts.rows }); + } + } + + private static async getLatestVersion(): Promise { + try { + const release = await ky + .get("https://api.github.com/repos/jup-ag/cli/releases/latest") + .json<{ tag_name: string }>(); + + return release.tag_name.replace(/^v/, ""); + } catch { + throw new Error( + "Failed to check for updates. Please check your internet connection and try again." + ); + } + } + + private static isNewer(latest: string, current: string): boolean { + const latestParts = latest.split(".").map(Number); + const currentParts = current.split(".").map(Number); + for (let i = 0; i < 3; i++) { + if ((latestParts[i] ?? 0) > (currentParts[i] ?? 0)) { + return true; + } + if ((latestParts[i] ?? 0) < (currentParts[i] ?? 0)) { + return false; + } + } + return false; + } + + private static async runInstallScript(): Promise { + const scriptUrl = + "https://raw.githubusercontent.com/jup-ag/cli/main/scripts/install.sh"; + const dir = await mkdtemp(join(tmpdir(), "jup-")); + const scriptPath = join(dir, "install.sh"); + + try { + const script = await ky.get(scriptUrl).text(); + await writeFile(scriptPath, script, { mode: 0o700 }); + execFileSync("bash", [scriptPath], { stdio: "inherit" }); + } catch { + throw new Error( + "Update failed. Run `jup update` again or install manually from https://github.com/jup-ag/cli/releases" + ); + } finally { + rm(dir, { recursive: true, force: true }).catch(() => {}); + } + } +} diff --git a/src/index.ts b/src/index.ts index 8a5a804..7f79d63 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { KeysCommand } from "./commands/KeysCommand.ts"; import { LendCommand } from "./commands/LendCommand.ts"; import { PerpsCommand } from "./commands/PerpsCommand.ts"; import { SpotCommand } from "./commands/SpotCommand.ts"; +import { UpdateCommand } from "./commands/UpdateCommand.ts"; import { version } from "../package.json"; import { Config } from "./lib/Config.ts"; @@ -34,6 +35,7 @@ KeysCommand.register(program); LendCommand.register(program); PerpsCommand.register(program); SpotCommand.register(program); +UpdateCommand.register(program); program.parseAsync().catch(async (err: unknown) => { await Output.error(err);