From b623e52c7b0ca95380b4304e9e7c214737af6d5f Mon Sep 17 00:00:00 2001 From: lifeofpavs Date: Fri, 20 Mar 2026 11:25:44 +0100 Subject: [PATCH 01/17] (feat): adding update command --- docs/update.md | 63 ++++++++ src/commands/UpdateCommand.ts | 277 ++++++++++++++++++++++++++++++++++ src/index.ts | 2 + 3 files changed, 342 insertions(+) create mode 100644 docs/update.md create mode 100644 src/commands/UpdateCommand.ts diff --git a/docs/update.md b/docs/update.md new file mode 100644 index 0000000..550ab52 --- /dev/null +++ b/docs/update.md @@ -0,0 +1,63 @@ +# Update + +Self-update the CLI to the latest version. Detects whether the CLI was installed via a package manager or as a standalone binary and acts accordingly. + +## 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 (via redirect, no API token needed) +2. Compares with the installed version +3. If a newer version is available: + - **Package manager installs** — prints the appropriate update command (npm, pnpm, yarn, bun, or Volta) + - **Binary installs** — downloads the new binary, verifies its SHA-256 checksum, and atomically replaces the current binary +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" +} + +// Package manager install — prints command to run +{ + "currentVersion": "0.3.0", + "latestVersion": "0.4.0", + "status": "manual_update_required", + "method": "npm", + "command": "npm install -g @jup-ag/cli@0.4.0" +} + +// Binary updated +{ + "currentVersion": "0.3.0", + "latestVersion": "0.4.0", + "status": "updated", + "method": "binary" +} +``` + +## Notes + +- Binary updates require write permission to the binary path. If you get a permission error, run `sudo jup update`. +- Binary builds are available for: `linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64`. +- In dev mode (`bun run dev`), the update command will refuse to run the binary update path to avoid overwriting the Bun runtime. diff --git a/src/commands/UpdateCommand.ts b/src/commands/UpdateCommand.ts new file mode 100644 index 0000000..71dfc03 --- /dev/null +++ b/src/commands/UpdateCommand.ts @@ -0,0 +1,277 @@ +import { createHash } from "crypto"; +import { chmod, rename, rm, writeFile } from "fs/promises"; +import { basename } 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"; + +type InstallMethod = "npm" | "binary"; + +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(); + + if (!this.isNewer(latestVersion, currentVersion)) { + if (Output.isJson()) { + Output.json({ + currentVersion, + latestVersion, + status: "up_to_date", + }); + } else { + Output.table({ + type: "vertical", + rows: [ + { label: "Version", value: `v${currentVersion}` }, + { label: "Status", value: chalk.green("Already up to date") }, + ], + }); + } + return; + } + + if (opts.check) { + if (Output.isJson()) { + Output.json({ + currentVersion, + latestVersion, + status: "update_available", + }); + } else { + Output.table({ + type: "vertical", + rows: [ + { label: "Current Version", value: `v${currentVersion}` }, + { + label: "Latest Version", + value: chalk.green(`v${latestVersion}`), + }, + { label: "Status", value: "Update available" }, + ], + }); + } + return; + } + + const method = this.detectInstallMethod(); + + if (method === "npm") { + const hint = this.getPackageManagerHint(latestVersion); + if (Output.isJson()) { + Output.json({ + currentVersion, + latestVersion, + status: "manual_update_required", + method: "npm", + command: hint, + }); + } else { + Output.table({ + type: "vertical", + rows: [ + { label: "Current Version", value: `v${currentVersion}` }, + { + label: "Latest Version", + value: chalk.green(`v${latestVersion}`), + }, + { + label: "Update Command", + value: chalk.cyan(hint), + }, + ], + }); + } + return; + } + + if (!Output.isJson()) { + console.log(`Downloading v${latestVersion}...`); + } + + await this.updateBinary(latestVersion); + + if (Output.isJson()) { + Output.json({ + currentVersion, + latestVersion, + status: "updated", + method, + }); + } else { + Output.table({ + type: "vertical", + rows: [ + { label: "Previous Version", value: `v${currentVersion}` }, + { + label: "New Version", + value: chalk.green(`v${latestVersion}`), + }, + { label: "Method", value: method }, + { label: "Status", value: chalk.green("Updated successfully") }, + ], + }); + } + } + + private static async getLatestVersion(): Promise { + // Use native fetch with manual redirect to avoid GitHub API rate limits. + // The /releases/latest endpoint returns a 302 to /releases/tag/vX.Y.Z. + const response = await fetch( + "https://github.com/jup-ag/cli/releases/latest", + { + redirect: "manual", + headers: { "User-Agent": "@jup-ag/cli" }, + } + ); + + const location = response.headers.get("location"); + if (!location) { + throw new Error("Failed to resolve latest version from GitHub"); + } + + const tag = location.split("/tag/").pop(); + if (!tag) { + throw new Error("Failed to parse version from redirect URL"); + } + + return tag.replace(/^v/, ""); + } + + 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 detectInstallMethod(): InstallMethod { + if (process.argv[1]?.includes("node_modules")) { + return "npm"; + } + return "binary"; + } + + private static getPackageManagerHint(version: string): string { + const agent = process.env.npm_config_user_agent ?? ""; + if (agent.startsWith("pnpm")) { + return `pnpm add -g @jup-ag/cli@${version}`; + } + if (agent.startsWith("yarn")) { + return `yarn global add @jup-ag/cli@${version}`; + } + if (agent.startsWith("bun")) { + return `bun add -g @jup-ag/cli@${version}`; + } + // Check Volta via env var (set when Volta manages the shell) + if (process.env.VOLTA_HOME) { + return "volta install @jup-ag/cli"; + } + return `npm install -g @jup-ag/cli@${version}`; + } + + private static async updateBinary(version: string): Promise { + const binaryPath = process.execPath; + const execName = basename(binaryPath); + + // Guard: refuse to overwrite the runtime in dev mode + if (execName === "bun" || execName === "node") { + throw new Error( + "Cannot self-update in dev mode — process.execPath points to the runtime, not the jup binary. " + + "Build a standalone binary first: bun build src/index.ts --compile --outfile jup" + ); + } + + const assetName = this.getBinaryAssetName(); + const baseUrl = `https://github.com/jup-ag/cli/releases/download/v${version}`; + const headers = { "User-Agent": "@jup-ag/cli" }; + + const [binary, checksums] = await Promise.all([ + ky.get(`${baseUrl}/${assetName}`, { headers }).arrayBuffer(), + ky.get(`${baseUrl}/checksums.txt`, { headers }).text(), + ]); + + // Verify checksum — exact match on filename after the hash + const expectedHash = checksums + .split("\n") + .find((line) => { + const parts = line.trim().split(/\s+/); + return parts.length === 2 && parts[1] === assetName; + }) + ?.split(/\s+/)[0]; + + if (!expectedHash) { + throw new Error(`Checksum not found for ${assetName}`); + } + + const actualHash = createHash("sha256") + .update(Buffer.from(binary)) + .digest("hex"); + + if (actualHash !== expectedHash) { + throw new Error( + `Checksum mismatch for ${assetName}: expected ${expectedHash}, got ${actualHash}` + ); + } + + const tmpPath = `${binaryPath}.tmp`; + + try { + await writeFile(tmpPath, Buffer.from(binary)); + await chmod(tmpPath, 0o755); + await rename(tmpPath, binaryPath); + } catch (err: unknown) { + await rm(tmpPath, { force: true }).catch(() => {}); + if (err instanceof Error && "code" in err && err.code === "EACCES") { + throw new Error( + "Permission denied. Try running with sudo: sudo jup update" + ); + } + throw err; + } + } + + private static getBinaryAssetName(): string { + const platform = process.platform; + const arch = process.arch; + + const osMap: Record = { + linux: "linux", + darwin: "darwin", + }; + const archMap: Record = { + x64: "x64", + arm64: "arm64", + }; + + const os = osMap[platform]; + const cpu = archMap[arch]; + + if (!os || !cpu) { + throw new Error( + `Unsupported platform: ${platform}-${arch}. ` + + "Supported: linux-x64, linux-arm64, darwin-x64, darwin-arm64" + ); + } + + return `jup-${os}-${cpu}`; + } +} 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); From ce29700f88a4bfa9b81f811b2303111deb05c868 Mon Sep 17 00:00:00 2001 From: lifeofpavs Date: Fri, 20 Mar 2026 12:13:59 +0100 Subject: [PATCH 02/17] =?UTF-8?q?refactor:=20simplify=20UpdateCommand=20?= =?UTF-8?q?=E2=80=94=20use=20GitHub=20API,=20dedupe=20buffer/checksum=20pa?= =?UTF-8?q?rsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switch getLatestVersion to GitHub REST API JSON response instead of redirect parsing - Replace identity maps in getBinaryAssetName with Set-based validation - Parse checksum line once and reuse; compute Buffer.from(binary) once --- src/commands/UpdateCommand.ts | 69 +++++++++++------------------------ 1 file changed, 22 insertions(+), 47 deletions(-) diff --git a/src/commands/UpdateCommand.ts b/src/commands/UpdateCommand.ts index 71dfc03..1d89609 100644 --- a/src/commands/UpdateCommand.ts +++ b/src/commands/UpdateCommand.ts @@ -126,27 +126,13 @@ export class UpdateCommand { } private static async getLatestVersion(): Promise { - // Use native fetch with manual redirect to avoid GitHub API rate limits. - // The /releases/latest endpoint returns a 302 to /releases/tag/vX.Y.Z. - const response = await fetch( - "https://github.com/jup-ag/cli/releases/latest", - { - redirect: "manual", + const release = await ky + .get("https://api.github.com/repos/jup-ag/cli/releases/latest", { headers: { "User-Agent": "@jup-ag/cli" }, - } - ); - - const location = response.headers.get("location"); - if (!location) { - throw new Error("Failed to resolve latest version from GitHub"); - } - - const tag = location.split("/tag/").pop(); - if (!tag) { - throw new Error("Failed to parse version from redirect URL"); - } + }) + .json<{ tag_name: string }>(); - return tag.replace(/^v/, ""); + return release.tag_name.replace(/^v/, ""); } private static isNewer(latest: string, current: string): boolean { @@ -209,22 +195,20 @@ export class UpdateCommand { ky.get(`${baseUrl}/checksums.txt`, { headers }).text(), ]); + const buf = Buffer.from(binary); + // Verify checksum — exact match on filename after the hash - const expectedHash = checksums + const checksumLine = checksums .split("\n") - .find((line) => { - const parts = line.trim().split(/\s+/); - return parts.length === 2 && parts[1] === assetName; - }) - ?.split(/\s+/)[0]; + .map((line) => line.trim().split(/\s+/)) + .find((parts) => parts.length === 2 && parts[1] === assetName); - if (!expectedHash) { + if (!checksumLine) { throw new Error(`Checksum not found for ${assetName}`); } - const actualHash = createHash("sha256") - .update(Buffer.from(binary)) - .digest("hex"); + const expectedHash = checksumLine[0]; + const actualHash = createHash("sha256").update(buf).digest("hex"); if (actualHash !== expectedHash) { throw new Error( @@ -235,7 +219,7 @@ export class UpdateCommand { const tmpPath = `${binaryPath}.tmp`; try { - await writeFile(tmpPath, Buffer.from(binary)); + await writeFile(tmpPath, buf); await chmod(tmpPath, 0o755); await rename(tmpPath, binaryPath); } catch (err: unknown) { @@ -250,28 +234,19 @@ export class UpdateCommand { } private static getBinaryAssetName(): string { - const platform = process.platform; - const arch = process.arch; - - const osMap: Record = { - linux: "linux", - darwin: "darwin", - }; - const archMap: Record = { - x64: "x64", - arm64: "arm64", - }; - - const os = osMap[platform]; - const cpu = archMap[arch]; + const supportedPlatforms = new Set(["linux", "darwin"]); + const supportedArchs = new Set(["x64", "arm64"]); - if (!os || !cpu) { + if ( + !supportedPlatforms.has(process.platform) || + !supportedArchs.has(process.arch) + ) { throw new Error( - `Unsupported platform: ${platform}-${arch}. ` + + `Unsupported platform: ${process.platform}-${process.arch}. ` + "Supported: linux-x64, linux-arm64, darwin-x64, darwin-arm64" ); } - return `jup-${os}-${cpu}`; + return `jup-${process.platform}-${process.arch}`; } } From ee23d3008f65ba96f817cadbcb9edec84a60e716 Mon Sep 17 00:00:00 2001 From: lifeofpavs Date: Fri, 20 Mar 2026 12:25:13 +0100 Subject: [PATCH 03/17] chore: remove explicit User-Agent headers from UpdateCommand ky sends the runtime's default User-Agent automatically; GitHub only rejects requests with an empty header, not a missing one. --- src/commands/UpdateCommand.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/commands/UpdateCommand.ts b/src/commands/UpdateCommand.ts index 1d89609..5cebe31 100644 --- a/src/commands/UpdateCommand.ts +++ b/src/commands/UpdateCommand.ts @@ -127,9 +127,7 @@ export class UpdateCommand { private static async getLatestVersion(): Promise { const release = await ky - .get("https://api.github.com/repos/jup-ag/cli/releases/latest", { - headers: { "User-Agent": "@jup-ag/cli" }, - }) + .get("https://api.github.com/repos/jup-ag/cli/releases/latest") .json<{ tag_name: string }>(); return release.tag_name.replace(/^v/, ""); @@ -188,11 +186,10 @@ export class UpdateCommand { const assetName = this.getBinaryAssetName(); const baseUrl = `https://github.com/jup-ag/cli/releases/download/v${version}`; - const headers = { "User-Agent": "@jup-ag/cli" }; const [binary, checksums] = await Promise.all([ - ky.get(`${baseUrl}/${assetName}`, { headers }).arrayBuffer(), - ky.get(`${baseUrl}/checksums.txt`, { headers }).text(), + ky.get(`${baseUrl}/${assetName}`).arrayBuffer(), + ky.get(`${baseUrl}/checksums.txt`).text(), ]); const buf = Buffer.from(binary); From 83b97f0f9ad16f82882070fedfee53256d36ab0d Mon Sep 17 00:00:00 2001 From: lifeofpavs Date: Fri, 20 Mar 2026 12:32:38 +0100 Subject: [PATCH 04/17] refactor: inline getBinaryAssetName into updateBinary Single-use method with no reuse benefit; inlining reduces indirection. --- src/commands/UpdateCommand.ts | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/src/commands/UpdateCommand.ts b/src/commands/UpdateCommand.ts index 5cebe31..7d49297 100644 --- a/src/commands/UpdateCommand.ts +++ b/src/commands/UpdateCommand.ts @@ -184,7 +184,20 @@ export class UpdateCommand { ); } - const assetName = this.getBinaryAssetName(); + const supportedPlatforms = new Set(["linux", "darwin"]); + const supportedArchs = new Set(["x64", "arm64"]); + + if ( + !supportedPlatforms.has(process.platform) || + !supportedArchs.has(process.arch) + ) { + throw new Error( + `Unsupported platform: ${process.platform}-${process.arch}. ` + + "Supported: linux-x64, linux-arm64, darwin-x64, darwin-arm64" + ); + } + + const assetName = `jup-${process.platform}-${process.arch}`; const baseUrl = `https://github.com/jup-ag/cli/releases/download/v${version}`; const [binary, checksums] = await Promise.all([ @@ -229,21 +242,4 @@ export class UpdateCommand { throw err; } } - - private static getBinaryAssetName(): string { - const supportedPlatforms = new Set(["linux", "darwin"]); - const supportedArchs = new Set(["x64", "arm64"]); - - if ( - !supportedPlatforms.has(process.platform) || - !supportedArchs.has(process.arch) - ) { - throw new Error( - `Unsupported platform: ${process.platform}-${process.arch}. ` + - "Supported: linux-x64, linux-arm64, darwin-x64, darwin-arm64" - ); - } - - return `jup-${process.platform}-${process.arch}`; - } } From f7431b0d427f1df2af373c45c167ca96147d83c6 Mon Sep 17 00:00:00 2001 From: lifeofpavs Date: Fri, 20 Mar 2026 12:56:21 +0100 Subject: [PATCH 05/17] refactor: derive supported platforms from checksums.txt Fetch checksums before the binary to validate platform support dynamically instead of hardcoding allowed platforms/archs. --- src/commands/UpdateCommand.ts | 37 ++++++++++++++--------------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/src/commands/UpdateCommand.ts b/src/commands/UpdateCommand.ts index 7d49297..83fb619 100644 --- a/src/commands/UpdateCommand.ts +++ b/src/commands/UpdateCommand.ts @@ -184,39 +184,32 @@ export class UpdateCommand { ); } - const supportedPlatforms = new Set(["linux", "darwin"]); - const supportedArchs = new Set(["x64", "arm64"]); - - if ( - !supportedPlatforms.has(process.platform) || - !supportedArchs.has(process.arch) - ) { - throw new Error( - `Unsupported platform: ${process.platform}-${process.arch}. ` + - "Supported: linux-x64, linux-arm64, darwin-x64, darwin-arm64" - ); - } - const assetName = `jup-${process.platform}-${process.arch}`; const baseUrl = `https://github.com/jup-ag/cli/releases/download/v${version}`; - const [binary, checksums] = await Promise.all([ - ky.get(`${baseUrl}/${assetName}`).arrayBuffer(), - ky.get(`${baseUrl}/checksums.txt`).text(), - ]); - - const buf = Buffer.from(binary); - - // Verify checksum — exact match on filename after the hash + // Fetch checksums first to validate platform support before downloading + const checksums = await ky.get(`${baseUrl}/checksums.txt`).text(); const checksumLine = checksums .split("\n") .map((line) => line.trim().split(/\s+/)) .find((parts) => parts.length === 2 && parts[1] === assetName); if (!checksumLine) { - throw new Error(`Checksum not found for ${assetName}`); + const supported = checksums + .split("\n") + .map((line) => line.trim().split(/\s+/)) + .filter((parts) => parts.length === 2) + .map((parts) => parts[1]!.replace("jup-", "")) + .join(", "); + throw new Error( + `Unsupported platform: ${process.platform}-${process.arch}. ` + + `Supported: ${supported}` + ); } + const binary = await ky.get(`${baseUrl}/${assetName}`).arrayBuffer(); + const buf = Buffer.from(binary); + const expectedHash = checksumLine[0]; const actualHash = createHash("sha256").update(buf).digest("hex"); From 8cb95a9229078dc4d14ee8f196d7f758720982c3 Mon Sep 17 00:00:00 2001 From: lifeofpavs Date: Fri, 20 Mar 2026 13:01:45 +0100 Subject: [PATCH 06/17] chore: remove dev workflow references from update command and docs --- docs/update.md | 1 - src/commands/UpdateCommand.ts | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/docs/update.md b/docs/update.md index 550ab52..932f25c 100644 --- a/docs/update.md +++ b/docs/update.md @@ -60,4 +60,3 @@ jup update --check - Binary updates require write permission to the binary path. If you get a permission error, run `sudo jup update`. - Binary builds are available for: `linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64`. -- In dev mode (`bun run dev`), the update command will refuse to run the binary update path to avoid overwriting the Bun runtime. diff --git a/src/commands/UpdateCommand.ts b/src/commands/UpdateCommand.ts index 83fb619..38f1bec 100644 --- a/src/commands/UpdateCommand.ts +++ b/src/commands/UpdateCommand.ts @@ -176,11 +176,9 @@ export class UpdateCommand { const binaryPath = process.execPath; const execName = basename(binaryPath); - // Guard: refuse to overwrite the runtime in dev mode if (execName === "bun" || execName === "node") { throw new Error( - "Cannot self-update in dev mode — process.execPath points to the runtime, not the jup binary. " + - "Build a standalone binary first: bun build src/index.ts --compile --outfile jup" + "Cannot self-update: process.execPath points to the runtime, not the jup binary" ); } From 78dc09b2021eb9f0220667544ac0f18e79ced2c6 Mon Sep 17 00:00:00 2001 From: lifeofpavs Date: Fri, 20 Mar 2026 13:12:15 +0100 Subject: [PATCH 07/17] feat: auto-execute package manager updates instead of printing hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both npm and binary paths now run automatically. Removes the manual_update_required status — all updates return status: updated. --- docs/update.md | 13 +++++------ src/commands/UpdateCommand.ts | 42 +++++++++-------------------------- 2 files changed, 16 insertions(+), 39 deletions(-) diff --git a/docs/update.md b/docs/update.md index 932f25c..dfbe4cd 100644 --- a/docs/update.md +++ b/docs/update.md @@ -14,10 +14,10 @@ jup update --check ## How it works -1. Checks the latest release on GitHub (via redirect, no API token needed) +1. Checks the latest release on GitHub 2. Compares with the installed version 3. If a newer version is available: - - **Package manager installs** — prints the appropriate update command (npm, pnpm, yarn, bun, or Volta) + - **Package manager installs** — runs the appropriate update command (npm, pnpm, yarn, bun, or Volta) - **Binary installs** — downloads the new binary, verifies its SHA-256 checksum, and atomically replaces the current binary 4. Outputs the result @@ -38,16 +38,15 @@ jup update --check "status": "update_available" } -// Package manager install — prints command to run +// Updated via package manager { "currentVersion": "0.3.0", "latestVersion": "0.4.0", - "status": "manual_update_required", - "method": "npm", - "command": "npm install -g @jup-ag/cli@0.4.0" + "status": "updated", + "method": "npm" } -// Binary updated +// Updated via binary { "currentVersion": "0.3.0", "latestVersion": "0.4.0", diff --git a/src/commands/UpdateCommand.ts b/src/commands/UpdateCommand.ts index 38f1bec..725db77 100644 --- a/src/commands/UpdateCommand.ts +++ b/src/commands/UpdateCommand.ts @@ -1,3 +1,4 @@ +import { execSync } from "child_process"; import { createHash } from "crypto"; import { chmod, rename, rm, writeFile } from "fs/promises"; import { basename } from "path"; @@ -67,40 +68,17 @@ export class UpdateCommand { const method = this.detectInstallMethod(); - if (method === "npm") { - const hint = this.getPackageManagerHint(latestVersion); - if (Output.isJson()) { - Output.json({ - currentVersion, - latestVersion, - status: "manual_update_required", - method: "npm", - command: hint, - }); - } else { - Output.table({ - type: "vertical", - rows: [ - { label: "Current Version", value: `v${currentVersion}` }, - { - label: "Latest Version", - value: chalk.green(`v${latestVersion}`), - }, - { - label: "Update Command", - value: chalk.cyan(hint), - }, - ], - }); - } - return; - } - if (!Output.isJson()) { - console.log(`Downloading v${latestVersion}...`); + console.log(`Updating to v${latestVersion}...`); } - await this.updateBinary(latestVersion); + if (method === "npm") { + execSync(this.getPackageManagerCommand(latestVersion), { + stdio: "inherit", + }); + } else { + await this.updateBinary(latestVersion); + } if (Output.isJson()) { Output.json({ @@ -154,7 +132,7 @@ export class UpdateCommand { return "binary"; } - private static getPackageManagerHint(version: string): string { + private static getPackageManagerCommand(version: string): string { const agent = process.env.npm_config_user_agent ?? ""; if (agent.startsWith("pnpm")) { return `pnpm add -g @jup-ag/cli@${version}`; From b99a54ab0d013aa9fdb83c057b0d276d2e12151f Mon Sep 17 00:00:00 2001 From: lifeofpavs Date: Mon, 23 Mar 2026 12:46:37 +0100 Subject: [PATCH 08/17] refactor: delegate binary updates to install.sh release asset Ship install.sh as a GitHub release asset and fetch it from the release URL instead of raw.githubusercontent.com, so the update command uses the same versioned script that users install with. --- .github/workflows/release.yml | 1 + src/commands/UpdateCommand.ts | 74 ++++++----------------------------- 2 files changed, 14 insertions(+), 61 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b739e56..97f0660 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,6 +76,7 @@ jobs: files: | dist/jup-* dist/checksums.txt + scripts/install.sh generate_release_notes: true - name: Publish to npm diff --git a/src/commands/UpdateCommand.ts b/src/commands/UpdateCommand.ts index 725db77..06a52c9 100644 --- a/src/commands/UpdateCommand.ts +++ b/src/commands/UpdateCommand.ts @@ -1,7 +1,7 @@ import { execSync } from "child_process"; -import { createHash } from "crypto"; -import { chmod, rename, rm, writeFile } from "fs/promises"; -import { basename } from "path"; +import { rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; import chalk from "chalk"; import ky from "ky"; @@ -77,7 +77,7 @@ export class UpdateCommand { stdio: "inherit", }); } else { - await this.updateBinary(latestVersion); + await this.updateBinary(); } if (Output.isJson()) { @@ -150,65 +150,17 @@ export class UpdateCommand { return `npm install -g @jup-ag/cli@${version}`; } - private static async updateBinary(version: string): Promise { - const binaryPath = process.execPath; - const execName = basename(binaryPath); - - if (execName === "bun" || execName === "node") { - throw new Error( - "Cannot self-update: process.execPath points to the runtime, not the jup binary" - ); - } - - const assetName = `jup-${process.platform}-${process.arch}`; - const baseUrl = `https://github.com/jup-ag/cli/releases/download/v${version}`; - - // Fetch checksums first to validate platform support before downloading - const checksums = await ky.get(`${baseUrl}/checksums.txt`).text(); - const checksumLine = checksums - .split("\n") - .map((line) => line.trim().split(/\s+/)) - .find((parts) => parts.length === 2 && parts[1] === assetName); - - if (!checksumLine) { - const supported = checksums - .split("\n") - .map((line) => line.trim().split(/\s+/)) - .filter((parts) => parts.length === 2) - .map((parts) => parts[1]!.replace("jup-", "")) - .join(", "); - throw new Error( - `Unsupported platform: ${process.platform}-${process.arch}. ` + - `Supported: ${supported}` - ); - } - - const binary = await ky.get(`${baseUrl}/${assetName}`).arrayBuffer(); - const buf = Buffer.from(binary); - - const expectedHash = checksumLine[0]; - const actualHash = createHash("sha256").update(buf).digest("hex"); - - if (actualHash !== expectedHash) { - throw new Error( - `Checksum mismatch for ${assetName}: expected ${expectedHash}, got ${actualHash}` - ); - } - - const tmpPath = `${binaryPath}.tmp`; + private static async updateBinary(): Promise { + const scriptUrl = + "https://github.com/jup-ag/cli/releases/latest/download/install.sh"; + const scriptPath = join(tmpdir(), "jup-install.sh"); try { - await writeFile(tmpPath, buf); - await chmod(tmpPath, 0o755); - await rename(tmpPath, binaryPath); - } catch (err: unknown) { - await rm(tmpPath, { force: true }).catch(() => {}); - if (err instanceof Error && "code" in err && err.code === "EACCES") { - throw new Error( - "Permission denied. Try running with sudo: sudo jup update" - ); - } - throw err; + const script = await ky.get(scriptUrl).text(); + await writeFile(scriptPath, script); + execSync(`bash ${scriptPath}`, { stdio: "inherit" }); + } finally { + await rm(scriptPath, { force: true }).catch(() => {}); } } } From cf281338f2ca954f4346ae4bcfbe48999babb0ec Mon Sep 17 00:00:00 2001 From: lifeofpavs Date: Mon, 23 Mar 2026 12:50:23 +0100 Subject: [PATCH 09/17] fix: use mkdtemp for secure temp file creation in updateBinary Avoid predictable temp file path by creating a unique directory with mkdtemp, preventing symlink attacks flagged by GitHub code scanning. --- src/commands/UpdateCommand.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/commands/UpdateCommand.ts b/src/commands/UpdateCommand.ts index 06a52c9..7ee14aa 100644 --- a/src/commands/UpdateCommand.ts +++ b/src/commands/UpdateCommand.ts @@ -1,5 +1,5 @@ import { execSync } from "child_process"; -import { rm, writeFile } from "fs/promises"; +import { mkdtemp, rm, writeFile } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; @@ -153,14 +153,15 @@ export class UpdateCommand { private static async updateBinary(): Promise { const scriptUrl = "https://github.com/jup-ag/cli/releases/latest/download/install.sh"; - const scriptPath = join(tmpdir(), "jup-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); + await writeFile(scriptPath, script, { mode: 0o700 }); execSync(`bash ${scriptPath}`, { stdio: "inherit" }); } finally { - await rm(scriptPath, { force: true }).catch(() => {}); + await rm(dir, { recursive: true, force: true }).catch(() => {}); } } } From 2dc272cafb599c27285dfac10cd9d685c3de0a90 Mon Sep 17 00:00:00 2001 From: lifeofpavs Date: Mon, 23 Mar 2026 12:51:45 +0100 Subject: [PATCH 10/17] refactor: embed install.sh at build time instead of fetching it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script is already in the repo — import it as text so Bun bundles it into the compiled binary. Removes the runtime network fetch. --- src/commands/UpdateCommand.ts | 6 ++---- src/types/text.d.ts | 4 ++++ 2 files changed, 6 insertions(+), 4 deletions(-) create mode 100644 src/types/text.d.ts diff --git a/src/commands/UpdateCommand.ts b/src/commands/UpdateCommand.ts index 7ee14aa..8641f14 100644 --- a/src/commands/UpdateCommand.ts +++ b/src/commands/UpdateCommand.ts @@ -8,6 +8,7 @@ import ky from "ky"; import type { Command } from "commander"; import { version as currentVersion } from "../../package.json"; +import installScript from "../../scripts/install.sh" with { type: "text" }; import { Output } from "../lib/Output.ts"; type InstallMethod = "npm" | "binary"; @@ -151,14 +152,11 @@ export class UpdateCommand { } private static async updateBinary(): Promise { - const scriptUrl = - "https://github.com/jup-ag/cli/releases/latest/download/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 }); + await writeFile(scriptPath, installScript, { mode: 0o700 }); execSync(`bash ${scriptPath}`, { stdio: "inherit" }); } finally { await rm(dir, { recursive: true, force: true }).catch(() => {}); diff --git a/src/types/text.d.ts b/src/types/text.d.ts new file mode 100644 index 0000000..9d18d63 --- /dev/null +++ b/src/types/text.d.ts @@ -0,0 +1,4 @@ +declare module "*.sh" { + const content: string; + export default content; +} From 0ac1ae450bdbec1589d94a2daad0a462c4ebae2d Mon Sep 17 00:00:00 2001 From: lifeofpavs Date: Mon, 23 Mar 2026 15:30:20 +0100 Subject: [PATCH 11/17] refactor: fetch install.sh from release instead of embedding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the text import approach — fetching from the release URL is cleaner than importing a shell script as a TS text module. --- src/commands/UpdateCommand.ts | 6 ++++-- src/types/text.d.ts | 4 ---- 2 files changed, 4 insertions(+), 6 deletions(-) delete mode 100644 src/types/text.d.ts diff --git a/src/commands/UpdateCommand.ts b/src/commands/UpdateCommand.ts index 8641f14..7ee14aa 100644 --- a/src/commands/UpdateCommand.ts +++ b/src/commands/UpdateCommand.ts @@ -8,7 +8,6 @@ import ky from "ky"; import type { Command } from "commander"; import { version as currentVersion } from "../../package.json"; -import installScript from "../../scripts/install.sh" with { type: "text" }; import { Output } from "../lib/Output.ts"; type InstallMethod = "npm" | "binary"; @@ -152,11 +151,14 @@ export class UpdateCommand { } private static async updateBinary(): Promise { + const scriptUrl = + "https://github.com/jup-ag/cli/releases/latest/download/install.sh"; const dir = await mkdtemp(join(tmpdir(), "jup-")); const scriptPath = join(dir, "install.sh"); try { - await writeFile(scriptPath, installScript, { mode: 0o700 }); + const script = await ky.get(scriptUrl).text(); + await writeFile(scriptPath, script, { mode: 0o700 }); execSync(`bash ${scriptPath}`, { stdio: "inherit" }); } finally { await rm(dir, { recursive: true, force: true }).catch(() => {}); diff --git a/src/types/text.d.ts b/src/types/text.d.ts deleted file mode 100644 index 9d18d63..0000000 --- a/src/types/text.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module "*.sh" { - const content: string; - export default content; -} From af9ce3b6e2d6690ef4df0a1378c61ee735c53a1f Mon Sep 17 00:00:00 2001 From: lifeofpavs Date: Mon, 23 Mar 2026 15:42:01 +0100 Subject: [PATCH 12/17] refactor: simplify update command to delegate entirely to install.sh Remove install method detection, package manager commands, and binary update logic. The update command now fetches and runs install.sh which handles volta/npm/binary fallback internally. --- .github/workflows/release.yml | 1 - src/commands/UpdateCommand.ts | 43 +++-------------------------------- 2 files changed, 3 insertions(+), 41 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97f0660..b739e56 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,7 +76,6 @@ jobs: files: | dist/jup-* dist/checksums.txt - scripts/install.sh generate_release_notes: true - name: Publish to npm diff --git a/src/commands/UpdateCommand.ts b/src/commands/UpdateCommand.ts index 7ee14aa..033f76c 100644 --- a/src/commands/UpdateCommand.ts +++ b/src/commands/UpdateCommand.ts @@ -10,8 +10,6 @@ import type { Command } from "commander"; import { version as currentVersion } from "../../package.json"; import { Output } from "../lib/Output.ts"; -type InstallMethod = "npm" | "binary"; - export class UpdateCommand { public static register(program: Command): void { program @@ -66,26 +64,17 @@ export class UpdateCommand { return; } - const method = this.detectInstallMethod(); - if (!Output.isJson()) { console.log(`Updating to v${latestVersion}...`); } - if (method === "npm") { - execSync(this.getPackageManagerCommand(latestVersion), { - stdio: "inherit", - }); - } else { - await this.updateBinary(); - } + await this.runInstallScript(); if (Output.isJson()) { Output.json({ currentVersion, latestVersion, status: "updated", - method, }); } else { Output.table({ @@ -96,7 +85,6 @@ export class UpdateCommand { label: "New Version", value: chalk.green(`v${latestVersion}`), }, - { label: "Method", value: method }, { label: "Status", value: chalk.green("Updated successfully") }, ], }); @@ -125,34 +113,9 @@ export class UpdateCommand { return false; } - private static detectInstallMethod(): InstallMethod { - if (process.argv[1]?.includes("node_modules")) { - return "npm"; - } - return "binary"; - } - - private static getPackageManagerCommand(version: string): string { - const agent = process.env.npm_config_user_agent ?? ""; - if (agent.startsWith("pnpm")) { - return `pnpm add -g @jup-ag/cli@${version}`; - } - if (agent.startsWith("yarn")) { - return `yarn global add @jup-ag/cli@${version}`; - } - if (agent.startsWith("bun")) { - return `bun add -g @jup-ag/cli@${version}`; - } - // Check Volta via env var (set when Volta manages the shell) - if (process.env.VOLTA_HOME) { - return "volta install @jup-ag/cli"; - } - return `npm install -g @jup-ag/cli@${version}`; - } - - private static async updateBinary(): Promise { + private static async runInstallScript(): Promise { const scriptUrl = - "https://github.com/jup-ag/cli/releases/latest/download/install.sh"; + "https://raw.githubusercontent.com/jup-ag/cli/main/scripts/install.sh"; const dir = await mkdtemp(join(tmpdir(), "jup-")); const scriptPath = join(dir, "install.sh"); From ccc046480848cbcd05cf2c8d8f513dc4d037aebe Mon Sep 17 00:00:00 2001 From: lifeofpavs Date: Mon, 23 Mar 2026 15:42:52 +0100 Subject: [PATCH 13/17] docs: update update.md to reflect install.sh delegation --- docs/update.md | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/docs/update.md b/docs/update.md index dfbe4cd..2f8d634 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,6 +1,6 @@ # Update -Self-update the CLI to the latest version. Detects whether the CLI was installed via a package manager or as a standalone binary and acts accordingly. +Self-update the CLI to the latest version. ## Usage @@ -16,9 +16,7 @@ jup update --check 1. Checks the latest release on GitHub 2. Compares with the installed version -3. If a newer version is available: - - **Package manager installs** — runs the appropriate update command (npm, pnpm, yarn, bun, or Volta) - - **Binary installs** — downloads the new binary, verifies its SHA-256 checksum, and atomically replaces the current binary +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 @@ -38,24 +36,14 @@ jup update --check "status": "update_available" } -// Updated via package manager +// Updated { "currentVersion": "0.3.0", "latestVersion": "0.4.0", - "status": "updated", - "method": "npm" -} - -// Updated via binary -{ - "currentVersion": "0.3.0", - "latestVersion": "0.4.0", - "status": "updated", - "method": "binary" + "status": "updated" } ``` ## Notes -- Binary updates require write permission to the binary path. If you get a permission error, run `sudo jup update`. -- Binary builds are available for: `linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64`. +- Supported platforms: `linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64`. From 9f1eab33f6fd18b7879f5aa9222907941778d8a3 Mon Sep 17 00:00:00 2001 From: lifeofpavs Date: Mon, 23 Mar 2026 17:02:15 +0100 Subject: [PATCH 14/17] nits --- scripts/install.sh | 30 ++++++--- src/commands/UpdateCommand.ts | 123 ++++++++++++++++------------------ 2 files changed, 80 insertions(+), 73 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index da4b7ea..dbaa965 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -46,26 +46,38 @@ 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 +if command -v sha256sum &>/dev/null; then + ACTUAL=$(sha256sum "$TMP_BINARY" | awk '{print $1}') +elif command -v shasum &>/dev/null; then + ACTUAL=$(shasum -a 256 "$TMP_BINARY" | awk '{print $1}') +else + error "No sha256sum or shasum found. Cannot verify checksum." +fi 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 index 033f76c..87203f1 100644 --- a/src/commands/UpdateCommand.ts +++ b/src/commands/UpdateCommand.ts @@ -1,4 +1,4 @@ -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { mkdtemp, rm, writeFile } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; @@ -21,82 +21,74 @@ export class UpdateCommand { private static async update(opts: { check?: boolean }): Promise { const latestVersion = await this.getLatestVersion(); + const isUpToDate = !this.isNewer(latestVersion, currentVersion); - if (!this.isNewer(latestVersion, currentVersion)) { - if (Output.isJson()) { - Output.json({ - currentVersion, - latestVersion, - status: "up_to_date", - }); - } else { - Output.table({ - type: "vertical", - rows: [ - { label: "Version", value: `v${currentVersion}` }, - { label: "Status", value: chalk.green("Already up to date") }, - ], - }); - } - return; + 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) { - if (Output.isJson()) { - Output.json({ - currentVersion, - latestVersion, - status: "update_available", - }); - } else { - Output.table({ - type: "vertical", - rows: [ - { label: "Current Version", value: `v${currentVersion}` }, - { - label: "Latest Version", - value: chalk.green(`v${latestVersion}`), - }, - { label: "Status", value: "Update available" }, - ], - }); - } - return; + 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(); + await this.runInstallScript(latestVersion); + 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({ - currentVersion, - latestVersion, - status: "updated", - }); + Output.json(opts.json); } else { - Output.table({ - type: "vertical", - rows: [ - { label: "Previous Version", value: `v${currentVersion}` }, - { - label: "New Version", - value: chalk.green(`v${latestVersion}`), - }, - { label: "Status", value: chalk.green("Updated successfully") }, - ], - }); + Output.table({ type: "vertical", rows: opts.rows }); } } private static async getLatestVersion(): Promise { - const release = await ky - .get("https://api.github.com/repos/jup-ag/cli/releases/latest") - .json<{ tag_name: string }>(); + 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/, ""); + 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 { @@ -113,18 +105,21 @@ export class UpdateCommand { return false; } - private static async runInstallScript(): Promise { - const scriptUrl = - "https://raw.githubusercontent.com/jup-ag/cli/main/scripts/install.sh"; + private static async runInstallScript(version: string): Promise { + const scriptUrl = `https://raw.githubusercontent.com/jup-ag/cli/v${version}/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 }); - execSync(`bash ${scriptPath}`, { stdio: "inherit" }); + 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 { - await rm(dir, { recursive: true, force: true }).catch(() => {}); + rm(dir, { recursive: true, force: true }).catch(() => {}); } } } From 3cfcae17c945788a08b558d8d8c4060d158881cc Mon Sep 17 00:00:00 2001 From: lifeofpavs Date: Mon, 23 Mar 2026 18:17:07 +0100 Subject: [PATCH 15/17] refactor: remove shasum fallback from install.sh macOS 15+ ships a native /sbin/sha256sum, so the shasum -a 256 fallback for older versions is unnecessary. --- scripts/install.sh | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index dbaa965..eccf999 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -60,13 +60,7 @@ EXPECTED=$(grep "$ASSET" "$TMP_CHECKSUMS" | awk '{print $1}') || true if [ -z "$EXPECTED" ]; then error "No checksum found for $ASSET in checksums.txt" fi -if command -v sha256sum &>/dev/null; then - ACTUAL=$(sha256sum "$TMP_BINARY" | awk '{print $1}') -elif command -v shasum &>/dev/null; then - ACTUAL=$(shasum -a 256 "$TMP_BINARY" | awk '{print $1}') -else - error "No sha256sum or shasum found. Cannot verify checksum." -fi +ACTUAL=$(sha256sum "$TMP_BINARY" | awk '{print $1}') if [ "$EXPECTED" != "$ACTUAL" ]; then error "Checksum verification failed. Expected $EXPECTED, got $ACTUAL" fi From 9bac55cf6afe899f475b2453c3e1ce8c874b4f1a Mon Sep 17 00:00:00 2001 From: lifeofpavs Date: Mon, 23 Mar 2026 23:54:37 +0100 Subject: [PATCH 16/17] fix: align update flow with canonical installer --- .github/workflows/release.yml | 27 ++++++++++++++++++++------- README.md | 2 +- docs/setup.md | 2 +- scripts/install.sh | 28 +++++++++++++++++++++------- src/commands/UpdateCommand.ts | 17 +++++++++++++---- 5 files changed, 56 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b739e56..ed05a82 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,22 +62,35 @@ jobs: if: steps.version.outputs.changed == 'true' run: cd dist && sha256sum jup-* > checksums.txt - - name: Create tag + - name: Publish to npm + if: steps.version.outputs.changed == 'true' + run: npm publish --access public --provenance + + - name: Wait for npm package availability if: steps.version.outputs.changed == 'true' + env: + EXPECTED_VERSION: ${{ steps.version.outputs.version }} run: | - git tag ${{ steps.version.outputs.tag }} - git push origin ${{ steps.version.outputs.tag }} + for attempt in {1..30}; do + PUBLISHED_VERSION=$(npm view @jup-ag/cli version 2>/dev/null || true) + if [ "$PUBLISHED_VERSION" = "$EXPECTED_VERSION" ]; then + echo "npm package version $EXPECTED_VERSION is available" + exit 0 + fi + echo "Waiting for npm to serve $EXPECTED_VERSION (got ${PUBLISHED_VERSION:-})" + sleep 10 + done + echo "Timed out waiting for npm package version $EXPECTED_VERSION" + exit 1 - name: Create GitHub Release if: steps.version.outputs.changed == 'true' uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 with: tag_name: ${{ steps.version.outputs.tag }} + target_commitish: ${{ github.sha }} files: | dist/jup-* dist/checksums.txt + scripts/install.sh generate_release_notes: true - - - name: Publish to npm - if: steps.version.outputs.changed == 'true' - run: npm publish --access public --provenance diff --git a/README.md b/README.md index 194f770..23567c4 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ npm i -g @jup-ag/cli Or use the install script to auto-detect the best method: ```bash -curl -fsSL https://raw.githubusercontent.com/jup-ag/cli/main/scripts/install.sh | bash +curl -fsSL https://github.com/jup-ag/cli/releases/latest/download/install.sh | bash ``` ## Quick Start diff --git a/docs/setup.md b/docs/setup.md index 691fea9..6f77a9b 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -13,7 +13,7 @@ npm i -g @jup-ag/cli Auto-detects the best install method (volta > npm > standalone binary): ```bash -curl -fsSL https://raw.githubusercontent.com/jup-ag/cli/main/scripts/install.sh | bash +curl -fsSL https://github.com/jup-ag/cli/releases/latest/download/install.sh | bash ``` ### Option 3: Standalone binary diff --git a/scripts/install.sh b/scripts/install.sh index eccf999..fb6f9c4 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4,21 +4,35 @@ set -euo pipefail PACKAGE="@jup-ag/cli" BINARY="jup" REPO="jup-ag/cli" +VERSION="${JUP_VERSION:-latest}" +QUIET="${JUP_INSTALL_QUIET:-0}" -info() { printf '\033[1;34m%s\033[0m\n' "$*"; } +if [ "$VERSION" != "latest" ]; then + VERSION="${VERSION#v}" +fi + +info() { [ "$QUIET" = "1" ] || printf '\033[1;34m%s\033[0m\n' "$*" >&2; } error() { printf '\033[1;31merror: %s\033[0m\n' "$*" >&2; exit 1; } +if [ "$VERSION" = "latest" ]; then + PACKAGE_SPEC="$PACKAGE" + RELEASE_BASE="https://github.com/${REPO}/releases/latest/download" +else + PACKAGE_SPEC="${PACKAGE}@${VERSION}" + RELEASE_BASE="https://github.com/${REPO}/releases/download/v${VERSION}" +fi + # Volta if command -v volta &>/dev/null; then - info "Installing $PACKAGE via volta..." - volta install "$PACKAGE" + info "Installing $PACKAGE_SPEC via volta..." + volta install "$PACKAGE_SPEC" exit 0 fi # npm if command -v npm &>/dev/null; then - info "Installing $PACKAGE via npm..." - npm install -g "$PACKAGE" + info "Installing $PACKAGE_SPEC via npm..." + npm install -g "$PACKAGE_SPEC" exit 0 fi @@ -42,9 +56,9 @@ esac ASSET="${BINARY}-${os}-${arch}" INSTALL_DIR="${JUP_INSTALL_DIR:-/usr/local/bin}" -URL="https://github.com/${REPO}/releases/latest/download/${ASSET}" +URL="${RELEASE_BASE}/${ASSET}" -CHECKSUM_URL="https://github.com/${REPO}/releases/latest/download/checksums.txt" +CHECKSUM_URL="${RELEASE_BASE}/checksums.txt" TMP_DIR=$(mktemp -d) TMP_BINARY="${TMP_DIR}/${BINARY}" diff --git a/src/commands/UpdateCommand.ts b/src/commands/UpdateCommand.ts index 87203f1..18b8009 100644 --- a/src/commands/UpdateCommand.ts +++ b/src/commands/UpdateCommand.ts @@ -51,7 +51,7 @@ export class UpdateCommand { console.log(`Updating to v${latestVersion}...`); } - await this.runInstallScript(latestVersion); + await this.runInstallScript(); this.output({ json: { currentVersion, latestVersion, status: "updated" }, @@ -105,15 +105,24 @@ export class UpdateCommand { return false; } - private static async runInstallScript(version: string): Promise { - const scriptUrl = `https://raw.githubusercontent.com/jup-ag/cli/v${version}/scripts/install.sh`; + private static async runInstallScript(): Promise { + const scriptUrl = + "https://github.com/jup-ag/cli/releases/latest/download/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" }); + execFileSync("bash", [scriptPath], { + env: { + ...process.env, + JUP_INSTALL_QUIET: Output.isJson() ? "1" : "0", + }, + stdio: Output.isJson() + ? ["inherit", "ignore", "inherit"] + : "inherit", + }); } catch { throw new Error( "Update failed. Run `jup update` again or install manually from https://github.com/jup-ag/cli/releases" From 6b8e72682edec54c144390cc62bfe6cde9607467 Mon Sep 17 00:00:00 2001 From: lifeofpavs Date: Tue, 24 Mar 2026 00:23:05 +0100 Subject: [PATCH 17/17] refactor: fetch installer from main --- .github/workflows/release.yml | 27 +++++++-------------------- README.md | 2 +- docs/setup.md | 2 +- scripts/install.sh | 29 +++++++---------------------- src/commands/UpdateCommand.ts | 12 ++---------- 5 files changed, 18 insertions(+), 54 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed05a82..b739e56 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,35 +62,22 @@ jobs: if: steps.version.outputs.changed == 'true' run: cd dist && sha256sum jup-* > checksums.txt - - name: Publish to npm - if: steps.version.outputs.changed == 'true' - run: npm publish --access public --provenance - - - name: Wait for npm package availability + - name: Create tag if: steps.version.outputs.changed == 'true' - env: - EXPECTED_VERSION: ${{ steps.version.outputs.version }} run: | - for attempt in {1..30}; do - PUBLISHED_VERSION=$(npm view @jup-ag/cli version 2>/dev/null || true) - if [ "$PUBLISHED_VERSION" = "$EXPECTED_VERSION" ]; then - echo "npm package version $EXPECTED_VERSION is available" - exit 0 - fi - echo "Waiting for npm to serve $EXPECTED_VERSION (got ${PUBLISHED_VERSION:-})" - sleep 10 - done - echo "Timed out waiting for npm package version $EXPECTED_VERSION" - exit 1 + git tag ${{ steps.version.outputs.tag }} + git push origin ${{ steps.version.outputs.tag }} - name: Create GitHub Release if: steps.version.outputs.changed == 'true' uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 with: tag_name: ${{ steps.version.outputs.tag }} - target_commitish: ${{ github.sha }} files: | dist/jup-* dist/checksums.txt - scripts/install.sh generate_release_notes: true + + - name: Publish to npm + if: steps.version.outputs.changed == 'true' + run: npm publish --access public --provenance diff --git a/README.md b/README.md index 23567c4..194f770 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ npm i -g @jup-ag/cli Or use the install script to auto-detect the best method: ```bash -curl -fsSL https://github.com/jup-ag/cli/releases/latest/download/install.sh | bash +curl -fsSL https://raw.githubusercontent.com/jup-ag/cli/main/scripts/install.sh | bash ``` ## Quick Start diff --git a/docs/setup.md b/docs/setup.md index 6f77a9b..691fea9 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -13,7 +13,7 @@ npm i -g @jup-ag/cli Auto-detects the best install method (volta > npm > standalone binary): ```bash -curl -fsSL https://github.com/jup-ag/cli/releases/latest/download/install.sh | bash +curl -fsSL https://raw.githubusercontent.com/jup-ag/cli/main/scripts/install.sh | bash ``` ### Option 3: Standalone binary diff --git a/scripts/install.sh b/scripts/install.sh index fb6f9c4..5f38d19 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4,35 +4,20 @@ set -euo pipefail PACKAGE="@jup-ag/cli" BINARY="jup" REPO="jup-ag/cli" -VERSION="${JUP_VERSION:-latest}" -QUIET="${JUP_INSTALL_QUIET:-0}" - -if [ "$VERSION" != "latest" ]; then - VERSION="${VERSION#v}" -fi - -info() { [ "$QUIET" = "1" ] || printf '\033[1;34m%s\033[0m\n' "$*" >&2; } +info() { printf '\033[1;34m%s\033[0m\n' "$*"; } error() { printf '\033[1;31merror: %s\033[0m\n' "$*" >&2; exit 1; } -if [ "$VERSION" = "latest" ]; then - PACKAGE_SPEC="$PACKAGE" - RELEASE_BASE="https://github.com/${REPO}/releases/latest/download" -else - PACKAGE_SPEC="${PACKAGE}@${VERSION}" - RELEASE_BASE="https://github.com/${REPO}/releases/download/v${VERSION}" -fi - # Volta if command -v volta &>/dev/null; then - info "Installing $PACKAGE_SPEC via volta..." - volta install "$PACKAGE_SPEC" + info "Installing $PACKAGE via volta..." + volta install "$PACKAGE" exit 0 fi # npm if command -v npm &>/dev/null; then - info "Installing $PACKAGE_SPEC via npm..." - npm install -g "$PACKAGE_SPEC" + info "Installing $PACKAGE via npm..." + npm install -g "$PACKAGE" exit 0 fi @@ -56,9 +41,9 @@ esac ASSET="${BINARY}-${os}-${arch}" INSTALL_DIR="${JUP_INSTALL_DIR:-/usr/local/bin}" -URL="${RELEASE_BASE}/${ASSET}" +URL="https://github.com/${REPO}/releases/latest/download/${ASSET}" -CHECKSUM_URL="${RELEASE_BASE}/checksums.txt" +CHECKSUM_URL="https://github.com/${REPO}/releases/latest/download/checksums.txt" TMP_DIR=$(mktemp -d) TMP_BINARY="${TMP_DIR}/${BINARY}" diff --git a/src/commands/UpdateCommand.ts b/src/commands/UpdateCommand.ts index 18b8009..e6d2e24 100644 --- a/src/commands/UpdateCommand.ts +++ b/src/commands/UpdateCommand.ts @@ -107,22 +107,14 @@ export class UpdateCommand { private static async runInstallScript(): Promise { const scriptUrl = - "https://github.com/jup-ag/cli/releases/latest/download/install.sh"; + "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], { - env: { - ...process.env, - JUP_INSTALL_QUIET: Output.isJson() ? "1" : "0", - }, - stdio: Output.isJson() - ? ["inherit", "ignore", "inherit"] - : "inherit", - }); + 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"