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..a5ff532 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"; @@ -34,7 +35,25 @@ async function main() { const program = new Command(); 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 +82,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..5251e63 --- /dev/null +++ b/src/lib/migrate.ts @@ -0,0 +1,131 @@ +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://docs.sfcompute.com/preview/guides/migrating-from-nodes"; + +export function showMigrateBanner() { + const message = `We've rewritten sf in Rust — faster, with new commands +like 'sf availability', 'sf capacities', and 'sf orders'. + +Migrating also opts you into our public preview, which +lets you resell unused compute back on our orderbook +and earn credits. + +Run 'sf migrate' to install it. Your current sf will +be moved to 'sf-old' so you can keep using it. + +Docs: ${MIGRATION_GUIDE_URL} +Hide: SF_CLI_DISABLE_MIGRATE_BANNER=1`; + + console.log( + boxen(chalk.cyan(message), { + padding: 1, + borderColor: "cyan", + borderStyle: "round", + }), + ); +} + +async function fetchInstallScript(): Promise { + const spinner = ora("Downloading install script").start(); + try { + const response = await fetch(NEW_CLI_INSTALL_URL); + if (!response.ok) { + spinner.fail("Failed to download install script."); + return null; + } + const script = await response.text(); + spinner.succeed(); + return script; + } catch (err) { + spinner.fail("Failed to download install script."); + console.error(err); + return null; + } +} + +async function runInstallScript(script: string): Promise { + 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 returning false. + 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}`)); + return false; + } + + return result.code === 0; +} + +export async function handleMigrate(): Promise { + const script = await fetchInstallScript(); + if (!script) return false; + + console.log(chalk.cyan("\nInstalling the new Rust sf CLI...\n")); + const ok = await runInstallScript(script); + if (!ok) { + console.error(chalk.red("\nMigration failed.")); + return false; + } + + 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 + +Migration guide: ${MIGRATION_GUIDE_URL}`, + ), + { + padding: 1, + borderColor: "cyan", + borderStyle: "round", + }, + ), + ); + return true; +} + +export function registerMigrate(program: Command) { + return program + .command("migrate") + .description("Install the new Rust-based sf CLI") + .action(async () => { + const success = await handleMigrate(); + process.exit(success ? 0 : 1); + }); +}