@@ -7,9 +7,10 @@ import ms from "ms";
77import console from "node:console" ;
88import process from "node:process" ;
99import { setTimeout } from "node:timers" ;
10- import dayjs from "npm:dayjs@1.11.13" ;
11- import duration from "npm:dayjs@1.11.13/plugin/duration.js" ;
12- import relativeTime from "npm:dayjs@1.11.13/plugin/relativeTime.js" ;
10+ import boxen from "npm:boxen@8.0.1" ;
11+ import dayjs from "dayjs" ;
12+ import duration from "dayjs/plugin/duration" ;
13+ import relativeTime from "dayjs/plugin/relativeTime" ;
1314import parseDurationFromLibrary from "parse-duration" ;
1415import React , { useCallback , useEffect , useState } from "react" ;
1516import invariant from "tiny-invariant" ;
@@ -34,10 +35,12 @@ import { Row } from "../Row.tsx";
3435import { GPUS_PER_NODE } from "../constants.ts" ;
3536import { parseAccelerators } from "../index.ts" ;
3637import { analytics } from "../posthog.ts" ;
38+ import { components } from "../../schema.ts" ;
3739
3840dayjs . extend ( relativeTime ) ;
3941dayjs . extend ( duration ) ;
4042
43+ type ZoneInfo = components [ "schemas" ] [ "node-api_ZoneInfo" ] ;
4144export type SfBuyOptions = ReturnType < ReturnType < typeof _registerBuy > [ "opts" ] > ;
4245
4346export function _registerBuy ( program : Command ) {
@@ -234,6 +237,7 @@ export function QuoteComponent(props: { options: SfBuyOptions }) {
234237
235238export function QuoteAndBuy ( props : { options : SfBuyOptions } ) {
236239 const [ orderProps , setOrderProps ] = useState < BuyOrderProps | null > ( null ) ;
240+ const [ zone , setZone ] = useState < ZoneInfo > ( ) ;
237241
238242 // submit a quote request, handle loading state
239243 useEffect ( ( ) => {
@@ -243,6 +247,7 @@ export function QuoteAndBuy(props: { options: SfBuyOptions }) {
243247 let pricePerGpuHour = parsePricePerGpuHour ( props . options . price ) ;
244248 let startAt = start ;
245249 let endsAt : Date ;
250+ let quoteZone : string | undefined ;
246251 const coercedStart = parseStartDate ( start ) ;
247252 if ( duration ) {
248253 // If duration is set, calculate end from start + duration
@@ -268,15 +273,34 @@ export function QuoteAndBuy(props: { options: SfBuyOptions }) {
268273 }
269274
270275 pricePerGpuHour = getPricePerGpuHourFromQuote ( quote ) ;
271-
272276 startAt = parseStartDateOrNow ( quote . start_at ) ;
273-
274277 endsAt = dayjs ( quote . end_at ) . toDate ( ) ;
278+ quoteZone = "zone" in quote ? quote . zone : undefined ;
275279 }
276280
277281 const { type, accelerators, colocate, yes, standing, cluster } =
278282 props . options ;
279283
284+ if ( cluster ) {
285+ const api = await apiClient ( ) ;
286+ const { data, error, response } = await api . GET ( `/v0/zones/{id}` , {
287+ params : {
288+ path : {
289+ id : cluster ,
290+ } ,
291+ } ,
292+ } ) ;
293+ if ( error ) {
294+ return logAndQuit (
295+ `Failed to get zone: ${ JSON . stringify ( error , null , 2 ) } ` ,
296+ ) ;
297+ }
298+ if ( ! response . ok ) {
299+ return logAndQuit ( `No zone found with slug: ${ cluster } ` ) ;
300+ }
301+ setZone ( data ) ;
302+ }
303+
280304 setOrderProps ( {
281305 type,
282306 price : pricePerGpuHour ,
@@ -286,7 +310,9 @@ export function QuoteAndBuy(props: { options: SfBuyOptions }) {
286310 yes,
287311 standing,
288312 colocate,
289- cluster,
313+ // If the user didn't specify a zone, use the zone from the quote
314+ // This helps prevent price surprises/location mismatches
315+ cluster : cluster ?? quoteZone ,
290316 } ) ;
291317 } ) ( ) ;
292318 } , [ props . options ] ) ;
@@ -300,7 +326,7 @@ export function QuoteAndBuy(props: { options: SfBuyOptions }) {
300326 </ Box >
301327 </ Box >
302328 )
303- : < BuyOrder { ...orderProps } /> ;
329+ : < BuyOrder { ...orderProps } zone = { zone } /> ;
304330}
305331
306332export function getTotalPrice (
@@ -429,10 +455,60 @@ type BuyOrderProps = {
429455 yes ?: boolean ;
430456 standing ?: boolean ;
431457 cluster ?: string ;
458+ zone ?: ZoneInfo ;
432459} ;
433460
461+ function VMWarning ( props : BuyOrderProps ) {
462+ const startDate = props . startAt === "NOW" ? dayjs ( ) : dayjs ( props . startAt ) ;
463+ const endDate = dayjs ( roundEndDate ( props . endsAt ) ) ;
464+ const realDuration = endDate . diff ( startDate ) ;
465+ const realDurationString = ms ( realDuration ) ;
466+
467+ // Build the equivalent sf nodes command
468+ let equivalentCommand = `sf nodes create -n ${ props . size } ` ;
469+
470+ if ( props . price ) {
471+ equivalentCommand += ` -p ${
472+ ( props . price * GPUS_PER_NODE / 100 ) . toFixed ( 2 )
473+ } `;
474+ }
475+ if ( props . startAt !== "NOW" ) {
476+ const startFormatted = startDate . toISOString ( ) ;
477+ equivalentCommand += ` -s "${ startFormatted } "` ;
478+ }
479+ equivalentCommand += ` -d ${ realDurationString } ` ;
480+ if ( props . yes ) {
481+ equivalentCommand += ` -y` ;
482+ }
483+ if ( props . cluster ) {
484+ equivalentCommand += ` -z ${ props . cluster } ` ;
485+ } else {
486+ // TODO: add support for any-zone
487+ // equivalentCommand += `--any-zone`;
488+ }
489+
490+ const warningMessage = boxen (
491+ `\x1b[31mWe're deprecating \x1b[97msf buy\x1b[31m for Virtual Machines.\x1b[0m
492+ \x1b[31mWe recommend you create a VM Node instead: \x1b[97m${ equivalentCommand } \x1b[0m
493+ \x1b[31m\x1b[97msf nodes\x1b[31m allows you to create, extend, and release specific machines directly.\x1b[0m` ,
494+ {
495+ padding : 0.75 ,
496+ borderColor : "red" ,
497+ } ,
498+ ) ;
499+
500+ return < Text > { warningMessage } </ Text > ;
501+ }
502+
434503function BuyOrder ( props : BuyOrderProps ) {
435504 const [ isLoading , setIsLoading ] = useState ( false ) ;
505+ const { type, zone } = props ;
506+ const isVM = type ?. endsWith ( "v" ) || zone ?. delivery_type === "VM" ;
507+ const [ vmWarningState , setVmWarningState ] = useState <
508+ "prompt" | "accepted" | "dismissed" | "not_applicable"
509+ > (
510+ isVM ? ( props . yes ? "accepted" : "prompt" ) : "not_applicable" ,
511+ ) ;
436512 const { exit } = useApp ( ) ;
437513 const [ order , setOrder ] = useState < Order | null > ( null ) ;
438514
@@ -522,6 +598,18 @@ function BuyOrder(props: BuyOrderProps) {
522598 [ props , exit , submitOrder ] ,
523599 ) ;
524600
601+ const handleDismissVMWarning = useCallback ( ( submitValue : boolean ) => {
602+ if ( ! submitValue ) {
603+ setIsLoading ( false ) ;
604+ setResultMessage (
605+ "VM order not placed. We recommend you use 'sf nodes create' instead." ,
606+ ) ;
607+ setTimeout ( ( ) => {
608+ exit ( ) ;
609+ } , 0 ) ;
610+ } else setVmWarningState ( "accepted" ) ;
611+ } , [ exit ] ) ;
612+
525613 useEffect ( ( ) => {
526614 if ( ! isLoading || ! order ?. id ) {
527615 return ;
@@ -554,9 +642,47 @@ function BuyOrder(props: BuyOrderProps) {
554642
555643 return (
556644 < Box gap = { 1 } flexDirection = "column" >
557- < MemoizedBuyOrderPreview { ...props } />
645+ { ( vmWarningState === "prompt" || vmWarningState === "accepted" ) && (
646+ < Box gap = { 0.5 } flexDirection = "column" >
647+ < VMWarning { ...props } />
648+ { vmWarningState === "prompt" && (
649+ < >
650+ < Text color = "red" >
651+ Place an order for a legacy VM anyway?{ " " }
652+ < Text color = "white" >
653+ (y/n)
654+ </ Text >
655+ </ Text >
656+
657+ < ConfirmInput
658+ isChecked = { false }
659+ onSubmit = { handleDismissVMWarning }
660+ />
661+ </ >
662+ ) }
663+ </ Box >
664+ ) }
665+
666+ { ( vmWarningState === "dismissed" || vmWarningState === "not_applicable" ||
667+ vmWarningState === "accepted" ) && (
668+ < MemoizedBuyOrderPreview
669+ { ...props }
670+ />
671+ ) }
558672
559- { ! isLoading && ! props . yes && (
673+ { vmWarningState === "accepted" && ! isLoading && ! props . yes && (
674+ < Box gap = { 1 } >
675+ < Text > Place order? (y/n)</ Text >
676+
677+ < ConfirmInput
678+ isChecked = { false }
679+ onSubmit = { handleSubmit }
680+ />
681+ </ Box >
682+ ) }
683+
684+ { ( vmWarningState === "dismissed" ||
685+ vmWarningState === "not_applicable" ) && ! isLoading && ! props . yes && (
560686 < Box gap = { 1 } >
561687 < Text > Place order? (y/n)</ Text >
562688
@@ -836,11 +962,13 @@ export async function getQuote(options: QuoteOptions) {
836962 if ( ! response . ok ) {
837963 switch ( response . status ) {
838964 case 400 :
839- return logAndQuit ( `Bad Request: ${ error } ` ) ;
965+ return logAndQuit ( `Bad Request: ${ JSON . stringify ( error , null , 2 ) } ` ) ;
840966 case 401 :
841967 return await logSessionTokenExpiredAndQuit ( ) ;
842968 case 500 :
843- return logAndQuit ( `Failed to get quote: ${ error } ` ) ;
969+ return logAndQuit (
970+ `Failed to get quote: ${ JSON . stringify ( error , null , 2 ) } ` ,
971+ ) ;
844972 default :
845973 return logAndQuit ( `Failed to get quote: ${ response . statusText } ` ) ;
846974 }
0 commit comments