Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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}"
Expand Down
23 changes: 16 additions & 7 deletions src/checkVersion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
// 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";
Expand Down Expand Up @@ -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 = `
Expand All @@ -166,5 +172,8 @@ Run 'sf upgrade' to update to the latest version
borderStyle: "round",
}),
);
return true;
}

return false;
}
30 changes: 29 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -63,6 +90,7 @@ async function main() {
registerBalance(program);
registerTokens(program);
registerUpgrade(program);
registerMigrate(program);
await registerScale(program);
registerMe(program);
await registerVM(program);
Expand Down
124 changes: 124 additions & 0 deletions src/lib/migrate.ts
Original file line number Diff line number Diff line change
@@ -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<Error>((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);
});
}
12 changes: 11 additions & 1 deletion src/lib/upgrade.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 = "";
Expand Down
Loading