From d0b679a9d39e31a00853e53d2c262e1bb4632577 Mon Sep 17 00:00:00 2001 From: Daniel Tao Date: Thu, 21 May 2026 22:09:29 +0000 Subject: [PATCH 1/4] feat: add sf migrate command and Rust CLI migration banner Show a cyan migration banner when the upgrade banner isn't being shown to nudge users toward the new Rust-based sf CLI, and add a `sf migrate` command that runs the new install script. Generated with [Indent](https://indent.com) Co-Authored-By: Indent --- src/checkVersion.ts | 23 +++++++--- src/index.ts | 14 +++++- src/lib/migrate.ts | 109 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 src/lib/migrate.ts diff --git a/src/checkVersion.ts b/src/checkVersion.ts index 64b68ecc..173169ea 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 9c73532a..5a381c4b 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,17 @@ 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. + if (!shownUpgradeBanner && !process.argv.slice(2).includes("migrate")) { + showMigrateBanner(); + } } program @@ -63,6 +74,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 00000000..1a6440de --- /dev/null +++ b/src/lib/migrate.ts @@ -0,0 +1,109 @@ +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 = `A new sf is here. + +We're moving to a Rust CLI with new commands like +'sf availability', 'sf capacities', and 'sf orders'. + +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}`; + + 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, + }); + + bashProcess.stdin.write(script); + bashProcess.stdin.end(); + + const code = await new Promise((resolve) => { + bashProcess.on("close", resolve); + }); + + return 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); + }); +} From 8f91f5f28678209e9d6d599bd549a4d0fb04019d Mon Sep 17 00:00:00 2001 From: Daniel Tao Date: Thu, 21 May 2026 22:13:33 +0000 Subject: [PATCH 2/4] fix: address review feedback on migrate banner + command - Skip migrate banner when running `sf upgrade` (was showing above upgrade output because checkVersion returns false early for it) - Add SF_CLI_DISABLE_MIGRATE_BANNER opt-out, surfaced in the banner - Use process.argv[2] === "migrate" for tighter scoping, matching the existing pattern in checkVersion.ts - Handle spawn errors in handleMigrate so ENOENT/EACCES on bash surface as a clean failure instead of crashing the CLI Generated with [Indent](https://indent.com) Co-Authored-By: Indent --- src/index.ts | 12 ++++++++++-- src/lib/migrate.ts | 34 +++++++++++++++++++++++++++------- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index 5a381c4b..a5ff5320 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,8 +42,16 @@ async function main() { // 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. - if (!shownUpgradeBanner && !process.argv.slice(2).includes("migrate")) { + // 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(); } } diff --git a/src/lib/migrate.ts b/src/lib/migrate.ts index 1a6440de..d29adb57 100644 --- a/src/lib/migrate.ts +++ b/src/lib/migrate.ts @@ -19,7 +19,8 @@ We're moving to a Rust CLI with new commands like 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}`; +Docs: ${MIGRATION_GUIDE_URL} +Hide: SF_CLI_DISABLE_MIGRATE_BANNER=1`; console.log( boxen(chalk.cyan(message), { @@ -54,14 +55,33 @@ async function runInstallScript(script: string): Promise { env: process.env, }); - bashProcess.stdin.write(script); - bashProcess.stdin.end(); - - const code = await new Promise((resolve) => { - bashProcess.on("close", resolve); + // 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); }); - return code === 0; + 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 { From a9e78ed982bbc725f695042391a07a61ef7c1d25 Mon Sep 17 00:00:00 2001 From: Daniel Tao Date: Thu, 21 May 2026 22:17:26 +0000 Subject: [PATCH 3/4] copy: rewrite migrate banner around reselling value prop Lead with the Rust rewrite and new commands, then surface the reselling story (the most important reason to migrate, per the April 7 changelog) before the action and fallback text. Generated with [Indent](https://indent.com) Co-Authored-By: Indent --- src/lib/migrate.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/lib/migrate.ts b/src/lib/migrate.ts index d29adb57..b3ac73d7 100644 --- a/src/lib/migrate.ts +++ b/src/lib/migrate.ts @@ -11,10 +11,12 @@ const MIGRATION_GUIDE_URL = "https://docs.sfcompute.com/preview/guides/migrating-from-nodes"; export function showMigrateBanner() { - const message = `A new sf is here. + const message = `We've rewritten sf in Rust — faster, with new commands +like 'sf availability', 'sf capacities', and 'sf orders'. -We're moving to a Rust CLI 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 to the market and +earn credits. Run 'sf migrate' to install it. Your current sf will be moved to 'sf-old' so you can keep using it. From dd5892371432f4ced8697ed9eb945183c85eaebf Mon Sep 17 00:00:00 2001 From: Daniel Tao Date: Thu, 21 May 2026 22:21:15 +0000 Subject: [PATCH 4/4] copy: "back to the market" -> "back on our orderbook" Per DTao. Generated with [Indent](https://indent.com) Co-Authored-By: Indent --- src/lib/migrate.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/migrate.ts b/src/lib/migrate.ts index b3ac73d7..5251e633 100644 --- a/src/lib/migrate.ts +++ b/src/lib/migrate.ts @@ -15,8 +15,8 @@ export function showMigrateBanner() { like 'sf availability', 'sf capacities', and 'sf orders'. Migrating also opts you into our public preview, which -lets you resell unused compute back to the market and -earn credits. +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.