From 376b258a8f61facb6be6eadc993c1e3534f1e921 Mon Sep 17 00:00:00 2001 From: Gaelan Chen Date: Mon, 9 Feb 2026 18:27:31 -0800 Subject: [PATCH 1/4] remove-buy-sell-orders Co-Authored-By: Claude Opus 4.6 --- src/helpers/fetchers.ts | 17 - src/helpers/format-duration.ts | 28 + src/helpers/quote.ts | 122 +++ src/helpers/urls.ts | 4 - src/index.ts | 6 - src/lib/Quote.tsx | 55 -- src/lib/buy/index.tsx | 1011 ------------------------- src/lib/extend/index.tsx | 36 +- src/lib/index.ts | 16 - src/lib/nodes/create.ts | 2 +- src/lib/nodes/extend.ts | 2 +- src/lib/orders/OrderDisplay.tsx | 2 +- src/lib/orders/index.tsx | 362 --------- src/lib/scale/ConfirmationMessage.tsx | 2 +- src/lib/scale/ProcurementDisplay.tsx | 2 +- src/lib/scale/create.tsx | 2 +- src/lib/sell.ts | 182 ----- src/lib/sell/index.tsx | 423 ----------- 18 files changed, 158 insertions(+), 2116 deletions(-) create mode 100644 src/helpers/format-duration.ts create mode 100644 src/helpers/quote.ts delete mode 100644 src/lib/Quote.tsx delete mode 100644 src/lib/buy/index.tsx delete mode 100644 src/lib/orders/index.tsx delete mode 100644 src/lib/sell.ts delete mode 100644 src/lib/sell/index.tsx diff --git a/src/helpers/fetchers.ts b/src/helpers/fetchers.ts index 69629f8d..7f67c091 100644 --- a/src/helpers/fetchers.ts +++ b/src/helpers/fetchers.ts @@ -13,20 +13,3 @@ export async function getContract(contractId: string) { } return data; } - -export async function getOrder(orderId: string) { - const api = await apiClient(); - const { data, response, error } = await api.GET("/v0/orders/{id}", { - params: { - path: { id: orderId }, - }, - }); - if (!response.ok) { - // @ts-expect-error -- TODO: FIXME: include error in OpenAPI schema output - if (error?.code === "order.not_found") { - return null; - } - return logAndQuit(`Failed to get order: ${response.statusText}`); - } - return data; -} diff --git a/src/helpers/format-duration.ts b/src/helpers/format-duration.ts new file mode 100644 index 00000000..c6e23ae6 --- /dev/null +++ b/src/helpers/format-duration.ts @@ -0,0 +1,28 @@ +import dayjs from "dayjs"; +import duration from "dayjs/plugin/duration"; + +dayjs.extend(duration); + +export function formatDuration(ms: number) { + const d = dayjs.duration(ms); + + const years = Math.floor(d.asYears()); + const weeks = Math.floor(d.asWeeks()) % 52; + const days = d.days(); + const hours = d.hours(); + const minutes = d.minutes(); + const seconds = d.seconds(); + const milliseconds = d.milliseconds(); + + let result = ""; + + if (years > 0) result += `${years}y`; + if (weeks > 0) result += `${weeks}w`; + if (days > 0) result += `${days}d`; + if (hours > 0) result += `${hours}h`; + if (minutes > 0) result += `${minutes}m`; + if (seconds > 0) result += `${seconds}s`; + if (milliseconds > 0) result += `${milliseconds}ms`; + + return result || "0ms"; +} diff --git a/src/helpers/quote.ts b/src/helpers/quote.ts new file mode 100644 index 00000000..312d44a4 --- /dev/null +++ b/src/helpers/quote.ts @@ -0,0 +1,122 @@ +import dayjs from "dayjs"; +import { apiClient } from "../apiClient.ts"; +import { + logAndQuit, + logSessionTokenExpiredAndQuit, +} from "./errors.ts"; +import { + parseStartDateOrNow, + roundDateUpToNextMinute, +} from "./units.ts"; +import { GPUS_PER_NODE } from "../lib/constants.ts"; + +export function getPricePerGpuHourFromQuote( + quote: Pick, "start_at" | "end_at" | "price" | "quantity">, +) { + const startTimeOrNow = parseStartDateOrNow(quote.start_at); + + // from the market's perspective, "NOW" means at the beginning of the next minute. + // when the order duration is very short, this can cause the rate to be computed incorrectly + // if we implicitly assume it to mean `new Date()`. + const coercedStartTime = + startTimeOrNow === "NOW" + ? roundDateUpToNextMinute(new Date()) + : startTimeOrNow; + const durationSeconds = dayjs(quote.end_at).diff(dayjs(coercedStartTime)); + const durationHours = durationSeconds / 3600 / 1000; + + return quote.price / GPUS_PER_NODE / quote.quantity / durationHours; +} + +type QuoteOptions = { + instanceType?: string; + quantity: number; + minStartTime: Date | "NOW"; + maxStartTime: Date | "NOW"; + minDurationSeconds: number; + maxDurationSeconds: number; + cluster?: string; + colocateWith?: string; +}; + +export async function getQuote(options: QuoteOptions) { + const api = await apiClient(); + + const params = { + query: { + side: "buy", + instance_type: options.instanceType, + quantity: options.quantity, + min_start_date: + options.minStartTime === "NOW" + ? ("NOW" as const) + : options.minStartTime.toISOString(), + max_start_date: + options.maxStartTime === "NOW" + ? ("NOW" as const) + : options.maxStartTime.toISOString(), + min_duration: options.minDurationSeconds, + max_duration: options.maxDurationSeconds, + cluster: options.cluster, + colocate_with: options.colocateWith, + }, + } as const; + + const { data, error, response } = await api.GET("/v0/quote", { + params, + // timeout after 600 seconds + signal: AbortSignal.timeout(600 * 1000), + }); + + if (!response.ok) { + switch (response.status) { + case 400: + return logAndQuit(`Bad Request: ${JSON.stringify(error, null, 2)}`); + case 401: + return await logSessionTokenExpiredAndQuit(); + case 500: + return logAndQuit( + `Failed to get quote: ${JSON.stringify(error, null, 2)}`, + ); + default: + return logAndQuit(`Failed to get quote: ${response.statusText}`); + } + } + + if (!data) { + return logAndQuit( + `Failed to get quote: Unexpected response from server: ${response}`, + ); + } + + if (!data.quote) { + return null; + } + + return { + ...data.quote, + price: Number(data.quote.price), + quantity: Number(data.quote.quantity), + start_at: data.quote.start_at, + end_at: data.quote.end_at, + }; +} + +export type Quote = + | { + price: number; + quantity: number; + start_at: string; + end_at: string; + instance_type: string; + zone?: string; + } + | { + price: number; + quantity: number; + start_at: string; + end_at: string; + contract_id: string; + zone?: string; + } + | null; diff --git a/src/helpers/urls.ts b/src/helpers/urls.ts index ef7b031b..fd8a30d8 100644 --- a/src/helpers/urls.ts +++ b/src/helpers/urls.ts @@ -22,10 +22,6 @@ const apiPaths: Record> = { me: "/v0/me", ping: "/v0/ping", - orders_list: "/v0/orders", - orders_get: ({ id }: IdParams): string => `/v0/orders/${id}`, - orders_cancel: ({ id }: IdParams): string => `/v0/orders/${id}`, - quote_get: "/v0/quote", instances_list: "/v0/instances", diff --git a/src/index.ts b/src/index.ts index ebfce211..9c15f08f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,17 +17,14 @@ import { checkVersion } from "./checkVersion.ts"; import { loadConfig, saveConfig } from "./helpers/config.ts"; import { getAppBanner } from "./lib/app-banner.ts"; import { registerBalance } from "./lib/balance.ts"; -import { registerBuy } from "./lib/buy/index.tsx"; import { registerContracts } from "./lib/contracts/index.tsx"; import { registerDev } from "./lib/dev.ts"; import { registerExtend } from "./lib/extend/index.tsx"; import { registerLogin } from "./lib/login.ts"; import { registerMe } from "./lib/me.ts"; import { registerNodes } from "./lib/nodes/index.ts"; -import { registerOrders } from "./lib/orders/index.tsx"; import { analytics, IS_TRACKING_DISABLED } from "./lib/posthog.ts"; import { registerScale } from "./lib/scale/index.tsx"; -import { registerSell } from "./lib/sell.ts"; import { registerTokens } from "./lib/tokens.ts"; import { registerUpgrade } from "./lib/upgrade.ts"; import { registerVM } from "./lib/vm/index.ts"; @@ -47,11 +44,8 @@ async function main() { // commands registerLogin(program); - registerBuy(program); registerExtend(program); - registerOrders(program); registerContracts(program); - registerSell(program); registerBalance(program); registerTokens(program); registerUpgrade(program); diff --git a/src/lib/Quote.tsx b/src/lib/Quote.tsx deleted file mode 100644 index 517bd3c1..00000000 --- a/src/lib/Quote.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Box, Text } from "ink"; -import { getPricePerGpuHourFromQuote } from "./buy/index.tsx"; -import { Row } from "./Row.tsx"; - -export default function QuoteDisplay(props: { quote: Quote }) { - if (!props.quote) { - return ( - - - No quote available for this configuration. That doesn't mean it's not - available, but you'll need to give a price you're willing to pay for - it. - - - # Place an order with a price - sf buy --price "2.50" - - - ); - } - - const pricePerGpuHourCents = getPricePerGpuHourFromQuote(props.quote); - const totalPrice = props.quote.price / 100; - - return ( - - - - - - ); -} - -export type Quote = - | { - price: number; - quantity: number; - start_at: string; - end_at: string; - instance_type: string; - zone?: string; - } - | { - price: number; - quantity: number; - start_at: string; - end_at: string; - contract_id: string; - zone?: string; - } - | null; diff --git a/src/lib/buy/index.tsx b/src/lib/buy/index.tsx deleted file mode 100644 index f82bdd29..00000000 --- a/src/lib/buy/index.tsx +++ /dev/null @@ -1,1011 +0,0 @@ -import console from "node:console"; -import process from "node:process"; -import { setTimeout } from "node:timers"; -import { type Command, Option } from "@commander-js/extra-typings"; -import boxen from "boxen"; -import chalk from "chalk"; -import { parseDate } from "chrono-node"; -import dayjs from "dayjs"; -import duration from "dayjs/plugin/duration"; -import relativeTime from "dayjs/plugin/relativeTime"; -import { Box, render, Text, useApp } from "ink"; -import Spinner from "ink-spinner"; -import ms from "ms"; -import parseDurationFromLibrary from "parse-duration"; -import React, { useCallback, useEffect, useState } from "react"; -import invariant from "tiny-invariant"; -import { apiClient } from "../../apiClient.ts"; -import { - logAndQuit, - logSessionTokenExpiredAndQuit, -} from "../../helpers/errors.ts"; -import { InstanceTypeMetadata } from "../../helpers/instance-types-meta.ts"; -import { - centsToDollarsFormatted, - parseStartDate, - parseStartDateOrNow, - roundDateUpToNextMinute, - roundEndDate, - roundStartDate, -} from "../../helpers/units.ts"; -import type { components } from "../../schema.ts"; -import ConfirmInput from "../ConfirmInput.tsx"; -import { GPUS_PER_NODE } from "../constants.ts"; -import { parseAccelerators } from "../index.ts"; -import { analytics } from "../posthog.ts"; -import type { Quote } from "../Quote.tsx"; -import QuoteDisplay from "../Quote.tsx"; -import { Row } from "../Row.tsx"; - -dayjs.extend(relativeTime); -dayjs.extend(duration); - -type ZoneInfo = components["schemas"]["node-api_ZoneInfo"]; -export type SfBuyOptions = ReturnType["opts"]>; - -export function _registerBuy(program: Command) { - return program - .command("buy") - .description("Place a buy order") - .showHelpAfterError() - .option("-t, --type ", "Type of GPU") - .option( - "-n, --accelerators ", - "Number of GPUs to purchase", - (val) => parseAccelerators(val, "buy"), - 8, - ) - .option( - "-d, --duration ", - "Duration of reservation (rounded up to the nearest hour)", - parseDuration, - ) - .option( - "-p, --price ", - "Sets the maximize price per gpu/hr you're willing to pay. If the market rate is lower, then you'll pay the market rate", - ) - .option( - "-s, --start ", - "Start time (date, relative time like '+1d', or 'NOW')", - parseStartDateOrNow, - "NOW", - ) - .addOption( - new Option( - "-e, --end ", - "End time (date or relative time like '+1d', rounded up to nearest hour)", - ) - .argParser(parseEnd) - .conflicts("duration"), - ) - .hook("preAction", (command) => { - const { duration, end } = command.opts(); - if ((!duration && !end) || (!!duration && !!end)) { - console.error( - chalk.yellow("Specify either --duration or --end, but not both"), - ); - command.help(); - process.exit(1); - } - }) - .option("-y, --yes", "Automatically confirm the order") - .option( - "--colo, --colocate ", - "Colocate with existing contracts. If provided, `-t`/`--type` will be ignored.", - ) - .option( - "-q, --quote", - "Get a price quote without placing an order. Useful for scripting.", - ) - .option( - "--standing", - "Places a standing order. Default behavior is to place an order that auto-cancels if it can't be filled immediately.", - ) - .option( - "-z, --zone ", - "Send into a specific zone. If provided, `-t`/`--type` will be ignored.", - ) - .option( - "-c, --cluster ", - "Send into a specific cluster (deprecated, alias for --zone). If provided, `-t`/`--type` will be ignored.", - ) - .hook("preAction", (command) => { - const { type, zone, cluster, colocate } = command.opts(); - if (!type && !zone && !cluster && !colocate) { - console.error( - chalk.yellow("Must specify either --type, --zone or --colocate"), - ); - command.help(); - process.exit(1); - } - }) - .configureHelp({ - optionDescription: (option) => { - if (option.flags === "-h, --help") { - return "Display help for buy"; - } - return option.description; - }, - }) - .addHelpText( - "before", - ` -Examples: - \x1b[2m# Buy 8 H100s for 1 hour at market price\x1b[0m - $ sf buy -t h100v -n 8 -d 1h - - \x1b[2m# Buy 32 H100s for 6 hours starting in 3 hours\x1b[0m - $ sf buy -t h100v -n 32 -d 6h -s +3h - - \x1b[2m# Buy 64 H100s for 12 hours starting tomorrow at 9am\x1b[0m - $ sf buy -t h100v -n 64 -d 12h -s "tomorrow at 9am" - - \x1b[2m# Extend an existing contract that ends at 4pm by 4 hours\x1b[0m - $ sf buy -s 4pm -d 4h -colo - - \x1b[2m# Place a standing order at a specific price\x1b[0m - $ sf buy -t h100v -n 16 -d 24h -p 1.50 --standing -`, - ) - .action(async function buyOrderAction(options) { - /* - * Flow is: - * 1. If --quote, get quote and exit - * 2. If -p is provided, use it as the price - * 3. Otherwise, get a price by quoting the market - * 4. If --yes isn't provided, ask for confirmation - * 5. Place order - */ - // Normalize zone/cluster: prioritize zone over cluster for backward compatibility - const normalizedOptions = { - ...options, - cluster: options.zone || options.cluster, - }; - - if (normalizedOptions.quote) { - const { waitUntilExit } = render( - , - ); - await waitUntilExit(); - } else { - const { waitUntilExit } = render( - , - ); - await waitUntilExit(); - } - }); -} - -export function registerBuy(program: Command) { - _registerBuy(program); -} - -function parseEnd(value: string) { - const parsed = parseDate(value); - if (!parsed) logAndQuit(`Invalid end date: ${value}`); - return roundEndDate(parsed); -} - -export function parseDuration(duration?: string) { - if (!duration) { - return 1 * 60 * 60; // 1 hour - } - - // Assumes the units is hours if no units are provided - let durationStr = duration; - if (!/[a-zA-Z]$/.test(duration)) { - durationStr = `${duration}h`; - } - const parsed = parseDurationFromLibrary(durationStr); - if (!parsed) { - return logAndQuit(`Invalid duration: ${duration} (examples: 1h, 30m, 2d)`); - } - - return parsed / 1000; -} - -export function parsePricePerGpuHour(price?: string) { - if (!price) { - return null; - } - - // Remove $ if present - const priceWithoutDollar = price.replace("$", ""); - return Number.parseFloat(priceWithoutDollar) * 100; -} - -export function QuoteComponent(props: { options: SfBuyOptions }) { - const [quote, setQuote] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const { exit } = useApp(); - - useEffect(() => { - (async () => { - const quote = await getQuoteFromParsedSfBuyOptions(props.options); - if (quote) setQuote(quote); - setIsLoading(false); - setTimeout(exit, 0); - })(); - }, [props.options, exit]); - - return isLoading ? ( - - - - Getting quote... - - - ) : ( - - ); -} - -export function QuoteAndBuy(props: { options: SfBuyOptions }) { - const [orderProps, setOrderProps] = useState(null); - const [zone, setZone] = useState(); - - // submit a quote request, handle loading state - useEffect(() => { - (async () => { - const { start, duration, end } = props.options; - // Grab the price per GPU hour, either - let pricePerGpuHour = parsePricePerGpuHour(props.options.price); - let startAt = start; - let endsAt: Date; - let quoteZone: string | undefined; - const coercedStart = parseStartDate(start); - if (duration) { - // If duration is set, calculate end from start + duration - endsAt = roundEndDate( - dayjs(coercedStart).add(duration, "seconds").toDate(), - ); - } else if (end) { - endsAt = end; - props.options.duration = dayjs(endsAt).diff( - dayjs(coercedStart), - "seconds", - ); - } else { - throw new Error("Either duration or end must be set"); - } - - if (!pricePerGpuHour) { - const quote = await getQuoteFromParsedSfBuyOptions(props.options); - if (!quote) { - return logAndQuit( - "No quote found for the desired order. Try with a different start date, duration, or price.", - ); - } - - pricePerGpuHour = getPricePerGpuHourFromQuote(quote); - startAt = parseStartDateOrNow(quote.start_at); - endsAt = dayjs(quote.end_at).toDate(); - quoteZone = "zone" in quote ? quote.zone : undefined; - } - - const { type, accelerators, colocate, yes, standing, cluster } = - props.options; - - if (cluster) { - const api = await apiClient(); - const { data, error, response } = await api.GET("/v0/zones/{id}", { - params: { - path: { - id: cluster, - }, - }, - }); - if (error) { - return logAndQuit( - `Failed to get zone: ${JSON.stringify(error, null, 2)}`, - ); - } - if (!response.ok) { - return logAndQuit(`No zone found with slug: ${cluster}`); - } - setZone(data); - } - - setOrderProps({ - type, - price: pricePerGpuHour, - size: accelerators / GPUS_PER_NODE, - startAt, - endsAt, - yes, - standing, - colocate, - // If the user didn't specify a zone, use the zone from the quote - // This helps prevent price surprises/location mismatches - cluster: cluster ?? quoteZone, - }); - })(); - }, [props.options]); - - return orderProps === null ? ( - - - - Getting quote... - - - ) : ( - - ); -} - -export function getTotalPrice( - pricePerGpuHour: number, - size: number, - durationInHours: number, -) { - return Math.ceil(pricePerGpuHour * size * GPUS_PER_NODE * durationInHours); -} - -function BuyOrderPreview(props: BuyOrderProps) { - const startDate = props.startAt === "NOW" ? dayjs() : dayjs(props.startAt); - const start = startDate.format("MMM D h:mm a").toLowerCase(); - - const startFromNow = startDate.fromNow(); - - const endDate = dayjs(roundEndDate(props.endsAt)); - const end = endDate.format("MMM D h:mm a").toLowerCase(); - - const endFromNow = endDate.fromNow(); - - const realDuration = endDate.diff(startDate); - const realDurationHours = realDuration / 3600 / 1000; - const realDurationString = ms(realDuration); - - const totalPrice = - getTotalPrice(props.price, props.size, realDurationHours) / 100; - - const isSupportedType = - typeof props.type === "string" && props.type in InstanceTypeMetadata; - const typeLabel = isSupportedType - ? InstanceTypeMetadata[props.type!]?.displayName - : props.type; - - return ( - - - - start - - - {start} - - {props.startAt === "NOW" ? "(now)" : `(${startFromNow})`} - - - - - - end - - - {end} - ({endFromNow}) - - - - - {typeLabel && ( - - - type - - - {typeLabel} - {isSupportedType && ({props.type!})} - - - )} - {props.cluster && ( - - - zone - - - {props.cluster} - - - )} - {props.colocate && ( - - - - colocate with - - - - {props.colocate} - - - )} - - - - ); -} - -const MemoizedBuyOrderPreview = React.memo(BuyOrderPreview); - -type Order = Omit< - NonNullable>>, - "status" -> & { - status: - | NonNullable>>["status"] - | NonNullable>>["status"]; -}; -type BuyOrderProps = { - price: number; - size: number; - startAt: Date | "NOW"; - endsAt: Date; - type?: string; - colocate?: string; - yes?: boolean; - standing?: boolean; - cluster?: string; - zone?: ZoneInfo; -}; - -function VMWarning(props: BuyOrderProps) { - const startDate = props.startAt === "NOW" ? dayjs() : dayjs(props.startAt); - const endDate = dayjs(roundEndDate(props.endsAt)); - const realDuration = endDate.diff(startDate); - const realDurationString = ms(realDuration); - - // Build the equivalent sf nodes command - let equivalentCommand = `sf nodes create -n ${props.size}`; - - if (props.price) { - equivalentCommand += ` -p ${((props.price * GPUS_PER_NODE) / 100).toFixed( - 2, - )}`; - } - if (props.startAt !== "NOW") { - const startFormatted = startDate.toISOString(); - equivalentCommand += ` -s "${startFormatted}"`; - } - equivalentCommand += ` -d ${realDurationString}`; - if (props.yes) { - equivalentCommand += " -y"; - } - if (props.cluster) { - equivalentCommand += ` -z ${props.cluster}`; - } else { - // TODO: add support for any-zone - // equivalentCommand += `--any-zone`; - } - - const warningMessage = boxen( - `\x1b[31mWe're deprecating \x1b[97msf buy\x1b[31m for Virtual Machines.\x1b[0m -\x1b[31mWe recommend you create a VM Node instead: \x1b[97m${equivalentCommand}\x1b[0m -\x1b[31m\x1b[97msf nodes\x1b[31m allows you to create, extend, and release specific machines directly.\x1b[0m`, - { - padding: 0.75, - borderColor: "red", - }, - ); - - return {warningMessage}; -} - -function BuyOrder(props: BuyOrderProps) { - const [isLoading, setIsLoading] = useState(false); - const { type, zone } = props; - const isVM = type?.endsWith("v") || zone?.delivery_type === "VM"; - const [vmWarningState, setVmWarningState] = useState< - "prompt" | "accepted" | "dismissed" | "not_applicable" - >(isVM ? (props.yes ? "accepted" : "prompt") : "not_applicable"); - const { exit } = useApp(); - const [order, setOrder] = useState(null); - - const [loadingMsg, setLoadingMsg] = useState( - "Placing order...", - ); - - const submitOrder = useCallback(async () => { - const { startAt, endsAt } = props; - const realDurationInHours = - dayjs(endsAt).diff(dayjs(parseStartDate(startAt))) / 1000 / 3600; - - setIsLoading(true); - const order = await placeBuyOrder({ - instanceType: props.type, - totalPriceInCents: getTotalPrice( - props.price, - props.size, - realDurationInHours, - ), - startsAt: props.startAt, - endsAt, - colocateWith: props.colocate, - numberNodes: props.size, - standing: props.standing, - cluster: props.cluster, - }); - setOrder(order as Order); - }, [props]); - - const [resultMessage, setResultMessage] = useState(null); - const handleSubmit = useCallback( - (submitValue: boolean) => { - const { startAt, endsAt } = props; - const realDurationInHours = - dayjs(endsAt).diff(dayjs(startAt)) / 1000 / 3600; - const totalPriceInCents = getTotalPrice( - props.price, - props.size, - realDurationInHours, - ); - - analytics.track({ - event: "buy_order_quoted", - properties: { - price: totalPriceInCents, - startsAt: startAt, - endsAt, - numberNodes: props.size, - instanceType: props.type, - duration: realDurationInHours, - }, - }); - if (submitValue === false) { - setIsLoading(false); - setResultMessage("Order not placed, use 'y' to confirm"); - setTimeout(() => { - analytics.track({ - event: "buy_order_quoted_rejected", - properties: { - price: totalPriceInCents, - startsAt: startAt, - endsAt, - numberNodes: props.size, - instanceType: props.type, - duration: realDurationInHours, - }, - }); - exit(); - }, 0); - return; - } - - analytics.track({ - event: "buy_order_quoted_accepted", - properties: { - price: totalPriceInCents, - startsAt: startAt, - endsAt, - numberNodes: props.size, - instanceType: props.type, - duration: realDurationInHours, - }, - }); - submitOrder(); - }, - [props, exit, submitOrder], - ); - - const handleDismissVMWarning = useCallback( - (submitValue: boolean) => { - if (!submitValue) { - setIsLoading(false); - setResultMessage( - "VM order not placed. We recommend you use 'sf nodes create' instead.", - ); - setTimeout(exit, 0); - } else setVmWarningState("accepted"); - }, - [exit], - ); - - useEffect(() => { - if (!isLoading || !order?.id) { - return; - } - - const pollForOrder = async () => { - const o = await getOrder(order.id); - if (!o) { - setLoadingMsg( - "Can't find order. This could be a network issue, try ctrl-c and running 'sf orders ls' to see if it was placed.", - ); - // Schedule next poll - setTimeout(pollForOrder, 200); - return; - } - setOrder(o); - // Exit after render, regardless of order status - setTimeout(exit, 0); - }; - - // Start the first poll - setTimeout(pollForOrder, 200); - }, [isLoading, order?.id, exit]); - - useEffect(() => { - if (!isLoading && props.yes) { - submitOrder(); - } - }, [isLoading, props.yes, submitOrder]); - - return ( - - {(vmWarningState === "prompt" || vmWarningState === "accepted") && ( - - - {vmWarningState === "prompt" && ( - <> - - Place an order for a legacy VM anyway?{" "} - (y/n) - - - - - )} - - )} - - {(vmWarningState === "dismissed" || - vmWarningState === "not_applicable" || - vmWarningState === "accepted") && ( - - )} - - {vmWarningState === "accepted" && !isLoading && !props.yes && ( - - Place order? (y/n) - - - - )} - - {(vmWarningState === "dismissed" || - vmWarningState === "not_applicable") && - !isLoading && - !props.yes && ( - - Place order? (y/n) - - - - )} - - {isLoading && ( - - {(!order || order.status === "pending") && } - {!order && {loadingMsg}} - {order && order.status === "open" && } - {order && order.status === "cancelled" && ( - - Order could not be filled: {order.id} - - No charges applied. Try again with different parameters (price, - duration, or quantity). - - - )} - {order && order.status !== "cancelled" && ( - - Order placed: {order.id} - - ({order.status}) - - )} - - {order && - order.status === "filled" && - (order as Awaited>) && - order.execution_price && ( - - {order.start_at && - order.end_at && - order.start_at !== order.end_at && ( - - )} - - {order.execution_price && - Number(order.price) > 0 && - Number(order.execution_price) > 0 && - Number(order.execution_price) < Number(order.price) && ( - - )} - - )} - - )} - - {resultMessage && {resultMessage}} - - {order && order.status === "open" && ( - - Order is open but not yet filled. Check status with: - - sf orders ls - - - Cancel this order with: - - sf orders cancel {order.id} - - - )} - - ); -} - -export async function placeBuyOrder(options: { - instanceType?: string; - totalPriceInCents: number; - startsAt: Date | "NOW"; - endsAt: Date; - colocateWith?: string; - numberNodes: number; - standing?: boolean; - cluster?: string; -}) { - invariant( - options.totalPriceInCents === Math.ceil(options.totalPriceInCents), - "totalPriceInCents must be a whole number", - ); - invariant(options.numberNodes > 0, "numberNodes must be greater than 0"); - invariant( - options.numberNodes === Math.ceil(options.numberNodes), - "numberNodes must be a whole number", - ); - - const api = await apiClient(); - - // round start date again because the user might take a long time to confirm - let start_at: string; - if (options.startsAt === "NOW") { - start_at = "NOW"; - } else { - const roundedStartDate = roundStartDate(options.startsAt); - if (roundedStartDate === "NOW") { - start_at = "NOW" as const; - } else { - start_at = roundedStartDate.toISOString(); - } - } - - const body = { - side: "buy" as const, - instance_type: options.instanceType, - quantity: options.numberNodes, - start_at, - end_at: roundEndDate(options.endsAt).toISOString(), - price: options.totalPriceInCents, - colocate_with: (options.colocateWith - ? [options.colocateWith] - : []) as string[], - flags: { - ioc: !options.standing, - }, - cluster: options.cluster, - }; - const { data, error, response } = await api.POST("/v0/orders", { - body, - }); - - if (!response.ok) { - switch (response.status) { - case 400: { - if (error?.message === "Insufficient balance") { - return logAndQuit( - "Order not placed. You don't have enough funds. Add funds with\n\t🏦 Bank transfer: https://sfcompute.com/dashboard?bankTransferDialogOpen=true\n\t💳 Credit card: https://sfcompute.com/dashboard?payWithCardDialogOpen=true", - ); - } - - return logAndQuit( - `Bad Request: ${error?.message}; ${JSON.stringify(error, null, 2)}`, - ); - } - case 401: - return await logSessionTokenExpiredAndQuit(); - case 500: - return logAndQuit(`Failed to place order: ${error?.message}`); - default: - return logAndQuit( - `Failed to place order: ${response.status} ${response.statusText} - ${ - error ? `[${error}] ` : "" - }${error?.message || "Unknown error"}`, - ); - } - } - - if (!data) { - return logAndQuit( - `Failed to place order: Unexpected response from server: ${response}`, - ); - } - - return data; -} - -export function getPricePerGpuHourFromQuote( - quote: Pick, "start_at" | "end_at" | "price" | "quantity">, -) { - const startTimeOrNow = parseStartDateOrNow(quote.start_at); - - // from the market's perspective, "NOW" means at the beginning of the next minute. - // when the order duration is very short, this can cause the rate to be computed incorrectly - // if we implicitly assume it to mean `new Date()`. - const coercedStartTime = - startTimeOrNow === "NOW" - ? roundDateUpToNextMinute(new Date()) - : startTimeOrNow; - const durationSeconds = dayjs(quote.end_at).diff(dayjs(coercedStartTime)); - const durationHours = durationSeconds / 3600 / 1000; - - return quote.price / GPUS_PER_NODE / quote.quantity / durationHours; -} - -async function getQuoteFromParsedSfBuyOptions(options: SfBuyOptions) { - const startsAt = - options.start === "NOW" - ? "NOW" - : roundStartDate(parseStartDate(options.start)); - const durationSeconds = options.duration - ? options.duration - : dayjs(options.end).diff(dayjs(parseStartDate(startsAt)), "seconds"); - const quantity = options.accelerators / GPUS_PER_NODE; - - const minDurationSeconds = Math.max( - 1, - durationSeconds - Math.ceil(durationSeconds * 0.1), - ); - const maxDurationSeconds = Math.max( - durationSeconds + 3600, - durationSeconds + Math.ceil(durationSeconds * 0.1), - ); - - return await getQuote({ - instanceType: options.type, - quantity, - minStartTime: startsAt, - maxStartTime: startsAt, - minDurationSeconds, - maxDurationSeconds, - cluster: options.cluster, - colocateWith: options.colocate, - }); -} - -type QuoteOptions = { - instanceType?: string; - quantity: number; - minStartTime: Date | "NOW"; - maxStartTime: Date | "NOW"; - minDurationSeconds: number; - maxDurationSeconds: number; - cluster?: string; - colocateWith?: string; -}; - -export async function getQuote(options: QuoteOptions) { - const api = await apiClient(); - - const params = { - query: { - side: "buy", - instance_type: options.instanceType, - quantity: options.quantity, - min_start_date: - options.minStartTime === "NOW" - ? ("NOW" as const) - : options.minStartTime.toISOString(), - max_start_date: - options.maxStartTime === "NOW" - ? ("NOW" as const) - : options.maxStartTime.toISOString(), - min_duration: options.minDurationSeconds, - max_duration: options.maxDurationSeconds, - cluster: options.cluster, - colocate_with: options.colocateWith, - }, - } as const; - - const { data, error, response } = await api.GET("/v0/quote", { - params, - // timeout after 600 seconds - signal: AbortSignal.timeout(600 * 1000), - }); - - if (!response.ok) { - switch (response.status) { - case 400: - return logAndQuit(`Bad Request: ${JSON.stringify(error, null, 2)}`); - case 401: - return await logSessionTokenExpiredAndQuit(); - case 500: - return logAndQuit( - `Failed to get quote: ${JSON.stringify(error, null, 2)}`, - ); - default: - return logAndQuit(`Failed to get quote: ${response.statusText}`); - } - } - - if (!data) { - return logAndQuit( - `Failed to get quote: Unexpected response from server: ${response}`, - ); - } - - if (!data.quote) { - return null; - } - - return { - ...data.quote, - price: Number(data.quote.price), - quantity: Number(data.quote.quantity), - start_at: data.quote.start_at, - end_at: data.quote.end_at, - }; -} - -export async function getOrder(orderId: string) { - const api = await apiClient(); - - const { - data: order, - error, - response, - } = await api.GET("/v0/orders/{id}", { - params: { path: { id: orderId } }, - }); - - if (error) { - // @ts-expect-error -- TODO: FIXME: include error in OpenAPI schema output - if (error?.code === "order.not_found" || response.status === 404) { - return undefined; - } - - return logAndQuit(`Failed to get order: ${error.message}`); - } - - return order; -} diff --git a/src/lib/extend/index.tsx b/src/lib/extend/index.tsx index 50e6f1fb..8f168b03 100644 --- a/src/lib/extend/index.tsx +++ b/src/lib/extend/index.tsx @@ -2,15 +2,8 @@ import console from "node:console"; import process from "node:process"; import type { Command } from "@commander-js/extra-typings"; import boxen from "boxen"; -import dayjs from "dayjs"; -import duration from "dayjs/plugin/duration"; -import relativeTime from "dayjs/plugin/relativeTime"; import ora from "ora"; import { apiClient } from "../../apiClient.ts"; -import { getContractRange } from "../contracts/utils.ts"; - -dayjs.extend(relativeTime); -dayjs.extend(duration); function _registerExtend(program: Command) { return program @@ -43,9 +36,7 @@ function _registerExtend(program: Command) { ${boxen( `\x1b[31m\x1b[97msf extend\x1b[31m is deprecated.\x1b[0m \x1b[31mTo create, extend, and release specific machines directly, use \x1b[97msf nodes\x1b[31m.\x1b[0m - \x1b[31mTo "extend" a contract, use \x1b[97msf buy -colo -s -d \x1b[31m.\x1b[0m - \x1b[31mHowever, contracts don't map to specific machines, so you can't choose which will persist.\x1b[0m - \x1b[31mWe strongly recommend using \x1b[97msf nodes\x1b[31m instead.\x1b[0m`, + \x1b[31mFor example: \x1b[97msf nodes extend --duration 3600 --max-price 12.50\x1b[31m.\x1b[0m`, { padding: 0.75, borderColor: "red", @@ -72,34 +63,11 @@ ${boxen( spinner.clear(); } - const contractRange = - contract?.shape && getContractRange(contract?.shape); - - // Build the equivalent sf buy command - let equivalentCommand = `sf buy -colo ${options.contract} -s ${ - contractRange?.endsAt?.toISOString?.() ?? "" - } -d ${options.duration}`; - - if (options.price) { - equivalentCommand += ` -p ${options.price}`; - } - if (options.yes) { - equivalentCommand += " -y"; - } - if (options.quote) { - equivalentCommand += " -q"; - } - if (options.standing) { - equivalentCommand += " --standing"; - } - console.error( boxen( `\x1b[31m\x1b[97msf extend\x1b[31m is deprecated.\x1b[0m \x1b[31mTo create, extend, and release specific machines directly, use \x1b[97msf nodes\x1b[31m.\x1b[0m - \x1b[31mTo "extend" a contract, use: \x1b[97m${equivalentCommand}\x1b[31m.\x1b[0m - \x1b[31mHowever, contracts don't map to specific machines, so you can't choose which will persist.\x1b[0m - \x1b[31mWe strongly recommend using \x1b[97msf nodes\x1b[31m instead.\x1b[0m`, + \x1b[31mFor example: \x1b[97msf nodes extend --duration 3600 --max-price 12.50\x1b[31m.\x1b[0m`, { padding: 0.75, borderColor: "red", diff --git a/src/lib/index.ts b/src/lib/index.ts index 62804213..e69de29b 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,16 +0,0 @@ -import { logAndQuit } from "../helpers/errors.ts"; -import { GPUS_PER_NODE } from "./constants.ts"; - -export function parseAccelerators(accelerators: string, type: "buy" | "sell") { - if (!accelerators) { - return 1; - } - - const parsedValue = Number.parseInt(accelerators, 10); - if (!Number.isInteger(parsedValue / GPUS_PER_NODE)) { - return logAndQuit( - `You can only ${type} whole nodes, or multiples of ${GPUS_PER_NODE} GPUs at a time. Got: ${accelerators}`, - ); - } - return parsedValue; -} diff --git a/src/lib/nodes/create.ts b/src/lib/nodes/create.ts index 51f35e1b..14873524 100644 --- a/src/lib/nodes/create.ts +++ b/src/lib/nodes/create.ts @@ -19,7 +19,7 @@ import { selectTime, } from "../../helpers/units.ts"; import { handleNodesError, nodesClient } from "../../nodesClient.ts"; -import { getPricePerGpuHourFromQuote, getQuote } from "../buy/index.tsx"; +import { getPricePerGpuHourFromQuote, getQuote } from "../../helpers/quote.ts"; import { GPUS_PER_NODE } from "../constants.ts"; import { createNodesTable, diff --git a/src/lib/nodes/extend.ts b/src/lib/nodes/extend.ts index 704bd1ad..f434472e 100644 --- a/src/lib/nodes/extend.ts +++ b/src/lib/nodes/extend.ts @@ -10,7 +10,7 @@ import dayjs from "dayjs"; import ora from "ora"; import { selectTime } from "../../helpers/units.ts"; import { handleNodesError, nodesClient } from "../../nodesClient.ts"; -import { getPricePerGpuHourFromQuote, getQuote } from "../buy/index.tsx"; +import { getPricePerGpuHourFromQuote, getQuote } from "../../helpers/quote.ts"; import { GPUS_PER_NODE } from "../constants.ts"; import { createNodesTable, diff --git a/src/lib/orders/OrderDisplay.tsx b/src/lib/orders/OrderDisplay.tsx index 33e59a2f..2579051f 100644 --- a/src/lib/orders/OrderDisplay.tsx +++ b/src/lib/orders/OrderDisplay.tsx @@ -4,7 +4,7 @@ import { Box, measureElement, Text, useInput } from "ink"; import React, { useEffect } from "react"; import { GPUS_PER_NODE } from "../constants.ts"; import { Row } from "../Row.tsx"; -import { formatDuration } from "./index.tsx"; +import { formatDuration } from "../../helpers/format-duration.ts"; import type { HydratedOrder } from "./types.ts"; export function orderDetails(order: HydratedOrder) { diff --git a/src/lib/orders/index.tsx b/src/lib/orders/index.tsx deleted file mode 100644 index b00859c1..00000000 --- a/src/lib/orders/index.tsx +++ /dev/null @@ -1,362 +0,0 @@ -import * as console from "node:console"; -import { type Command, Option } from "@commander-js/extra-typings"; -import dayjs from "dayjs"; -import duration from "dayjs/plugin/duration"; -import relativeTime from "dayjs/plugin/relativeTime"; -import { render } from "ink"; -import { getAuthToken, isLoggedIn } from "../../helpers/config.ts"; -import { parseDurationArgument } from "../../helpers/duration.ts"; -import { - logAndQuit, - logLoginMessageAndQuit, - logSessionTokenExpiredAndQuit, -} from "../../helpers/errors.ts"; -import { fetchAndHandleErrors } from "../../helpers/fetch.ts"; -import { parseStartDate } from "../../helpers/units.ts"; -import { getApiUrl } from "../../helpers/urls.ts"; -import { OrderDisplay } from "./OrderDisplay.tsx"; -import type { HydratedOrder, ListResponseBody } from "./types.ts"; - -dayjs.extend(relativeTime); -dayjs.extend(duration); - -export function formatDuration(ms: number) { - const d = dayjs.duration(ms); - - const years = Math.floor(d.asYears()); - const weeks = Math.floor(d.asWeeks()) % 52; - const days = d.days(); - const hours = d.hours(); - const minutes = d.minutes(); - const seconds = d.seconds(); - const milliseconds = d.milliseconds(); - - let result = ""; - - if (years > 0) result += `${years}y`; - if (weeks > 0) result += `${weeks}w`; - if (days > 0) result += `${days}d`; - if (hours > 0) result += `${hours}h`; - if (minutes > 0) result += `${minutes}m`; - if (seconds > 0) result += `${seconds}s`; - if (milliseconds > 0) result += `${milliseconds}ms`; - - return result || "0ms"; -} - -export function registerOrders(program: Command) { - const ordersCommand = program - .command("orders") - .alias("o") - .alias("order") - .description("Manage orders"); - - ordersCommand - .command("ls") - .alias("list") - .description("List orders") - .addOption( - new Option("--side ", "Filter by order side (buy or sell)").choices( - ["buy", "sell"] as const, - ), - ) - .option("-t, --type ", "Filter by instance type") - .option("-v, --verbose", "Show verbose output") - .addOption( - new Option( - "--public", - "This option is deprecated. It's no longer possible to view public orders.", - ) - .conflicts(["onlyFilled", "onlyCancelled"]) - .implies({ - onlyOpen: true, - }), - ) - .option( - "--min-price ", - "Filter by minimum price (in cents)", - Number.parseInt, - ) - .option( - "--max-price ", - "Filter by maximum price (in cents)", - Number.parseInt, - ) - .option( - "--min-start ", - "Filter by minimum start date (ISO 8601 datestring)", - ) - .option( - "--max-start ", - "Filter by maximum start date (ISO 8601 datestring)", - ) - .option( - "--min-duration ", - "Filter by minimum duration (in seconds)", - ) - .option( - "--max-duration ", - "Filter by maximum duration (in seconds)", - ) - .option( - "--min-quantity ", - "Filter by minimum quantity", - Number.parseInt, - ) - .option( - "--max-quantity ", - "Filter by maximum quantity", - Number.parseInt, - ) - .option( - "--contract-id ", - "Filter by contract ID (only for sell orders)", - ) - .addOption( - new Option("--only-open", "Show only open orders").conflicts([ - "onlyFilled", - "onlyCancelled", - ]), - ) - .addOption( - new Option("--exclude-filled", "Exclude filled orders").conflicts([ - "onlyFilled", - ]), - ) - .addOption( - new Option("--only-filled", "Show only filled orders").conflicts([ - "excludeFilled", - "onlyCancelled", - "onlyOpen", - "public", - ]), - ) - .option( - "--min-filled-at ", - "Filter by minimum filled date (ISO 8601 datestring)", - ) - .option( - "--max-filled-at ", - "Filter by maximum filled date (ISO 8601 datestring)", - ) - .option( - "--min-fill-price ", - "Filter by minimum fill price (in cents)", - Number.parseInt, - ) - .option( - "--max-fill-price ", - "Filter by maximum fill price (in cents)", - Number.parseInt, - ) - .option("--include-cancelled", "Include cancelled orders") - .addOption( - new Option("--only-cancelled", "Show only cancelled orders") - .conflicts(["onlyFilled", "onlyOpen", "public"]) - .implies({ - includeCancelled: true, - }), - ) - .option( - "--min-cancelled-at ", - "Filter by minimum cancelled date (ISO 8601 datestring)", - ) - .option( - "--max-cancelled-at ", - "Filter by maximum cancelled date (ISO 8601 datestring)", - ) - .option( - "--min-placed-at ", - "Filter by minimum placed date (ISO 8601 datestring)", - ) - .option( - "--max-placed-at ", - "Filter by maximum placed date (ISO 8601 datestring)", - ) - .option("--limit ", "Limit the number of results", Number.parseInt) - .option( - "--offset ", - "Offset the results (for pagination)", - Number.parseInt, - ) - .option("--json", "Output in JSON format") - .action(async (options) => { - const minDuration = parseDurationArgument(options.minDuration); - const maxDuration = parseDurationArgument(options.maxDuration); - const orders = await getOrders({ - side: options.side, - instance_type: options.type, - - min_price: options.minPrice, - max_price: options.maxPrice, - min_start_date: options.minStart, - max_start_date: options.maxStart, - min_duration: minDuration, - max_duration: maxDuration, - min_quantity: options.minQuantity, - max_quantity: options.maxQuantity, - - contract_id: options.contractId, - - only_open: options.onlyOpen, - - exclude_filled: options.excludeFilled, - only_filled: options.onlyFilled, - min_filled_at: options.minFilledAt, - max_filled_at: options.maxFilledAt, - min_fill_price: options.minFillPrice, - max_fill_price: options.maxFillPrice, - - exclude_cancelled: !options.includeCancelled, - only_cancelled: options.onlyCancelled, - min_cancelled_at: options.minCancelledAt, - max_cancelled_at: options.maxCancelledAt, - - min_placed_at: options.minPlacedAt, - max_placed_at: options.maxPlacedAt, - - limit: options.limit, - offset: options.offset, - - sort_by: "start_time", - sort_direction: "ASC", - }); - - // Sort orders by start time ascending (present to future) - const sortedOrders = [...orders].sort((a, b) => { - const aStart = parseStartDate(a.start_at); - const bStart = parseStartDate(b.start_at); - return aStart.getTime() - bStart.getTime(); - }); - - if (options.json) { - console.log(JSON.stringify(sortedOrders, null, 2)); - } else { - const { waitUntilExit } = render( - , - ); - await waitUntilExit(); - } - }); - - ordersCommand - .command("cancel ") - .description("Cancel an order") - .action(submitOrderCancellationByIdAction); -} - -export async function getOrders(props: { - side?: "buy" | "sell"; - instance_type?: string; - - min_price?: number; - max_price?: number; - min_start_date?: string; - max_start_date?: string; - min_duration?: number; - max_duration?: number; - min_quantity?: number; - max_quantity?: number; - - contract_id?: string; - - only_open?: boolean; - - exclude_filled?: boolean; - only_filled?: boolean; - min_filled_at?: string; - max_filled_at?: string; - min_fill_price?: number; - max_fill_price?: number; - - exclude_cancelled?: boolean; - only_cancelled?: boolean; - min_cancelled_at?: string; - max_cancelled_at?: string; - - min_placed_at?: string; - max_placed_at?: string; - - limit?: number; - offset?: number; - - sort_by?: "created_at" | "start_time"; - sort_direction?: "ASC" | "DESC"; -}) { - const loggedIn = await isLoggedIn(); - if (!loggedIn) { - logLoginMessageAndQuit(); - } - - const params = new URLSearchParams(); - for (const [key, value] of Object.entries(props)) { - if (value !== undefined) { - params.append(key, value.toString()); - } - } - - const url = `${await getApiUrl("orders_list")}?${params.toString()}`; - - const response = await fetchAndHandleErrors(url, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${await getAuthToken()}`, - }, - }); - - if (!response.ok) { - switch (response.status) { - case 401: - return await logSessionTokenExpiredAndQuit(); - default: - return logAndQuit(`Failed to fetch orders: ${response.statusText}`); - } - } - - const resp = (await response.json()) as ListResponseBody; - return resp.data; -} - -export async function submitOrderCancellationByIdAction(orderId: string) { - const loggedIn = await isLoggedIn(); - if (!loggedIn) { - logLoginMessageAndQuit(); - } - - const url = await getApiUrl("orders_cancel", { id: orderId }); - const response = await fetchAndHandleErrors(url, { - method: "DELETE", - body: JSON.stringify({}), - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${await getAuthToken()}`, - }, - }); - if (!response.ok) { - if (response.status === 401) { - return await logSessionTokenExpiredAndQuit(); - } - - const error = (await response.json()) as { code?: string }; - switch (error.code) { - case "order.not_found": - return logAndQuit(`Order ${orderId} not found`); - case "order.already_cancelled": - return logAndQuit(`Order ${orderId} is already cancelled`); - default: - // TODO: handle more specific errors - return logAndQuit(`Failed to cancel order ${orderId}`); - } - } - - const resp = (await response.json()) as { object?: string }; - const cancellationSubmitted = resp.object === "pending"; - if (!cancellationSubmitted) { - return logAndQuit(`Failed to cancel order ${orderId}`); - } - - // cancellation submitted successfully - console.log(`Cancellation for Order ${orderId} submitted.`); - // process.exit(0); -} diff --git a/src/lib/scale/ConfirmationMessage.tsx b/src/lib/scale/ConfirmationMessage.tsx index dd1b0cb2..6deee312 100644 --- a/src/lib/scale/ConfirmationMessage.tsx +++ b/src/lib/scale/ConfirmationMessage.tsx @@ -1,7 +1,7 @@ import { Box, Text } from "ink"; import { InstanceTypeMetadata } from "../../helpers/instance-types-meta.ts"; -import { formatDuration } from "../orders/index.tsx"; +import { formatDuration } from "../../helpers/format-duration.ts"; import { Row } from "../Row.tsx"; import { diff --git a/src/lib/scale/ProcurementDisplay.tsx b/src/lib/scale/ProcurementDisplay.tsx index e56e4c6c..86e30c2d 100644 --- a/src/lib/scale/ProcurementDisplay.tsx +++ b/src/lib/scale/ProcurementDisplay.tsx @@ -3,7 +3,7 @@ import { Box, Text } from "ink"; import { InstanceTypeMetadata } from "../../helpers/instance-types-meta.ts"; import { GPUS_PER_NODE } from "../constants.ts"; -import { formatDuration } from "../orders/index.tsx"; +import { formatDuration } from "../../helpers/format-duration.ts"; import { Row } from "../Row.tsx"; import { formatColocationStrategy, type Procurement } from "./utils.ts"; diff --git a/src/lib/scale/create.tsx b/src/lib/scale/create.tsx index 42126f4a..f6ccd0dd 100644 --- a/src/lib/scale/create.tsx +++ b/src/lib/scale/create.tsx @@ -13,7 +13,7 @@ import { logAndQuit } from "../../helpers/errors.ts"; import { roundDateUpToNextMinute } from "../../helpers/units.ts"; import type { components } from "../../schema.ts"; -import { getQuote } from "../buy/index.tsx"; +import { getQuote } from "../../helpers/quote.ts"; import ConfirmInput from "../ConfirmInput.tsx"; import { GPUS_PER_NODE } from "../constants.ts"; import { pluralizeNodes } from "../nodes/utils.ts"; diff --git a/src/lib/sell.ts b/src/lib/sell.ts deleted file mode 100644 index 3de430f0..00000000 --- a/src/lib/sell.ts +++ /dev/null @@ -1,182 +0,0 @@ -import type { Command } from "@commander-js/extra-typings"; -import * as chrono from "chrono-node"; -import dayjs from "dayjs"; -import parseDuration from "parse-duration"; -import { apiClient } from "../apiClient.ts"; -import { isLoggedIn } from "../helpers/config.ts"; -import { - logAndQuit, - logLoginMessageAndQuit, - logSessionTokenExpiredAndQuit, -} from "../helpers/errors.ts"; -import { getContract } from "../helpers/fetchers.ts"; -import { pricePerGPUHourToTotalPriceCents } from "../helpers/price.ts"; -import { - priceWholeToCents, - roundEndDate, - roundStartDate, -} from "../helpers/units.ts"; -import { GPUS_PER_NODE } from "./constants.ts"; -import { parseAccelerators } from "./index.ts"; -import type { PlaceSellOrderParameters } from "./orders/types.ts"; - -export function registerSell(program: Command) { - program - .command("sell") - .description("Place a sell order") - .requiredOption("-p, --price ", "The price in dollars, per GPU hour") - .requiredOption("-c, --contract-id ", "Specify the contract ID") - .option( - "-n, --accelerators ", - "Specify the number of GPUs", - (val) => parseAccelerators(val, "sell"), - 8, - ) - .option("-s, --start ", "Specify the start time (ISO 8601 format)") - .option("-d, --duration ", "Specify the duration, like '1h'") - .option( - "-f, --flags ", - "Specify additional flags as JSON", - JSON.parse, - ) - .action(async function placeSellOrder(options) { - const loggedIn = await isLoggedIn(); - if (!loggedIn) { - return logLoginMessageAndQuit(); - } - - const { cents: priceCents, invalid } = priceWholeToCents(options.price); - if (invalid || !priceCents) { - return logAndQuit(`Invalid price: ${options.price}`); - } - - const contract = await getContract(options.contractId); - if (!contract) { - return logAndQuit(`Contract ${options.contractId} not found`); - } - - if (contract?.status === "pending") { - return logAndQuit( - `Contract ${options.contractId} is currently pending. Please try again in a few seconds.`, - ); - } - - if (options.accelerators % GPUS_PER_NODE !== 0) { - const exampleCommand = `sf sell -n ${GPUS_PER_NODE} -c ${options.contractId}`; - return logAndQuit( - `At the moment, only entire-nodes are available, so you must have a multiple of ${GPUS_PER_NODE} GPUs. Example command:\n\n${exampleCommand}`, - ); - } - - const { startDate: contractStartDate, endDate: contractEndDate } = - contractStartAndEnd({ - shape: { - intervals: contract.shape.intervals, - quantities: contract.shape.quantities, - }, - }); - - const startDate = options.start - ? chrono.parseDate(options.start) - : contractStartDate; - if (!startDate) { - return logAndQuit("Invalid start date"); - } - - const roundedStartDate = roundStartDate(startDate); - - let endDate = contractEndDate; - if (options.duration) { - const durationSecs = parseDuration(options.duration, "s"); - if (!durationSecs) { - return logAndQuit("Invalid duration"); - } - endDate = dayjs( - roundedStartDate === "NOW" ? new Date() : roundedStartDate, - ) - .add(durationSecs, "s") - .toDate(); - } - - endDate = roundEndDate(endDate); - // if the end date is longer than the contract, use the contract end date - if (endDate > contractEndDate) { - endDate = roundEndDate(contractEndDate); - } - const totalDurationSecs = dayjs(endDate).diff(startDate, "s"); - const nodes = Math.ceil(options.accelerators / GPUS_PER_NODE); - - const totalPrice = pricePerGPUHourToTotalPriceCents( - priceCents, - totalDurationSecs, - nodes, - GPUS_PER_NODE, - ); - - const params: PlaceSellOrderParameters = { - side: "sell", - quantity: forceAsNumber(options.accelerators) / GPUS_PER_NODE, - price: totalPrice, - contract_id: options.contractId, - start_at: - roundedStartDate === "NOW" ? "NOW" : roundedStartDate.toISOString(), - end_at: endDate.toISOString(), - }; - - const api = await apiClient(); - const { data, error, response } = await api.POST("/v0/orders", { - body: params, - }); - - if (!response.ok) { - switch (response.status) { - case 400: - return logAndQuit( - `Bad Request: ${error?.message}: ${JSON.stringify( - error?.details, - null, - 2, - )}`, - ); - // return logAndQuit(`Bad Request: ${error?.message}`); - case 401: - return await logSessionTokenExpiredAndQuit(); - case 403: - return logAndQuit( - "Selling is not enabled on your account yet. Contact us at hello@sfcompute.com if you are interested.", - ); - default: - return logAndQuit( - `Failed to place sell order: ${response.statusText}`, - ); - } - } - - if (!data?.id) { - return logAndQuit("Order ID not found"); - } - - // process.exit(0); - }); -} - -function forceAsNumber(value: string | number): number { - if (typeof value === "number") { - return value; - } - return Number.parseFloat(value); -} - -function contractStartAndEnd(contract: { - shape: { - intervals: string[]; // date strings - quantities: number[]; - }; -}) { - const startDate = dayjs(contract.shape.intervals[0]).toDate(); - const endDate = dayjs( - contract.shape.intervals[contract.shape.intervals.length - 1], - ).toDate(); - - return { startDate, endDate }; -} diff --git a/src/lib/sell/index.tsx b/src/lib/sell/index.tsx deleted file mode 100644 index 815b5baf..00000000 --- a/src/lib/sell/index.tsx +++ /dev/null @@ -1,423 +0,0 @@ -import { clearInterval, setInterval, setTimeout } from "node:timers"; -import type { Command } from "@commander-js/extra-typings"; -import dayjs from "dayjs"; -import duration from "dayjs/plugin/duration"; -import relativeTime from "dayjs/plugin/relativeTime"; -import { Box, render, Text, useApp } from "ink"; -import Spinner from "ink-spinner"; -import ms from "ms"; -import parseDurationFromLibrary from "parse-duration"; -import { useCallback, useEffect, useState } from "react"; -import invariant from "tiny-invariant"; -import { apiClient } from "../../apiClient.ts"; -import { isLoggedIn } from "../../helpers/config.ts"; -import { - logAndQuit, - logLoginMessageAndQuit, - logSessionTokenExpiredAndQuit, -} from "../../helpers/errors.ts"; -import { getContract } from "../../helpers/fetchers.ts"; -import { parseStartDate } from "../../helpers/units.ts"; -import type { components } from "../../schema.ts"; -import ConfirmInput from "../ConfirmInput.tsx"; -import { GPUS_PER_NODE } from "../constants.ts"; -import { Row } from "../Row.tsx"; - -type SellOrderFlags = components["schemas"]["market-api_OrderFlags"]; - -dayjs.extend(relativeTime); -dayjs.extend(duration); - -export function registerSell(program: Command) { - program - .command("sell") - .description("Place a sell order") - .requiredOption("-p, --price ", "The price in dollars, per GPU hour") - .requiredOption("-c, --contract-id ", "Specify the contract ID") - .option("-n, --accelerators ", "Specify the number of GPUs", "8") - .option( - "-s, --start ", - "Specify the start date. Can be a date, relative time like '+1d', or the string 'NOW'", - ) - .option("-d, --duration ", "Specify the duration", "1h") - .option( - "-f, --flags ", - "Specify additional flags as JSON", - JSON.parse, - ) - .option("-y, --yes", "Automatically confirm the order") - .action(async function sellOrderAction(options) { - if (!(await isLoggedIn())) { - return logLoginMessageAndQuit(); - } - - const pricePerGpuHour = parsePricePerGpuHour(options.price); - if (!pricePerGpuHour) { - return logAndQuit(`Invalid price: ${options.price}`); - } - - const contractId = options.contractId; - - if (!contractId || !contractId.startsWith("cont_")) { - return logAndQuit(`Invalid contract ID: ${contractId}`); - } - - const size = parseAccelerators(options.accelerators); - if (Number.isNaN(size) || size <= 0) { - return logAndQuit( - `Invalid number of accelerators: ${options.accelerators}`, - ); - } - - const durationSeconds = parseDuration(options.duration); - if (!durationSeconds || durationSeconds <= 0) { - return logAndQuit(`Invalid duration: ${options.duration}`); - } - - const startDate = parseStartDate(options.start); - if (!startDate) { - return logAndQuit(`Invalid start date: ${options.start}`); - } - - const endDate = roundEndDate( - dayjs(startDate).add(durationSeconds, "seconds").toDate(), - ).toDate(); - - // Fetch contract details - const contract = await getContract(contractId); - if (!contract) { - return logAndQuit(`Contract not found: ${contractId}`); - } - - // Prepare order details - const orderDetails = { - price: pricePerGpuHour, - contractId: contractId, - size: size, - startAt: startDate, - endsAt: endDate, - flags: options.flags as SellOrderFlags, // TODO: explicitly parse and validate this - autoConfirm: options.yes || false, - }; - - // Render the SellOrder component - const { waitUntilExit } = render(); - await waitUntilExit(); - }); -} - -function parseAccelerators(accelerators?: string) { - if (!accelerators) { - return 1; - } - - return Number.parseInt(accelerators, 10) / GPUS_PER_NODE; -} - -function parseDuration(duration?: string) { - if (!duration) { - return 1 * 60 * 60; // 1 hour - } - - const parsed = parseDurationFromLibrary(duration); - if (!parsed) { - return logAndQuit(`Invalid duration: ${duration}`); - } - - return parsed / 1000; -} - -function parsePricePerGpuHour(price?: string) { - if (!price) { - return null; - } - - // Remove $ if present - const priceWithoutDollar = price.replace("$", ""); - return Number.parseFloat(priceWithoutDollar) * 100; -} - -function roundEndDate(endDate: Date) { - return dayjs(endDate).add(1, "hour").startOf("hour"); -} - -function getTotalPrice( - pricePerGpuHour: number, - size: number, - durationInHours: number, -) { - return Math.ceil(pricePerGpuHour * size * GPUS_PER_NODE * durationInHours); -} - -type Order = - | Awaited> - | Awaited>; - -function SellOrder(props: { - price: number; - contractId: string; - size: number; - startAt: Date | "NOW"; - endsAt: Date; - flags?: SellOrderFlags; - autoConfirm?: boolean; -}) { - const [isLoading, setIsLoading] = useState(false); - const { exit } = useApp(); - const [order, setOrder] = useState(null); - - // biome-ignore lint/correctness/useExhaustiveDependencies: submitOrder reads props but we don't want to re-create callback when props change. - const handleSubmit = useCallback( - (submitValue: boolean) => { - if (submitValue === false) { - setIsLoading(false); - exit(); - return; - } - - submitOrder(); - }, - [exit], - ); - - async function submitOrder() { - setIsLoading(true); - // Place the sell order - const placedOrder = await placeSellOrder({ - price: props.price, - contractId: props.contractId, - quantity: props.size, - startAt: props.startAt, - endsAt: props.endsAt, - flags: props.flags, - }); - setOrder(placedOrder); - } - - // biome-ignore lint/correctness/useExhaustiveDependencies: submitOrder reads props but we only want to run on mount if autoConfirm is true. See: https://react.dev/blog/2025/10/01/react-19-2#use-effect-event - useEffect(() => { - if (props.autoConfirm) { - submitOrder(); - } - }, [props.autoConfirm]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: Polling interval reads current state but shouldn't restart on state changes. See: https://react.dev/blog/2025/10/01/react-19-2#use-effect-event - useEffect(() => { - let interval: ReturnType | null = null; - if (isLoading) { - interval = setInterval(async () => { - if (!isLoading) { - exit(); - } - - if (!order) { - return; - } - - const o = await getOrder(order?.id); - setOrder(o); - if (o) { - setTimeout(exit, 0); - } - }, 200); - } - - return () => { - if (interval) { - clearInterval(interval); - } - }; - }, [isLoading]); - - return ( - - - - {!isLoading && !props.autoConfirm && ( - - Place order? (y/n) - - - - )} - - {isLoading && ( - - {(!order || order.status === "pending") && } - {order && order.status === "open" && } - {!order && Placing order...} - {order && ( - - Order placed: {order.id} - - ({order.status}) - - )} - - )} - - {order && order.status === "open" && ( - - - Your order is open, but not filled. You can check its status with... - - - sf orders ls - - - Or you can cancel it with... - - sf orders cancel {order.id} - - - )} - - ); -} - -function SellOrderPreview(props: { - price: number; - contractId: string; - size: number; - startAt: Date | "NOW"; - endsAt: Date; - flags?: SellOrderFlags; -}) { - const startDate = props.startAt === "NOW" ? dayjs() : dayjs(props.startAt); - const start = startDate.format("MMM D h:mm a").toLowerCase(); - - const startFromNow = startDate.fromNow(); - - const endDate = roundEndDate(props.endsAt); - const end = endDate.format("MMM D h:mm a").toLowerCase(); - - const endFromNow = endDate.fromNow(); - - const realDuration = endDate.diff(startDate); - const realDurationHours = realDuration / 3600 / 1000; - const realDurationString = ms(realDuration); - - const totalPrice = - getTotalPrice(props.price, props.size, realDurationHours) / 100; - - return ( - - Sell Order - - - - start - - - {start} - - {props.startAt === "NOW" ? "(now)" : `(${startFromNow})`} - - - - - - end - - - {end} - ({endFromNow}) - - - - - - - - ); -} - -export async function placeSellOrder(options: { - price: number; - contractId: string; - quantity: number; - startAt: Date | "NOW"; - endsAt: Date; - flags?: SellOrderFlags; -}) { - const realDurationHours = - dayjs(options.endsAt).diff( - dayjs(options.startAt === "NOW" ? new Date() : options.startAt), - ) / - 3600 / - 1000; - const totalPrice = getTotalPrice( - options.price, - options.quantity, - realDurationHours, - ); - invariant( - totalPrice === Math.ceil(totalPrice), - "totalPrice must be a whole number", - ); - - const api = await apiClient(); - const { data, error, response } = await api.POST("/v0/orders", { - body: { - side: "sell", - price: totalPrice, - contract_id: options.contractId, - quantity: options.quantity, - start_at: - options.startAt === "NOW" ? "NOW" : options.startAt.toISOString(), - end_at: options.endsAt.toISOString(), - flags: options.flags || {}, - }, - }); - - if (!response.ok) { - switch (response.status) { - case 400: - return logAndQuit(`Bad Request: ${error?.message}`); - case 401: - return await logSessionTokenExpiredAndQuit(); - case 500: - return logAndQuit(`Failed to place order: ${error?.message}`); - default: - return logAndQuit(`Failed to place order: ${response.statusText}`); - } - } - - if (!data) { - return logAndQuit( - `Failed to place order: Unexpected response from server: ${response}`, - ); - } - - return data; -} - -export async function getOrder(orderId: string) { - const api = await apiClient(); - - const { - data: order, - error, - response, - } = await api.GET("/v0/orders/{id}", { - params: { path: { id: orderId } }, - }); - - if (error) { - // @ts-expect-error -- TODO: FIXME: include error in OpenAPI schema output - if (error?.code === "order.not_found" || response.status === 404) { - return undefined; - } - - return logAndQuit(`Failed to get order: ${error.message}`); - } - - return order; -} From c4ccb43a6ecabdcae820e3649aa36aa9122bbff8 Mon Sep 17 00:00:00 2001 From: Gaelan Chen Date: Mon, 9 Feb 2026 19:03:02 -0800 Subject: [PATCH 2/4] Fix biome import ordering and formatting Co-Authored-By: Claude Opus 4.6 --- src/helpers/quote.ts | 10 ++-------- src/lib/nodes/create.ts | 2 +- src/lib/nodes/extend.ts | 2 +- src/lib/orders/OrderDisplay.tsx | 2 +- src/lib/scale/ConfirmationMessage.tsx | 3 +-- src/lib/scale/ProcurementDisplay.tsx | 3 +-- src/lib/scale/create.tsx | 3 +-- 7 files changed, 8 insertions(+), 17 deletions(-) diff --git a/src/helpers/quote.ts b/src/helpers/quote.ts index 312d44a4..06662e45 100644 --- a/src/helpers/quote.ts +++ b/src/helpers/quote.ts @@ -1,14 +1,8 @@ import dayjs from "dayjs"; import { apiClient } from "../apiClient.ts"; -import { - logAndQuit, - logSessionTokenExpiredAndQuit, -} from "./errors.ts"; -import { - parseStartDateOrNow, - roundDateUpToNextMinute, -} from "./units.ts"; import { GPUS_PER_NODE } from "../lib/constants.ts"; +import { logAndQuit, logSessionTokenExpiredAndQuit } from "./errors.ts"; +import { parseStartDateOrNow, roundDateUpToNextMinute } from "./units.ts"; export function getPricePerGpuHourFromQuote( quote: Pick, "start_at" | "end_at" | "price" | "quantity">, diff --git a/src/lib/nodes/create.ts b/src/lib/nodes/create.ts index 14873524..6d89ad81 100644 --- a/src/lib/nodes/create.ts +++ b/src/lib/nodes/create.ts @@ -13,13 +13,13 @@ import ora from "ora"; import { logAndQuit } from "../../helpers/errors.ts"; import { formatDate } from "../../helpers/format-date.ts"; +import { getPricePerGpuHourFromQuote, getQuote } from "../../helpers/quote.ts"; import { parseStartDate, roundStartDate, selectTime, } from "../../helpers/units.ts"; import { handleNodesError, nodesClient } from "../../nodesClient.ts"; -import { getPricePerGpuHourFromQuote, getQuote } from "../../helpers/quote.ts"; import { GPUS_PER_NODE } from "../constants.ts"; import { createNodesTable, diff --git a/src/lib/nodes/extend.ts b/src/lib/nodes/extend.ts index f434472e..780d8a39 100644 --- a/src/lib/nodes/extend.ts +++ b/src/lib/nodes/extend.ts @@ -8,9 +8,9 @@ import { formatDuration } from "date-fns/formatDuration"; import { intervalToDuration } from "date-fns/intervalToDuration"; import dayjs from "dayjs"; import ora from "ora"; +import { getPricePerGpuHourFromQuote, getQuote } from "../../helpers/quote.ts"; import { selectTime } from "../../helpers/units.ts"; import { handleNodesError, nodesClient } from "../../nodesClient.ts"; -import { getPricePerGpuHourFromQuote, getQuote } from "../../helpers/quote.ts"; import { GPUS_PER_NODE } from "../constants.ts"; import { createNodesTable, diff --git a/src/lib/orders/OrderDisplay.tsx b/src/lib/orders/OrderDisplay.tsx index 2579051f..14947dcc 100644 --- a/src/lib/orders/OrderDisplay.tsx +++ b/src/lib/orders/OrderDisplay.tsx @@ -2,9 +2,9 @@ import process from "node:process"; import dayjs from "dayjs"; import { Box, measureElement, Text, useInput } from "ink"; import React, { useEffect } from "react"; +import { formatDuration } from "../../helpers/format-duration.ts"; import { GPUS_PER_NODE } from "../constants.ts"; import { Row } from "../Row.tsx"; -import { formatDuration } from "../../helpers/format-duration.ts"; import type { HydratedOrder } from "./types.ts"; export function orderDetails(order: HydratedOrder) { diff --git a/src/lib/scale/ConfirmationMessage.tsx b/src/lib/scale/ConfirmationMessage.tsx index 6deee312..584681e0 100644 --- a/src/lib/scale/ConfirmationMessage.tsx +++ b/src/lib/scale/ConfirmationMessage.tsx @@ -1,7 +1,6 @@ import { Box, Text } from "ink"; - -import { InstanceTypeMetadata } from "../../helpers/instance-types-meta.ts"; import { formatDuration } from "../../helpers/format-duration.ts"; +import { InstanceTypeMetadata } from "../../helpers/instance-types-meta.ts"; import { Row } from "../Row.tsx"; import { diff --git a/src/lib/scale/ProcurementDisplay.tsx b/src/lib/scale/ProcurementDisplay.tsx index 86e30c2d..f6f5cfeb 100644 --- a/src/lib/scale/ProcurementDisplay.tsx +++ b/src/lib/scale/ProcurementDisplay.tsx @@ -1,9 +1,8 @@ import { Badge } from "@inkjs/ui"; import { Box, Text } from "ink"; - +import { formatDuration } from "../../helpers/format-duration.ts"; import { InstanceTypeMetadata } from "../../helpers/instance-types-meta.ts"; import { GPUS_PER_NODE } from "../constants.ts"; -import { formatDuration } from "../../helpers/format-duration.ts"; import { Row } from "../Row.tsx"; import { formatColocationStrategy, type Procurement } from "./utils.ts"; diff --git a/src/lib/scale/create.tsx b/src/lib/scale/create.tsx index f6ccd0dd..bad2cf83 100644 --- a/src/lib/scale/create.tsx +++ b/src/lib/scale/create.tsx @@ -10,10 +10,9 @@ import type React from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { apiClient } from "../../apiClient.ts"; import { logAndQuit } from "../../helpers/errors.ts"; +import { getQuote } from "../../helpers/quote.ts"; import { roundDateUpToNextMinute } from "../../helpers/units.ts"; - import type { components } from "../../schema.ts"; -import { getQuote } from "../../helpers/quote.ts"; import ConfirmInput from "../ConfirmInput.tsx"; import { GPUS_PER_NODE } from "../constants.ts"; import { pluralizeNodes } from "../nodes/utils.ts"; From a5585ca23769c8f4fe126b44da937b83327514ca Mon Sep 17 00:00:00 2001 From: Gaelan Chen Date: Tue, 10 Feb 2026 12:44:15 -0800 Subject: [PATCH 3/4] perf: remove sf extend, code reformatting --- src/helpers/format-duration.ts | 28 ------ .../{format-date.ts => format-time.ts} | 29 +++++++ src/helpers/quote.ts | 26 +----- src/helpers/units.ts | 2 +- src/index.ts | 2 - src/lib/extend/index.tsx | 87 ------------------- src/lib/nodes/create.ts | 2 +- src/lib/nodes/image/list.tsx | 2 +- src/lib/nodes/image/show.tsx | 2 +- src/lib/nodes/list.tsx | 2 +- src/lib/nodes/utils.ts | 2 +- src/lib/orders/OrderDisplay.tsx | 2 +- src/lib/scale/ConfirmationMessage.tsx | 2 +- src/lib/scale/ProcurementDisplay.tsx | 2 +- src/lib/tokens.ts | 2 +- 15 files changed, 43 insertions(+), 149 deletions(-) delete mode 100644 src/helpers/format-duration.ts rename src/helpers/{format-date.ts => format-time.ts} (81%) delete mode 100644 src/lib/extend/index.tsx diff --git a/src/helpers/format-duration.ts b/src/helpers/format-duration.ts deleted file mode 100644 index c6e23ae6..00000000 --- a/src/helpers/format-duration.ts +++ /dev/null @@ -1,28 +0,0 @@ -import dayjs from "dayjs"; -import duration from "dayjs/plugin/duration"; - -dayjs.extend(duration); - -export function formatDuration(ms: number) { - const d = dayjs.duration(ms); - - const years = Math.floor(d.asYears()); - const weeks = Math.floor(d.asWeeks()) % 52; - const days = d.days(); - const hours = d.hours(); - const minutes = d.minutes(); - const seconds = d.seconds(); - const milliseconds = d.milliseconds(); - - let result = ""; - - if (years > 0) result += `${years}y`; - if (weeks > 0) result += `${weeks}w`; - if (days > 0) result += `${days}d`; - if (hours > 0) result += `${hours}h`; - if (minutes > 0) result += `${minutes}m`; - if (seconds > 0) result += `${seconds}s`; - if (milliseconds > 0) result += `${milliseconds}ms`; - - return result || "0ms"; -} diff --git a/src/helpers/format-date.ts b/src/helpers/format-time.ts similarity index 81% rename from src/helpers/format-date.ts rename to src/helpers/format-time.ts index 3d7cce2b..083ad366 100644 --- a/src/helpers/format-date.ts +++ b/src/helpers/format-time.ts @@ -7,9 +7,11 @@ import { } from "date-fns"; import dayjs, { type Dayjs } from "dayjs"; import utc from "dayjs/plugin/utc"; +import duration from "dayjs/plugin/duration"; import { formatDateRange } from "little-date"; dayjs.extend(utc); +dayjs.extend(duration); const shortenAmPm = (text: string): string => { const shortened = (text || "").replace(/ AM/g, "am").replace(/ PM/g, "pm"); @@ -133,3 +135,30 @@ export const formatDateAsUTC = (date: Dayjs): string => { return `${utcDate.format("MMM D")}, ${timeStr} UTC`; }; + +/** + * Formats a duration in milliseconds to a compact string like "1d2h30m". + */ +export function formatDuration(ms: number) { + const d = dayjs.duration(ms); + + const years = Math.floor(d.asYears()); + const weeks = Math.floor(d.asWeeks()) % 52; + const days = d.days(); + const hours = d.hours(); + const minutes = d.minutes(); + const seconds = d.seconds(); + const milliseconds = d.milliseconds(); + + let result = ""; + + if (years > 0) result += `${years}y`; + if (weeks > 0) result += `${weeks}w`; + if (days > 0) result += `${days}d`; + if (hours > 0) result += `${hours}h`; + if (minutes > 0) result += `${minutes}m`; + if (seconds > 0) result += `${seconds}s`; + if (milliseconds > 0) result += `${milliseconds}ms`; + + return result || "0ms"; +} \ No newline at end of file diff --git a/src/helpers/quote.ts b/src/helpers/quote.ts index 06662e45..9ec631f3 100644 --- a/src/helpers/quote.ts +++ b/src/helpers/quote.ts @@ -1,6 +1,7 @@ import dayjs from "dayjs"; import { apiClient } from "../apiClient.ts"; import { GPUS_PER_NODE } from "../lib/constants.ts"; +import type { components } from "../schema.ts"; import { logAndQuit, logSessionTokenExpiredAndQuit } from "./errors.ts"; import { parseStartDateOrNow, roundDateUpToNextMinute } from "./units.ts"; @@ -22,7 +23,7 @@ export function getPricePerGpuHourFromQuote( return quote.price / GPUS_PER_NODE / quote.quantity / durationHours; } -type QuoteOptions = { +export async function getQuote(options: { instanceType?: string; quantity: number; minStartTime: Date | "NOW"; @@ -31,9 +32,7 @@ type QuoteOptions = { maxDurationSeconds: number; cluster?: string; colocateWith?: string; -}; - -export async function getQuote(options: QuoteOptions) { +}) { const api = await apiClient(); const params = { @@ -96,21 +95,4 @@ export async function getQuote(options: QuoteOptions) { }; } -export type Quote = - | { - price: number; - quantity: number; - start_at: string; - end_at: string; - instance_type: string; - zone?: string; - } - | { - price: number; - quantity: number; - start_at: string; - end_at: string; - contract_id: string; - zone?: string; - } - | null; +export type Quote = components["schemas"]["quoter_ApiQuoteDetails"] | null; diff --git a/src/helpers/units.ts b/src/helpers/units.ts index 4213981a..48f2906b 100644 --- a/src/helpers/units.ts +++ b/src/helpers/units.ts @@ -5,7 +5,7 @@ import dayjs from "dayjs"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; import type { Nullable } from "../types/empty.ts"; -import { formatDate, formatDateAsUTC } from "./format-date.ts"; +import { formatDate, formatDateAsUTC } from "./format-time.ts"; dayjs.extend(utc); dayjs.extend(timezone); diff --git a/src/index.ts b/src/index.ts index 9c15f08f..c8699fae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,7 +19,6 @@ import { getAppBanner } from "./lib/app-banner.ts"; import { registerBalance } from "./lib/balance.ts"; import { registerContracts } from "./lib/contracts/index.tsx"; import { registerDev } from "./lib/dev.ts"; -import { registerExtend } from "./lib/extend/index.tsx"; import { registerLogin } from "./lib/login.ts"; import { registerMe } from "./lib/me.ts"; import { registerNodes } from "./lib/nodes/index.ts"; @@ -44,7 +43,6 @@ async function main() { // commands registerLogin(program); - registerExtend(program); registerContracts(program); registerBalance(program); registerTokens(program); diff --git a/src/lib/extend/index.tsx b/src/lib/extend/index.tsx deleted file mode 100644 index 8f168b03..00000000 --- a/src/lib/extend/index.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import console from "node:console"; -import process from "node:process"; -import type { Command } from "@commander-js/extra-typings"; -import boxen from "boxen"; -import ora from "ora"; -import { apiClient } from "../../apiClient.ts"; - -function _registerExtend(program: Command) { - return program - .command("extend") - .description("Extend an existing contract") - .requiredOption( - "-c, --contract ", - "Contract ID to extend e.g. cont_a9IcKaLesUBTHEY", - ) - .requiredOption( - "-d, --duration ", - "Extension duration (rounded up to the nearest hour)", - ) - .option( - "-p, --price ", - "Sets the maximize price per gpu/hr you're willing to pay. If the market rate is lower, then you'll pay the market rate", - ) - .option("-y, --yes", "Automatically confirm the extension") - .option( - "-q, --quote", - "Get a price quote without placing an extension order", - ) - .option( - "--standing", - "Places a standing order. Default behavior is to place an order that auto-cancels if it can't be filled immediately.", - ) - .addHelpText( - "before", - ` -${boxen( - `\x1b[31m\x1b[97msf extend\x1b[31m is deprecated.\x1b[0m - \x1b[31mTo create, extend, and release specific machines directly, use \x1b[97msf nodes\x1b[31m.\x1b[0m - \x1b[31mFor example: \x1b[97msf nodes extend --duration 3600 --max-price 12.50\x1b[31m.\x1b[0m`, - { - padding: 0.75, - borderColor: "red", - }, -)} -`, - ) - .action(async function extendAction(options) { - const spinner = ora().start(`Fetching contract ${options.contract}...`); - const api = await apiClient(); - - const { data: contract, response } = await api.GET("/v0/contracts/{id}", { - params: { - path: { id: options.contract }, - }, - }); - - const fetchFailed = !response.ok || !contract; - if (fetchFailed) { - spinner.fail( - `Failed to fetch contract ${options.contract}: ${response.statusText}`, - ); - } else { - spinner.clear(); - } - - console.error( - boxen( - `\x1b[31m\x1b[97msf extend\x1b[31m is deprecated.\x1b[0m - \x1b[31mTo create, extend, and release specific machines directly, use \x1b[97msf nodes\x1b[31m.\x1b[0m - \x1b[31mFor example: \x1b[97msf nodes extend --duration 3600 --max-price 12.50\x1b[31m.\x1b[0m`, - { - padding: 0.75, - borderColor: "red", - }, - ), - ); - - if (fetchFailed) { - process.exit(1); - } - process.exit(0); - }); -} - -export function registerExtend(program: Command) { - _registerExtend(program); -} diff --git a/src/lib/nodes/create.ts b/src/lib/nodes/create.ts index 6d89ad81..5f2ef456 100644 --- a/src/lib/nodes/create.ts +++ b/src/lib/nodes/create.ts @@ -12,7 +12,7 @@ import utc from "dayjs/plugin/utc"; import ora from "ora"; import { logAndQuit } from "../../helpers/errors.ts"; -import { formatDate } from "../../helpers/format-date.ts"; +import { formatDate } from "../../helpers/format-time.ts"; import { getPricePerGpuHourFromQuote, getQuote } from "../../helpers/quote.ts"; import { parseStartDate, diff --git a/src/lib/nodes/image/list.tsx b/src/lib/nodes/image/list.tsx index 2d0fd367..5de72c41 100644 --- a/src/lib/nodes/image/list.tsx +++ b/src/lib/nodes/image/list.tsx @@ -6,7 +6,7 @@ import ora from "ora"; import { getAuthToken } from "../../../helpers/config.ts"; import { logAndQuit } from "../../../helpers/errors.ts"; -import { formatDate } from "../../../helpers/format-date.ts"; +import { formatDate } from "../../../helpers/format-time.ts"; import { handleNodesError, nodesClient } from "../../../nodesClient.ts"; const list = new Command("list") diff --git a/src/lib/nodes/image/show.tsx b/src/lib/nodes/image/show.tsx index 40479cce..9456fe89 100644 --- a/src/lib/nodes/image/show.tsx +++ b/src/lib/nodes/image/show.tsx @@ -8,7 +8,7 @@ import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; import { Box, render, Text } from "ink"; import Link from "ink-link"; -import { formatDate } from "../../../helpers/format-date.ts"; +import { formatDate } from "../../../helpers/format-time.ts"; import { handleNodesError, nodesClient } from "../../../nodesClient.ts"; import { Row } from "../../Row.tsx"; diff --git a/src/lib/nodes/list.tsx b/src/lib/nodes/list.tsx index 73984afb..3350dd7d 100644 --- a/src/lib/nodes/list.tsx +++ b/src/lib/nodes/list.tsx @@ -15,7 +15,7 @@ import { logAndQuit } from "../../helpers/errors.ts"; import { formatDate, formatNullableDateRange, -} from "../../helpers/format-date.ts"; +} from "../../helpers/format-time.ts"; import { handleNodesError, nodesClient } from "../../nodesClient.ts"; import { Row } from "../Row.tsx"; import { diff --git a/src/lib/nodes/utils.ts b/src/lib/nodes/utils.ts index e532d8fb..f9b16ae4 100644 --- a/src/lib/nodes/utils.ts +++ b/src/lib/nodes/utils.ts @@ -8,7 +8,7 @@ import timezone from "dayjs/plugin/timezone.js"; import utc from "dayjs/plugin/utc.js"; import { parseDurationArgument } from "../../helpers/duration.ts"; import { logAndQuit } from "../../helpers/errors.ts"; -import { formatNullableDateRange } from "../../helpers/format-date.ts"; +import { formatNullableDateRange } from "../../helpers/format-time.ts"; import { parseStartDateOrNow } from "../../helpers/units.ts"; dayjs.extend(utc); diff --git a/src/lib/orders/OrderDisplay.tsx b/src/lib/orders/OrderDisplay.tsx index 14947dcc..4e03348b 100644 --- a/src/lib/orders/OrderDisplay.tsx +++ b/src/lib/orders/OrderDisplay.tsx @@ -2,7 +2,7 @@ import process from "node:process"; import dayjs from "dayjs"; import { Box, measureElement, Text, useInput } from "ink"; import React, { useEffect } from "react"; -import { formatDuration } from "../../helpers/format-duration.ts"; +import { formatDuration } from "../../helpers/format-time.ts"; import { GPUS_PER_NODE } from "../constants.ts"; import { Row } from "../Row.tsx"; import type { HydratedOrder } from "./types.ts"; diff --git a/src/lib/scale/ConfirmationMessage.tsx b/src/lib/scale/ConfirmationMessage.tsx index 584681e0..b0847c2e 100644 --- a/src/lib/scale/ConfirmationMessage.tsx +++ b/src/lib/scale/ConfirmationMessage.tsx @@ -1,5 +1,5 @@ import { Box, Text } from "ink"; -import { formatDuration } from "../../helpers/format-duration.ts"; +import { formatDuration } from "../../helpers/format-time.ts"; import { InstanceTypeMetadata } from "../../helpers/instance-types-meta.ts"; import { Row } from "../Row.tsx"; diff --git a/src/lib/scale/ProcurementDisplay.tsx b/src/lib/scale/ProcurementDisplay.tsx index f6f5cfeb..17162d8d 100644 --- a/src/lib/scale/ProcurementDisplay.tsx +++ b/src/lib/scale/ProcurementDisplay.tsx @@ -1,6 +1,6 @@ import { Badge } from "@inkjs/ui"; import { Box, Text } from "ink"; -import { formatDuration } from "../../helpers/format-duration.ts"; +import { formatDuration } from "../../helpers/format-time.ts"; import { InstanceTypeMetadata } from "../../helpers/instance-types-meta.ts"; import { GPUS_PER_NODE } from "../constants.ts"; import { Row } from "../Row.tsx"; diff --git a/src/lib/tokens.ts b/src/lib/tokens.ts index cbfd3d7c..e0809935 100644 --- a/src/lib/tokens.ts +++ b/src/lib/tokens.ts @@ -10,7 +10,7 @@ import { logLoginMessageAndQuit, logSessionTokenExpiredAndQuit, } from "../helpers/errors.ts"; -import { formatDate } from "../helpers/format-date.ts"; +import { formatDate } from "../helpers/format-time.ts"; import { getApiUrl } from "../helpers/urls.ts"; export const TOKEN_EXPIRATION_SECONDS = { From 4ed1c22f058fe95302c32bde8cc501a1a5d1b782 Mon Sep 17 00:00:00 2001 From: Gaelan Chen Date: Tue, 10 Feb 2026 12:46:01 -0800 Subject: [PATCH 4/4] Fix biome ci issues --- src/helpers/format-time.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/helpers/format-time.ts b/src/helpers/format-time.ts index 083ad366..b6a93c25 100644 --- a/src/helpers/format-time.ts +++ b/src/helpers/format-time.ts @@ -6,8 +6,8 @@ import { startOfDay, } from "date-fns"; import dayjs, { type Dayjs } from "dayjs"; -import utc from "dayjs/plugin/utc"; import duration from "dayjs/plugin/duration"; +import utc from "dayjs/plugin/utc"; import { formatDateRange } from "little-date"; dayjs.extend(utc); @@ -161,4 +161,4 @@ export function formatDuration(ms: number) { if (milliseconds > 0) result += `${milliseconds}ms`; return result || "0ms"; -} \ No newline at end of file +}