diff --git a/install.sh b/install.sh index d2af927..d0292c5 100755 --- a/install.sh +++ b/install.sh @@ -4,14 +4,23 @@ set -e # Exit on any error # Define the GitHub repository and the name of the binary. GITHUB_REPO="sfcompute/cli" -BINARY_NAME="sf" +# Allow the caller to override the binary name / install dir so an in-place +# upgrade lands wherever the existing binary actually lives (e.g. `sf-old`, +# or an `sf` that's not under ~/.local/bin). The TS CLI sets these from +# process.execPath when it shells out to this script. +BINARY_NAME="${SF_CLI_BINARY_NAME:-sf}" # Check the operating system OS="$(uname -s)" ARCH="$(uname -m)" -TARGET_DIR_UNEXPANDED="\${HOME}/.local/bin" -TARGET_DIR="${HOME}/.local/bin" +if [ -n "${SF_CLI_TARGET_DIR}" ]; then + TARGET_DIR="${SF_CLI_TARGET_DIR}" + TARGET_DIR_UNEXPANDED="${SF_CLI_TARGET_DIR}" +else + TARGET_DIR_UNEXPANDED="\${HOME}/.local/bin" + TARGET_DIR="${HOME}/.local/bin" +fi # Function to check if a command exists command_exists() { @@ -130,6 +139,13 @@ if [ -f "${TARGET_FILE}" ]; then echo "Successfully installed '${BINARY_NAME}' CLI." echo "The binary is located at '${TARGET_FILE}'." + # In-place upgrades (TARGET_DIR overridden by the caller) skip the PATH + # onboarding nudge — the user is already running the binary, so they + # obviously have it on PATH. + if [ -n "${SF_CLI_TARGET_DIR}" ]; then + exit 0 + fi + # Provide instructions for adding the target directory to the PATH. printf "\033[0;32m\\n" printf "To use the '%s' command, add '%s' to your PATH.\\n" "${BINARY_NAME}" "${TARGET_DIR_UNEXPANDED}" diff --git a/src/checkVersion.ts b/src/checkVersion.ts index 64b68ec..173169e 100644 --- a/src/checkVersion.ts +++ b/src/checkVersion.ts @@ -93,30 +93,35 @@ async function checkProductionCLIVersion() { } } -export async function checkVersion() { +/** + * Returns true if an upgrade banner was shown (or an auto-upgrade was + * performed), false otherwise. Callers can use this to decide whether to + * show a different banner instead. + */ +export async function checkVersion(): Promise { // Disable auto-upgrade if env var is set if (process.env.SF_CLI_DISABLE_AUTO_UPGRADE) { - return; + return false; } // Skip version check if running upgrade command const args = process.argv.slice(2); - if (args[0] === "upgrade") return; + if (args[0] === "upgrade") return false; const version = pkg.version; const latestVersion = await checkProductionCLIVersion(); - if (!latestVersion) return; + if (!latestVersion) return false; - if (version === latestVersion) return; + if (version === latestVersion) return false; // Don't upgrade from stable to prerelease const currentIsStable = !semver.prerelease(version); const latestIsPrerelease = semver.prerelease(latestVersion); - if (currentIsStable && latestIsPrerelease) return; + if (currentIsStable && latestIsPrerelease) return false; const isOutdated = semver.lt(version, latestVersion); - if (!isOutdated) return; + if (!isOutdated) return false; // Only auto-upgrade for patch changes and when not going to a prerelease const isPatchUpdate = semver.diff(version, latestVersion) === "patch"; @@ -149,6 +154,7 @@ export async function checkVersion() { } catch { // Silent error, just run the command the user wanted to run } + return true; } else if (!latestIsPrerelease) { // Only show update message for non-prerelease versions const message = ` @@ -166,5 +172,8 @@ Run 'sf upgrade' to update to the latest version borderStyle: "round", }), ); + return true; } + + return false; } diff --git a/src/index.ts b/src/index.ts index 9c73532..8612401 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ import { registerDev } from "./lib/dev.ts"; import { registerImages } from "./lib/images/index.ts"; import { registerLogin } from "./lib/login.ts"; import { registerMe } from "./lib/me.ts"; +import { registerMigrate, showMigrateBanner } from "./lib/migrate.ts"; import { registerNodes } from "./lib/nodes/index.ts"; import { analytics, IS_TRACKING_DISABLED } from "./lib/posthog.ts"; import { registerScale } from "./lib/scale/index.tsx"; @@ -33,8 +34,34 @@ import { registerZones } from "./lib/zones.tsx"; async function main() { const program = new Command(); + // `sf migrate` replaces this binary outright, so auto-upgrading the legacy + // CLI first would be wasted work — and worse, the install scripts target + // the same `~/.local/bin/sf` path, so racing them risks clobbering the new + // Rust binary the user is about to install. + if (process.argv[2] === "migrate") { + process.env.SF_CLI_DISABLE_AUTO_UPGRADE = "1"; + } + if (!process.argv.includes("--json")) { - await Promise.all([checkVersion(), getAppBanner()]); + const [shownUpgradeBanner] = await Promise.all([ + checkVersion(), + getAppBanner(), + ]); + // If the user is already on the latest version of the legacy CLI, nudge + // them toward the new Rust CLI instead of showing nothing. We avoid + // double-stacking with the upgrade banner since users on outdated builds + // need to upgrade before migrating, and skip the banner for the + // `upgrade` / `migrate` commands themselves (where it'd just be noise) + // and for users who've opted out via SF_CLI_DISABLE_MIGRATE_BANNER. + const subcommand = process.argv[2]; + if ( + !shownUpgradeBanner && + subcommand !== "migrate" && + subcommand !== "upgrade" && + !process.env.SF_CLI_DISABLE_MIGRATE_BANNER + ) { + showMigrateBanner(); + } } program @@ -63,6 +90,7 @@ async function main() { registerBalance(program); registerTokens(program); registerUpgrade(program); + registerMigrate(program); await registerScale(program); registerMe(program); await registerVM(program); diff --git a/src/lib/migrate.ts b/src/lib/migrate.ts new file mode 100644 index 0000000..e2bce95 --- /dev/null +++ b/src/lib/migrate.ts @@ -0,0 +1,124 @@ +import { spawn } from "node:child_process"; +import * as console from "node:console"; +import process from "node:process"; +import type { Command } from "@commander-js/extra-typings"; +import boxen from "boxen"; +import chalk from "chalk"; +import ora from "ora"; + +const NEW_CLI_INSTALL_URL = "https://cli.sfcompute.com"; +const MIGRATION_GUIDE_URL = "https://sfcompute.com/migrate"; + +export function showMigrateBanner() { + const message = `We've rewritten the sf CLI in Rust. + +List idle capacity on the orderbook to +recoup up to 20% of your spend. + +Run 'sf migrate' to switch. Your current +CLI stays as 'sf-old'. + +Docs: ${MIGRATION_GUIDE_URL} +Hide: SF_CLI_DISABLE_MIGRATE_BANNER=1`; + + console.log( + boxen(chalk.yellow(message), { + padding: 1, + borderColor: "yellow", + borderStyle: "round", + }), + ); +} + +export function registerMigrate(program: Command) { + return program + .command("migrate") + .description("Install the new Rust-based sf CLI") + .action(async () => { + const spinner = ora("Downloading install script").start(); + let script: string; + try { + const response = await fetch(NEW_CLI_INSTALL_URL); + if (!response.ok) { + spinner.fail("Failed to download install script."); + process.exit(1); + } + script = await response.text(); + spinner.succeed(); + } catch (err) { + spinner.fail("Failed to download install script."); + console.error(err); + process.exit(1); + } + + console.log(chalk.cyan("\nInstalling the new Rust sf CLI...\n")); + + if (process.env.IS_DEVELOPMENT_CLI_ENV) { + console.log( + chalk.yellow( + "[dev] Skipping install script execution (IS_DEVELOPMENT_CLI_ENV).\n", + ), + ); + } else { + const bashProcess = spawn("bash", [], { + stdio: ["pipe", "inherit", "inherit"], + env: process.env, + }); + + // Without an error listener, spawn failures (ENOENT/EACCES on bash) emit + // an unhandled 'error' event and crash the CLI instead of exiting cleanly. + const spawnError = new Promise((resolve) => { + bashProcess.once("error", resolve); + }); + + try { + bashProcess.stdin.write(script); + bashProcess.stdin.end(); + } catch { + // If stdin is already torn down (e.g. spawn failed synchronously), the + // 'error' event handler below will surface the real reason. + } + + const result = await Promise.race([ + new Promise<{ kind: "close"; code: number | null }>((resolve) => { + bashProcess.once("close", (code) => + resolve({ kind: "close", code }), + ); + }), + spawnError.then((err) => ({ kind: "error" as const, err })), + ]); + + if (result.kind === "error") { + console.error(chalk.red(`Failed to run bash: ${result.err.message}`)); + process.exit(1); + } + + if (result.code !== 0) { + console.error(chalk.red("\nMigration failed.")); + process.exit(1); + } + } + + console.log( + boxen( + chalk.cyan( + `You're on the new sf. + +Your previous CLI is still available as 'sf-old'. + +Next steps: + sf login + sf availability + +Docs: ${MIGRATION_GUIDE_URL}`, + ), + { + padding: 1, + borderColor: "cyan", + borderStyle: "round", + }, + ), + ); + process.exit(0); + }); +} diff --git a/src/lib/upgrade.ts b/src/lib/upgrade.ts index ac3a554..5edebd1 100644 --- a/src/lib/upgrade.ts +++ b/src/lib/upgrade.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; import * as console from "node:console"; +import { basename, dirname } from "node:path"; import process from "node:process"; import type { Command } from "@commander-js/extra-typings"; import ora from "ora"; @@ -70,9 +71,18 @@ export async function handleUpgrade( // Execute the script with bash spinner.start("Installing upgrade"); + // Tell the install script to write back to this exact binary's path. Without + // this, the installer hardcodes ~/.local/bin/sf — which would clobber the + // Rust `sf` if we're running as `sf-old`, and would silently drop a + // duplicate copy when `sf` is installed somewhere else (e.g. /usr/local/bin). const bashProcess = spawn("bash", [], { stdio: ["pipe", "pipe", "pipe"], - env: version ? { ...process.env, SF_CLI_VERSION: version } : process.env, + env: { + ...process.env, + ...(version ? { SF_CLI_VERSION: version } : {}), + SF_CLI_TARGET_DIR: dirname(process.execPath), + SF_CLI_BINARY_NAME: basename(process.execPath), + }, }); let stdout = "";