Skip to content

Commit 06230a2

Browse files
sigmachiralityindentclaude
authored
feat: [PR-1797] add sf migrate command and Rust CLI migration banner (#266)
Co-authored-by: Indent <noreply@indent.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 785a6dc commit 06230a2

5 files changed

Lines changed: 199 additions & 12 deletions

File tree

install.sh

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,23 @@ set -e # Exit on any error
44

55
# Define the GitHub repository and the name of the binary.
66
GITHUB_REPO="sfcompute/cli"
7-
BINARY_NAME="sf"
7+
# Allow the caller to override the binary name / install dir so an in-place
8+
# upgrade lands wherever the existing binary actually lives (e.g. `sf-old`,
9+
# or an `sf` that's not under ~/.local/bin). The TS CLI sets these from
10+
# process.execPath when it shells out to this script.
11+
BINARY_NAME="${SF_CLI_BINARY_NAME:-sf}"
812

913
# Check the operating system
1014
OS="$(uname -s)"
1115
ARCH="$(uname -m)"
1216

13-
TARGET_DIR_UNEXPANDED="\${HOME}/.local/bin"
14-
TARGET_DIR="${HOME}/.local/bin"
17+
if [ -n "${SF_CLI_TARGET_DIR}" ]; then
18+
TARGET_DIR="${SF_CLI_TARGET_DIR}"
19+
TARGET_DIR_UNEXPANDED="${SF_CLI_TARGET_DIR}"
20+
else
21+
TARGET_DIR_UNEXPANDED="\${HOME}/.local/bin"
22+
TARGET_DIR="${HOME}/.local/bin"
23+
fi
1524

1625
# Function to check if a command exists
1726
command_exists() {
@@ -130,6 +139,13 @@ if [ -f "${TARGET_FILE}" ]; then
130139
echo "Successfully installed '${BINARY_NAME}' CLI."
131140
echo "The binary is located at '${TARGET_FILE}'."
132141

142+
# In-place upgrades (TARGET_DIR overridden by the caller) skip the PATH
143+
# onboarding nudge — the user is already running the binary, so they
144+
# obviously have it on PATH.
145+
if [ -n "${SF_CLI_TARGET_DIR}" ]; then
146+
exit 0
147+
fi
148+
133149
# Provide instructions for adding the target directory to the PATH.
134150
printf "\033[0;32m\\n"
135151
printf "To use the '%s' command, add '%s' to your PATH.\\n" "${BINARY_NAME}" "${TARGET_DIR_UNEXPANDED}"

src/checkVersion.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,30 +93,35 @@ async function checkProductionCLIVersion() {
9393
}
9494
}
9595

96-
export async function checkVersion() {
96+
/**
97+
* Returns true if an upgrade banner was shown (or an auto-upgrade was
98+
* performed), false otherwise. Callers can use this to decide whether to
99+
* show a different banner instead.
100+
*/
101+
export async function checkVersion(): Promise<boolean> {
97102
// Disable auto-upgrade if env var is set
98103
if (process.env.SF_CLI_DISABLE_AUTO_UPGRADE) {
99-
return;
104+
return false;
100105
}
101106

102107
// Skip version check if running upgrade command
103108
const args = process.argv.slice(2);
104-
if (args[0] === "upgrade") return;
109+
if (args[0] === "upgrade") return false;
105110

106111
const version = pkg.version;
107112
const latestVersion = await checkProductionCLIVersion();
108113

109-
if (!latestVersion) return;
114+
if (!latestVersion) return false;
110115

111-
if (version === latestVersion) return;
116+
if (version === latestVersion) return false;
112117

113118
// Don't upgrade from stable to prerelease
114119
const currentIsStable = !semver.prerelease(version);
115120
const latestIsPrerelease = semver.prerelease(latestVersion);
116-
if (currentIsStable && latestIsPrerelease) return;
121+
if (currentIsStable && latestIsPrerelease) return false;
117122

118123
const isOutdated = semver.lt(version, latestVersion);
119-
if (!isOutdated) return;
124+
if (!isOutdated) return false;
120125

121126
// Only auto-upgrade for patch changes and when not going to a prerelease
122127
const isPatchUpdate = semver.diff(version, latestVersion) === "patch";
@@ -149,6 +154,7 @@ export async function checkVersion() {
149154
} catch {
150155
// Silent error, just run the command the user wanted to run
151156
}
157+
return true;
152158
} else if (!latestIsPrerelease) {
153159
// Only show update message for non-prerelease versions
154160
const message = `
@@ -166,5 +172,8 @@ Run 'sf upgrade' to update to the latest version
166172
borderStyle: "round",
167173
}),
168174
);
175+
return true;
169176
}
177+
178+
return false;
170179
}

src/index.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { registerDev } from "./lib/dev.ts";
2222
import { registerImages } from "./lib/images/index.ts";
2323
import { registerLogin } from "./lib/login.ts";
2424
import { registerMe } from "./lib/me.ts";
25+
import { registerMigrate, showMigrateBanner } from "./lib/migrate.ts";
2526
import { registerNodes } from "./lib/nodes/index.ts";
2627
import { analytics, IS_TRACKING_DISABLED } from "./lib/posthog.ts";
2728
import { registerScale } from "./lib/scale/index.tsx";
@@ -33,8 +34,34 @@ import { registerZones } from "./lib/zones.tsx";
3334
async function main() {
3435
const program = new Command();
3536

37+
// `sf migrate` replaces this binary outright, so auto-upgrading the legacy
38+
// CLI first would be wasted work — and worse, the install scripts target
39+
// the same `~/.local/bin/sf` path, so racing them risks clobbering the new
40+
// Rust binary the user is about to install.
41+
if (process.argv[2] === "migrate") {
42+
process.env.SF_CLI_DISABLE_AUTO_UPGRADE = "1";
43+
}
44+
3645
if (!process.argv.includes("--json")) {
37-
await Promise.all([checkVersion(), getAppBanner()]);
46+
const [shownUpgradeBanner] = await Promise.all([
47+
checkVersion(),
48+
getAppBanner(),
49+
]);
50+
// If the user is already on the latest version of the legacy CLI, nudge
51+
// them toward the new Rust CLI instead of showing nothing. We avoid
52+
// double-stacking with the upgrade banner since users on outdated builds
53+
// need to upgrade before migrating, and skip the banner for the
54+
// `upgrade` / `migrate` commands themselves (where it'd just be noise)
55+
// and for users who've opted out via SF_CLI_DISABLE_MIGRATE_BANNER.
56+
const subcommand = process.argv[2];
57+
if (
58+
!shownUpgradeBanner &&
59+
subcommand !== "migrate" &&
60+
subcommand !== "upgrade" &&
61+
!process.env.SF_CLI_DISABLE_MIGRATE_BANNER
62+
) {
63+
showMigrateBanner();
64+
}
3865
}
3966

4067
program
@@ -63,6 +90,7 @@ async function main() {
6390
registerBalance(program);
6491
registerTokens(program);
6592
registerUpgrade(program);
93+
registerMigrate(program);
6694
await registerScale(program);
6795
registerMe(program);
6896
await registerVM(program);

src/lib/migrate.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { spawn } from "node:child_process";
2+
import * as console from "node:console";
3+
import process from "node:process";
4+
import type { Command } from "@commander-js/extra-typings";
5+
import boxen from "boxen";
6+
import chalk from "chalk";
7+
import ora from "ora";
8+
9+
const NEW_CLI_INSTALL_URL = "https://cli.sfcompute.com";
10+
const MIGRATION_GUIDE_URL = "https://sfcompute.com/migrate";
11+
12+
export function showMigrateBanner() {
13+
const message = `We've rewritten the sf CLI in Rust.
14+
15+
List idle capacity on the orderbook to
16+
recoup up to 20% of your spend.
17+
18+
Run 'sf migrate' to switch. Your current
19+
CLI stays as 'sf-old'.
20+
21+
Docs: ${MIGRATION_GUIDE_URL}
22+
Hide: SF_CLI_DISABLE_MIGRATE_BANNER=1`;
23+
24+
console.log(
25+
boxen(chalk.yellow(message), {
26+
padding: 1,
27+
borderColor: "yellow",
28+
borderStyle: "round",
29+
}),
30+
);
31+
}
32+
33+
export function registerMigrate(program: Command) {
34+
return program
35+
.command("migrate")
36+
.description("Install the new Rust-based sf CLI")
37+
.action(async () => {
38+
const spinner = ora("Downloading install script").start();
39+
let script: string;
40+
try {
41+
const response = await fetch(NEW_CLI_INSTALL_URL);
42+
if (!response.ok) {
43+
spinner.fail("Failed to download install script.");
44+
process.exit(1);
45+
}
46+
script = await response.text();
47+
spinner.succeed();
48+
} catch (err) {
49+
spinner.fail("Failed to download install script.");
50+
console.error(err);
51+
process.exit(1);
52+
}
53+
54+
console.log(chalk.cyan("\nInstalling the new Rust sf CLI...\n"));
55+
56+
if (process.env.IS_DEVELOPMENT_CLI_ENV) {
57+
console.log(
58+
chalk.yellow(
59+
"[dev] Skipping install script execution (IS_DEVELOPMENT_CLI_ENV).\n",
60+
),
61+
);
62+
} else {
63+
const bashProcess = spawn("bash", [], {
64+
stdio: ["pipe", "inherit", "inherit"],
65+
env: process.env,
66+
});
67+
68+
// Without an error listener, spawn failures (ENOENT/EACCES on bash) emit
69+
// an unhandled 'error' event and crash the CLI instead of exiting cleanly.
70+
const spawnError = new Promise<Error>((resolve) => {
71+
bashProcess.once("error", resolve);
72+
});
73+
74+
try {
75+
bashProcess.stdin.write(script);
76+
bashProcess.stdin.end();
77+
} catch {
78+
// If stdin is already torn down (e.g. spawn failed synchronously), the
79+
// 'error' event handler below will surface the real reason.
80+
}
81+
82+
const result = await Promise.race([
83+
new Promise<{ kind: "close"; code: number | null }>((resolve) => {
84+
bashProcess.once("close", (code) =>
85+
resolve({ kind: "close", code }),
86+
);
87+
}),
88+
spawnError.then((err) => ({ kind: "error" as const, err })),
89+
]);
90+
91+
if (result.kind === "error") {
92+
console.error(chalk.red(`Failed to run bash: ${result.err.message}`));
93+
process.exit(1);
94+
}
95+
96+
if (result.code !== 0) {
97+
console.error(chalk.red("\nMigration failed."));
98+
process.exit(1);
99+
}
100+
}
101+
102+
console.log(
103+
boxen(
104+
chalk.cyan(
105+
`You're on the new sf.
106+
107+
Your previous CLI is still available as 'sf-old'.
108+
109+
Next steps:
110+
sf login
111+
sf availability
112+
113+
Docs: ${MIGRATION_GUIDE_URL}`,
114+
),
115+
{
116+
padding: 1,
117+
borderColor: "cyan",
118+
borderStyle: "round",
119+
},
120+
),
121+
);
122+
process.exit(0);
123+
});
124+
}

src/lib/upgrade.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { spawn } from "node:child_process";
22
import * as console from "node:console";
3+
import { basename, dirname } from "node:path";
34
import process from "node:process";
45
import type { Command } from "@commander-js/extra-typings";
56
import ora from "ora";
@@ -70,9 +71,18 @@ export async function handleUpgrade(
7071
// Execute the script with bash
7172
spinner.start("Installing upgrade");
7273

74+
// Tell the install script to write back to this exact binary's path. Without
75+
// this, the installer hardcodes ~/.local/bin/sf — which would clobber the
76+
// Rust `sf` if we're running as `sf-old`, and would silently drop a
77+
// duplicate copy when `sf` is installed somewhere else (e.g. /usr/local/bin).
7378
const bashProcess = spawn("bash", [], {
7479
stdio: ["pipe", "pipe", "pipe"],
75-
env: version ? { ...process.env, SF_CLI_VERSION: version } : process.env,
80+
env: {
81+
...process.env,
82+
...(version ? { SF_CLI_VERSION: version } : {}),
83+
SF_CLI_TARGET_DIR: dirname(process.execPath),
84+
SF_CLI_BINARY_NAME: basename(process.execPath),
85+
},
7686
});
7787

7888
let stdout = "";

0 commit comments

Comments
 (0)