diff --git a/deno.lock b/deno.lock index caac3328..5d68149f 100644 --- a/deno.lock +++ b/deno.lock @@ -1,7 +1,8 @@ { - "version": "4", + "version": "5", "specifiers": { "jsr:@std/assert@^1.0.11": "1.0.11", + "jsr:@std/fmt@*": "1.0.8", "jsr:@std/internal@^1.0.5": "1.0.5", "npm:@commander-js/extra-typings@^12.1.0": "12.1.0_commander@12.1.0", "npm:@inkjs/ui@2": "2.0.0_ink@5.2.0__@types+react@18.3.18__react@18.3.1_@types+react@18.3.18_react@18.3.1", @@ -56,6 +57,9 @@ "jsr:@std/internal" ] }, + "@std/fmt@1.0.8": { + "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" + }, "@std/internal@1.0.5": { "integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba" } @@ -422,8 +426,10 @@ "cli-table3@0.6.5": { "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", "dependencies": [ - "@colors/colors", "string-width@4.2.3" + ], + "optionalDependencies": [ + "@colors/colors" ] }, "cli-truncate@2.1.0": { @@ -596,6 +602,9 @@ "integrity": "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==", "dependencies": [ "@types/react@18.3.18" + ], + "optionalPeers": [ + "@types/react@>=18.0.0" ] }, "ink-text-input@3.3.0_ink@2.7.1__@types+react@18.3.18__react@16.14.0_react@16.14.0_@types+react@18.3.18": { @@ -639,6 +648,9 @@ "widest-line@3.1.0", "wrap-ansi@6.2.0", "yoga-layout-prebuilt" + ], + "optionalPeers": [ + "@types/react@>=16.8.0" ] }, "ink@5.2.0_@types+react@18.3.18_react@18.3.1": { @@ -670,6 +682,10 @@ "wrap-ansi@9.0.0", "ws", "yoga-layout" + ], + "optionalPeers": [ + "@types/react@>=18.0.0", + "react-devtools-core@^4.19.1" ] }, "inquirer@10.2.2": { @@ -689,7 +705,8 @@ "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", "dependencies": [ "ci-info" - ] + ], + "bin": true }, "is-fullwidth-code-point@2.0.0": { "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==" @@ -707,7 +724,8 @@ ] }, "is-in-ci@1.0.0": { - "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==" + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", + "bin": true }, "is-interactive@2.0.0": { "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==" @@ -752,7 +770,8 @@ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "dependencies": [ "js-tokens" - ] + ], + "bin": true }, "mime-db@1.52.0": { "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" @@ -849,7 +868,8 @@ ] }, "prettier@3.5.3": { - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==" + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "bin": true }, "prop-types@15.8.1": { "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", @@ -954,10 +974,12 @@ ] }, "semver@7.6.3": { - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==" + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "bin": true }, "semver@7.7.1": { - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==" + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "bin": true }, "shescape@2.1.1": { "integrity": "sha512-sAbmiCuXKxMGqPGe+nhwFJ/o++2D7Fzb8dwIO78Hnt/MGN5mbVvs4WNydZmxxVxkoJz4VuUBXDvhwsvqildYhA==", @@ -1094,7 +1116,8 @@ "integrity": "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==", "dependencies": [ "isexe" - ] + ], + "bin": true }, "widest-line@3.1.0": { "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", @@ -1133,10 +1156,15 @@ ] }, "ws@8.18.1": { - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==" + "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "optionalPeers": [ + "bufferutil@^4.0.1", + "utf-8-validate@>=5.0.2" + ] }, "yaml@2.6.1": { - "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==" + "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==", + "bin": true }, "yn@3.1.1": { "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==" diff --git a/src/helpers/urls.ts b/src/helpers/urls.ts index 521d5f34..ef7b031b 100644 --- a/src/helpers/urls.ts +++ b/src/helpers/urls.ts @@ -43,6 +43,8 @@ const apiPaths: Record> = { tokens_list: "/v0/tokens", tokens_delete_by_id: ({ id }: IdParams): string => `/v0/tokens/${id}`, + zones_list: "/v0/zones", + vms_instances_list: "/v0/vms/instances", vms_logs_list: "/v0/vms/logs", vms_replace: "/v0/vms/replace", diff --git a/src/index.ts b/src/index.ts index 95babfb3..ca9752a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ import { registerTokens } from "./lib/tokens.ts"; import { registerUpgrade } from "./lib/upgrade.ts"; import { registerVM } from "./lib/vm.ts"; import { registerScale } from "./lib/scale/index.tsx"; +import { registerZones } from "./lib/zones.tsx"; const program = new Command(); @@ -50,6 +51,7 @@ await registerScale(program); registerClusters(program); registerMe(program); registerVM(program); +await registerZones(program); // (development commands) registerDev(program); diff --git a/src/lib/buy/index.tsx b/src/lib/buy/index.tsx index e8a89178..82849da1 100644 --- a/src/lib/buy/index.tsx +++ b/src/lib/buy/index.tsx @@ -44,7 +44,10 @@ export function _registerBuy(program: Command) { .command("buy") .description("Place a buy order") .showHelpAfterError() - .requiredOption("-t, --type ", "Type of GPU", "h100i") + .option( + "-t, --type ", + "Type of GPU", + ) .option( "-n, --accelerators ", "Number of GPUs to purchase", @@ -97,7 +100,22 @@ export function _registerBuy(program: Command) { "--standing", "Places a standing order. Default behavior is to place an order that auto-cancels if it can't be filled immediately.", ) - .option("-c, --cluster ", "Send into a specific cluster") + .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 } = command.opts(); + if (!type && !zone && !cluster) { + console.error(chalk.yellow("Must specify either --type or --zone")); + command.help(); + process.exit(1); + } + }) .configureHelp({ optionDescription: (option) => { if (option.flags === "-h, --help") { @@ -135,10 +153,16 @@ Examples: * 4. If --yes isn't provided, ask for confirmation * 5. Place order */ - if (options.quote) { - render(); + // Normalize zone/cluster: prioritize zone over cluster for backward compatibility + const normalizedOptions = { + ...options, + cluster: options.zone || options.cluster, + }; + + if (normalizedOptions.quote) { + render(); } else { - render(); + render(); } }); } @@ -251,7 +275,7 @@ export function QuoteAndBuy(props: { options: SfBuyOptions }) { props.options; setOrderProps({ - type, + type: type ?? "h100i", // We still need to pass something even if --zone is provided price: pricePerGpuHour, size: accelerators / GPUS_PER_NODE, startAt, @@ -739,7 +763,7 @@ async function getQuoteFromParsedSfBuyOptions(options: SfBuyOptions) { ); return await getQuote({ - instanceType: options.type, + instanceType: options.type ?? "h100i", // We still need to pass something even if --zone is provided quantity, minStartTime: startsAt, maxStartTime: startsAt, diff --git a/src/lib/posthog.ts b/src/lib/posthog.ts index 23099d79..f82a5098 100644 --- a/src/lib/posthog.ts +++ b/src/lib/posthog.ts @@ -67,7 +67,7 @@ const trackEvent = ({ } }; -type FeatureFlags = "procurements"; +type FeatureFlags = "procurements" | "zones"; /** * Checks if a feature is enabled for the current user. diff --git a/src/lib/scale/create.tsx b/src/lib/scale/create.tsx index b9920ff5..6b1d05ff 100644 --- a/src/lib/scale/create.tsx +++ b/src/lib/scale/create.tsx @@ -91,14 +91,16 @@ function CreateProcurementCommand(props: CreateProcurementCommandProps) { React.ReactNode >(); + const clusterName = props.zone || props.cluster; + const nodesRequired = useMemo( () => acceleratorsToNodes(props.accelerators), [props.accelerators], ); const colocationStrategy = useMemo(() => { - if (props.cluster && props.colocationStrategy === "pinned") { - return { type: "pinned" as const, cluster_name: props.cluster }; + if (clusterName && props.colocationStrategy === "pinned") { + return { type: "pinned" as const, cluster_name: clusterName }; } return { type: props.colocationStrategy as Exclude< @@ -106,7 +108,7 @@ function CreateProcurementCommand(props: CreateProcurementCommandProps) { "pinned" >, }; - }, [props.cluster, props.colocationStrategy]); + }, [clusterName, props.colocationStrategy]); const [isQuoting, setIsQuoting] = useState(false); const [displayedPricePerGpuHourInCents, setDisplayedPricePerGpuHourInCents] = @@ -128,7 +130,7 @@ function CreateProcurementCommand(props: CreateProcurementCommandProps) { maxStartTime: "NOW", minDurationSeconds: quoteMinutes * 60, maxDurationSeconds: quoteMinutes * 60 + 3600, - cluster: props.cluster, + cluster: clusterName, }); setIsQuoting(false); @@ -158,7 +160,7 @@ function CreateProcurementCommand(props: CreateProcurementCommandProps) { nodesRequired, type: props.type, pricePerGpuHourInCents: limitPricePerGpuHourInCents, - cluster: props.cluster, + cluster: clusterName, colocationStrategy, }); } else { @@ -206,7 +208,7 @@ function CreateProcurementCommand(props: CreateProcurementCommandProps) { nodesRequired, type: props.type, pricePerGpuHourInCents: displayedPricePerGpuHourInCents, - cluster: props.cluster, + cluster: clusterName, colocationStrategy, }); }; @@ -298,10 +300,16 @@ $ sf scale create -n 8 --horizon '30m' parseAccelerators, ) .option("-t, --type ", "Specify node type", "h100i") + .addOption( + new Option( + "-z, --zone ", + "Only buy on the specified zone. If provided, \`-t\`/`--type` will be ignored.", + ).implies({ colocationStrategy: "pinned" as const }), + ) .addOption( new Option( "-c, --cluster ", - "Only buy on the specified cluster. If provided, \`-t\`/`--type` will be ignored.", + "Only buy on the specified cluster (deprecated, alias for --zone). If provided, \`-t\`/`--type` will be ignored.", ).implies({ colocationStrategy: "pinned" as const }), ) .addOption( @@ -330,10 +338,10 @@ $ sf scale create -n 8 --horizon '30m' ) .option("-y, --yes", "Automatically confirm the command.") .hook("preAction", (command) => { - const { colocationStrategy, cluster } = command.opts(); - if (colocationStrategy === "pinned" && !cluster) { + const { colocationStrategy, zone, cluster } = command.opts(); + if (colocationStrategy === "pinned" && !(zone || cluster)) { console.error( - "Invalid colocation strategy: `-c`/`--cluster` is required when using `pinned` colocation strategy.", + "Invalid colocation strategy: `-z`/`--zone` or `-c`/`--cluster` is required when using `pinned` colocation strategy.", ); command.help(); process.exit(1); diff --git a/src/lib/zones.tsx b/src/lib/zones.tsx new file mode 100644 index 00000000..4a9aba20 --- /dev/null +++ b/src/lib/zones.tsx @@ -0,0 +1,213 @@ +import type { Command } from "@commander-js/extra-typings"; +import { Box, render, Text } from "ink"; +import Table from "cli-table3"; +import { cyan, gray, green, red } from "jsr:@std/fmt/colors"; +import * as console from "node:console"; +import React from "react"; +import { getAuthToken, isLoggedIn } from "../helpers/config.ts"; +import { + logAndQuit, + logLoginMessageAndQuit, + logSessionTokenExpiredAndQuit, +} from "../helpers/errors.ts"; +import { getApiUrl } from "../helpers/urls.ts"; +import { isFeatureEnabled } from "./posthog.ts"; + +type ZoneInfo = { + object: string; + name: string; + available: boolean; + available_capacity: number; + region: string; + hardware_type: string; + interconnect_type: string; + delivery_type: string; +}; + +type ZonesListResponse = { + object: string; + data: ZoneInfo[]; +}; + +// Delivery type conversion similar to InstanceTypeMetadata pattern +const DeliveryTypeMetadata: Record = { + "K8s": { displayName: "Kubernetes" }, + "K8sNamespace": { displayName: "Kubernetes" }, + "VM": { displayName: "Virtual Machine" }, +} as const; + +function formatDeliveryType(deliveryType: string): string { + return DeliveryTypeMetadata[deliveryType]?.displayName || deliveryType; +} + +// Region conversion to short slugs +const RegionMetadata: Record = { + "NorthAmerica": { slug: "North America" }, + "AsiaPacific": { slug: "Asia" }, + "EuropeMiddleEastAfrica": { slug: "EMEA" }, +} as const; + +function formatRegion(region: string): string { + return RegionMetadata[region]?.slug || region; +} + +export async function registerZones(program: Command) { + const isEnabled = await isFeatureEnabled("zones"); + if (!isEnabled) return; + + const zones = program + .command("zones") + .description("View zones") + .addHelpText( + "after", + ` +Examples: + \x1b[2m# List all zones\x1b[0m + $ sf zones ls + + \x1b[2m# List zones with JSON output\x1b[0m + $ sf zones ls --json + +Note: This is an early access feature (v0) that may change at any time. +`, + ); + zones + .command("list") + .alias("ls") + .description("List all zones") + .option("--json", "Output in JSON format") + .action(async (options) => { + await listZonesAction(options); + }); +} + +async function listZonesAction(options: { json?: boolean }) { + console.error( + `\x1b[33mNote: This is an early access feature (v0) and may change at any time.\x1b[0m\n`, + ); + + const loggedIn = await isLoggedIn(); + if (!loggedIn) { + logLoginMessageAndQuit(); + } + + const url = await getApiUrl("zones_list"); + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${await getAuthToken()}`, + }, + }); + + // Following clig.dev: Handle errors gracefully with actionable messages + if (!response.ok) { + switch (response.status) { + case 401: + return await logSessionTokenExpiredAndQuit(); + case 403: + return logAndQuit( + "Access denied. This feature may require special permissions. Reach out to hello@sfcompute.com if you need access.", + ); + case 404: + return logAndQuit( + "Zones not found. Please wait a few seconds and try again.", + ); + default: + return logAndQuit( + `Failed to fetch zones: ${response.status} ${response.statusText}`, + ); + } + } + + const data = (await response.json()) as ZonesListResponse; + + if (!data?.data) { + return logAndQuit( + "Failed to fetch zones: Unexpected response format from server", + ); + } + + // Filter out retired zones + // TODO: This is a temporary solution to filter out retired zones. + // Remove this once the backend has implemented soft deletion. + const retiredZones = ["alamo", "seacliff", "southbeach", "sunset"]; + const filteredZones = data.data.filter((zone) => + !retiredZones.includes(zone.name) + ); + + if (options.json) { + console.log(JSON.stringify(filteredZones, null, 2)); + return; + } + + // Following clig.dev: Human-readable by default with clear, informative output + displayZonesTable(filteredZones); +} + +function displayZonesTable(zones: ZoneInfo[]) { + if (zones.length === 0) { + render(); + return; + } + + // Sort zones so available ones come first, then alphabetically by name + const sortedZones = [...zones].sort((a, b) => { + // Available zones first (true comes before false) + if (a.available !== b.available) { + return b.available ? 1 : -1; + } + // Then sort by name alphabetically + return a.name.localeCompare(b.name); + }); + + const table = new Table({ + head: [ + cyan("Zone"), + cyan("Delivery Type"), + cyan("Available Nodes"), + cyan("GPU Type"), + cyan("Interconnect"), + cyan("Region"), + ], + style: { + head: [], + border: ["gray"], + }, + }); + + sortedZones.forEach((zone) => { + const availableNodesText = zone.available_capacity > 0 + ? green(zone.available_capacity.toString()) + : red(zone.available_capacity.toString()); + + table.push([ + zone.name, + formatDeliveryType(zone.delivery_type), + availableNodesText, + zone.hardware_type, + zone.interconnect_type || "None", + formatRegion(zone.region), + ]); + }); + + console.log(table.toString()); + console.log( + `\n${gray("Use zone names when placing orders or configuring nodes.")}\n`, + ); + console.log(gray("Examples:")); + console.log(` sf buy --zone ${green("alamo")}`); + console.log(` sf scale create -n 16 --zone ${green("alamo")}`); +} + +function EmptyZonesDisplay() { + return ( + + No zones found. + + # Check back later for available zones + sf zones ls + + + ); +}