Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
09878ca
Add nodes get command with flexible node retrieval and display options
cursoragent Aug 15, 2025
9575132
fix: change get to tsx file to imply JSX pragma
sigmachirality Aug 28, 2025
98ef5dd
fix: breaking change in nodes /v1/list
sigmachirality Aug 28, 2025
972dbb9
fix: import new tsx file
sigmachirality Aug 28, 2025
069f33c
build: update OAPI schema build artifact
sigmachirality Aug 28, 2025
ffe162e
fix: instance type is also optional in quote
sigmachirality Aug 28, 2025
dce16cb
fix: [ENG-2206] make `--type` optional if `--cluster` or `--colocate`…
sigmachirality Aug 29, 2025
7f74f54
release: v0.20.1
github-actions[bot] Aug 29, 2025
006eb60
feat: [ENG-2203] node create accepts cloud-init user data script (#189)
sigmachirality Sep 5, 2025
bd118a3
release: v0.20.2
github-actions[bot] Sep 5, 2025
c2788e3
fix: fix race condition in poll loop (#190)
Sladuca Sep 5, 2025
2786d30
feat: [ENG-2108], [ENG-2157] implement `sf vms images` CRUD commands …
sigmachirality Sep 12, 2025
457c9a0
release: v0.21.0
github-actions[bot] Sep 12, 2025
b825d75
fix(nodes/create): Add duration to first example (#195)
joshi4 Sep 13, 2025
5cc9fb0
release: v0.21.1
github-actions[bot] Sep 13, 2025
b6f0586
fix: Update src/lib/vm/image/list.tsx
sigmachirality Sep 16, 2025
133b91d
fix: make `getPricePerGpuHourFromQuote` only require fields it actual…
sigmachirality Sep 16, 2025
cc0ed62
fix: make `sf nodes extend|release` support node ids
sigmachirality Sep 16, 2025
a952dde
fix: `sf nodes get` should only output json when `--json` is specified
sigmachirality Sep 16, 2025
fb99f85
perf: remove unnecessary variable reassignment
sigmachirality Sep 16, 2025
73971fb
chore: remove duplicate example
sigmachirality Sep 16, 2025
d2386f5
fix: update copy to not say `$N/A/hr`
sigmachirality Sep 16, 2025
91f64bc
fix: `sf extend` quotes from end of node
sigmachirality Sep 16, 2025
90e34f4
Merge branch 'main' into cursor/refactor-and-add-node-get-commands-21c7
sigmachirality Sep 16, 2025
3424699
fix: slice properlly
sigmachirality Sep 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/lib/buy/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -746,7 +746,9 @@ export async function placeBuyOrder(options: {
return data;
}

export function getPricePerGpuHourFromQuote(quote: NonNullable<Quote>) {
export function getPricePerGpuHourFromQuote(
quote: Pick<NonNullable<Quote>, "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.
Expand Down
85 changes: 56 additions & 29 deletions src/lib/nodes/extend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import {
import SFCNodes from "@sfcompute/nodes-sdk-alpha";
import { getPricePerGpuHourFromQuote, getQuote } from "../buy/index.tsx";
import { GPUS_PER_NODE } from "../constants.ts";
import { logAndQuit } from "../../helpers/errors.ts";
import { formatDuration } from "date-fns/formatDuration";
import { intervalToDuration } from "date-fns/intervalToDuration";

const extend = new Command("extend")
.description("Extend the duration of reserved nodes and update their pricing")
Expand Down Expand Up @@ -58,17 +59,19 @@ async function extendNodeAction(
try {
const client = await nodesClient();

// Fetch all nodes and filter by provided names/IDs
const fetchSpinner = ora().start();
const { data: allNodes } = await client.nodes.list();
// Use the API's names parameter to filter nodes directly
const fetchSpinner = ora().start(
`Checking ${nodeNames.length} ${pluralizeNodes(nodeNames.length)}...`,
);
const { data: fetchedNodes } = await client.nodes.list({ name: nodeNames });
fetchSpinner.stop();

// Filter nodes that match the provided names/IDs
// Check which names were not found
const nodes: { name: string; node: SFCNodes.Node }[] = [];
const notFound: string[] = [];

for (const nameOrId of nodeNames) {
const node = allNodes.find((n) =>
const node = fetchedNodes.find((n) =>
n.name === nameOrId || n.id === nameOrId
);
if (node) {
Expand Down Expand Up @@ -124,6 +127,16 @@ async function extendNodeAction(
process.exit(1);
}

const formattedDuration = formatDuration(
intervalToDuration({
start: 0,
end: options.duration! * 1000,
}),
{
delimiter: ", ",
},
);

// Only show pricing and get confirmation if not using --yes
if (!options.yes) {
// Get quote for accurate pricing preview
Expand All @@ -144,30 +157,46 @@ async function extendNodeAction(
durationSeconds + Math.ceil(durationSeconds * 0.1),
);

const quote = await getQuote({
instanceType: "h100v",
quantity: extendableNodes.length,
minStartTime: "NOW",
maxStartTime: "NOW",
minDurationSeconds: minDurationSeconds,
maxDurationSeconds: maxDurationSeconds,
});
const quotes = await Promise.allSettled(
extendableNodes.map(async ({ node }) => {
return await getQuote({
instanceType: `${node.gpu_type.toLowerCase()}v` as const,
quantity: 8,
minStartTime: node.end_at ? new Date(node.end_at * 1000) : "NOW",
maxStartTime: node.end_at ? new Date(node.end_at * 1000) : "NOW",
minDurationSeconds: minDurationSeconds,
maxDurationSeconds: maxDurationSeconds,
cluster: node.zone ?? undefined,
});
}),
);

const filteredQuotes = quotes.filter((quote) =>
quote.status === "fulfilled"
);

spinner.stop();

let confirmationMessage = `Extend ${extendableNodes.length} ${
pluralizeNodes(extendableNodes.length)
} for ${Math.round(durationSeconds / 3600 * 100) / 100} hours`;
} for ${formattedDuration}`;

if (quote) {
const pricePerGpuHour = getPricePerGpuHourFromQuote(quote);
// If there's only one node, show the price per node per hour
if (filteredQuotes.length === 1 && filteredQuotes[0].value) {
const pricePerGpuHour = getPricePerGpuHourFromQuote(
filteredQuotes[0].value,
);
const pricePerNodeHour = (pricePerGpuHour * GPUS_PER_NODE) / 100;
confirmationMessage += ` for ~$${pricePerNodeHour.toFixed(2)}/node/hr`;
} else if (filteredQuotes.length > 1) {
const totalPrice = filteredQuotes.reduce((acc, quote) => {
return acc + (quote.value?.price ?? 0);
}, 0);
// If there's multiple nodes, show the total price, as nodes could be on different zones or have different hardware
confirmationMessage += ` for ~$${totalPrice / 100}`;
} else {
logAndQuit(
red(
"No nodes available matching your requirements. This is likely due to insufficient capacity.",
),
confirmationMessage = red(
"No nodes available matching your requirements. This is likely due to insufficient capacity. Attempt to extend anyway",
);
}

Expand Down Expand Up @@ -201,6 +230,11 @@ async function extendNodeAction(
}
}

if (options.json) {
console.log(JSON.stringify(results.map((r) => r.node), null, 2));
process.exit(0);
}

if (results.length > 0) {
spinner.succeed(
`Successfully extended ${results.length} ${
Expand All @@ -221,19 +255,12 @@ async function extendNodeAction(
}
}

if (options.json) {
console.log(JSON.stringify(results.map((r) => r.node), null, 2));
process.exit(0);
}

if (results.length > 0) {
console.log(gray("\nExtended nodes:"));
console.log(createNodesTable(results.map((r) => r.node)));
console.log(
gray(
`\nDuration extended by: ${options.duration} seconds (${
Math.round(options.duration! / 3600 * 100) / 100
} hours)`,
`\nDuration extended by ${formattedDuration}`,
),
);
console.log(gray(`Max price: $${options.maxPrice.toFixed(2)}/hour`));
Expand Down
107 changes: 107 additions & 0 deletions src/lib/nodes/get.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import React from "react";
import { Command } from "@commander-js/extra-typings";
import { gray, red } from "jsr:@std/fmt/colors";
import console from "node:console";
import process from "node:process";
import ora from "ora";
import { render } from "ink";

import { handleNodesError, nodesClient } from "../../nodesClient.ts";
import { createNodesTable, jsonOption, pluralizeNodes } from "./utils.ts";
import { getAuthToken } from "../../helpers/config.ts";
import { logAndQuit } from "../../helpers/errors.ts";
import { NodesVerboseDisplay } from "./list.tsx";

const get = new Command("get")
.alias("show")
.description("Get detailed information about specific nodes")
.showHelpAfterError()
.argument("<names...>", "Node names to get information about")
.option("--short", "Show nodes in table format instead of verbose output")
.addOption(jsonOption)
.addHelpText(
"after",
`
Examples:\n
\x1b[2m# Get detailed information about specific nodes (verbose by default)\x1b[0m
$ sf nodes get node-1 node-2

\x1b[2m# Get nodes in table format\x1b[0m
$ sf nodes get node-1 node-2 --short

\x1b[2m# Get nodes in JSON format\x1b[0m
$ sf nodes get node-1 node-2 --json
`,
)
.action(getNodesAction);

async function getNodesAction(
nodeNames: string[],
options: ReturnType<typeof get.opts>,
) {
try {
const token = await getAuthToken();
if (!token) {
logAndQuit("Not logged in. Please run 'sf login' first.");
}
const client = await nodesClient(token);

// Use the API's names parameter to filter nodes directly
const spinner = ora("Fetching nodes...").start();
const { data: fetchedNodes } = await client.nodes.list({ name: nodeNames });
spinner.stop();

// Check which names were not found
const foundNames = new Set(fetchedNodes.map((node) => node.name));
const notFound: string[] = [];

for (const name of nodeNames) {
if (!foundNames.has(name)) {
notFound.push(name);
}
}

if (options.json) {
console.log(JSON.stringify(fetchedNodes, null, 2));
return;
}

if (notFound.length > 0) {
console.error(
red(
`Could not find ${notFound.length === 1 ? "this" : "these"} ${
pluralizeNodes(notFound.length)
}:`,
),
);
for (const name of notFound) {
console.error(` • ${name}`);
}
console.error();
}

if (fetchedNodes.length === 0) {
console.error("No nodes found.");
process.exit(1);
}

if (options.short) {
// Show table format
console.log(createNodesTable(fetchedNodes));
console.log(
gray(
`\nFound ${fetchedNodes.length} ${
pluralizeNodes(fetchedNodes.length)
}.`,
),
);
} else {
// Show verbose output by default
render(<NodesVerboseDisplay nodes={fetchedNodes} />);
}
} catch (err) {
handleNodesError(err);
}
}

export default get;
5 changes: 5 additions & 0 deletions src/lib/nodes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import list from "./list.tsx";
import release from "./release.ts";
import set from "./set.ts";
import extend from "./extend.ts";
import get from "./get.tsx";
import { isFeatureEnabled } from "../posthog.ts";

export async function registerNodes(program: Command) {
Expand Down Expand Up @@ -32,6 +33,9 @@ $ sf nodes create -n 2 -z hayesvalley --start +1h --duration 2d -p 15.00
\x1b[2m# List all nodes\x1b[0m
$ sf nodes list

\x1b[2m# Get detailed information about specific nodes\x1b[0m
$ sf nodes get my-node-name

\x1b[2m# Release a node\x1b[0m
$ sf nodes release my-node-name

Expand All @@ -46,6 +50,7 @@ $ sf nodes extend my-node-name --duration 3600 --max-price 12.50
// Attach sub-commands
nodes
.addCommand(list)
.addCommand(get)
.addCommand(extend)
.addCommand(release)
.addCommand(set)
Expand Down
2 changes: 1 addition & 1 deletion src/lib/nodes/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ function NodeVerboseDisplay({ node }: { node: SFCNodes.Node }) {
}

// Component for displaying multiple nodes in verbose format
function NodesVerboseDisplay({ nodes }: { nodes: SFCNodes.Node[] }) {
export function NodesVerboseDisplay({ nodes }: { nodes: SFCNodes.Node[] }) {
return (
<Box flexDirection="column" gap={1}>
{nodes.map((node, index) => (
Expand Down
8 changes: 4 additions & 4 deletions src/lib/nodes/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,17 @@ async function releaseNodesAction(
try {
const client = await nodesClient();

// Fetch and filter nodes for both dry run and confirmation
// Use the API's names parameter to filter nodes directly
const spinner = ora("Fetching nodes to release...").start();
const { data: allNodes } = await client.nodes.list();
const { data: fetchedNodes } = await client.nodes.list({ name: nodeNames });
spinner.stop();

// Filter nodes that match the provided names/IDs
// Check which names were not found
const foundNodes: { name: string; node: SFCNodes.Node }[] = [];
const notFound: string[] = [];

for (const nameOrId of nodeNames) {
const node = allNodes.find((n) =>
const node = fetchedNodes.find((n) =>
n.name === nameOrId || n.id === nameOrId
);
if (node) {
Expand Down
21 changes: 10 additions & 11 deletions src/lib/nodes/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { Command, CommanderError } from "@commander-js/extra-typings";
import ora from "ora";
import { gray } from "jsr:@std/fmt/colors";
import console from "node:console";
import type { SFCNodes } from "@sfcompute/nodes-sdk-alpha";

import { handleNodesError, nodesClient } from "../../nodesClient.ts";
import { maxPriceOption, pluralizeNodes } from "./utils.ts";
Expand All @@ -25,24 +24,24 @@ async function setNodesAction(
const client = await nodesClient();
const spinner = ora("Updating nodes...").start();

const { data: allNodes } = await client.nodes.list();
// Use the API's names parameter to filter nodes directly
const { data: fetchedNodes } = await client.nodes.list({ name: names });

const nodesToUpdate: SFCNodes.Node[] = [];
// Check which names were not found
const foundNames = new Set(fetchedNodes.map((node) => node.name));
const notFound: string[] = [];

for (const nameOrId of names) {
const node = allNodes.find((n) =>
n.name === nameOrId || n.id === nameOrId
);
if (node) nodesToUpdate.push(node);
else notFound.push(nameOrId);
for (const name of names) {
if (!foundNames.has(name)) {
notFound.push(name);
}
}

// Filter nodes that have procurement_id (auto reserved nodes)
const nodesWithProcurement = nodesToUpdate.filter((node) =>
const nodesWithProcurement = fetchedNodes.filter((node) =>
node.procurement_id
);
const nodesWithoutProcurement = nodesToUpdate.filter((node) =>
const nodesWithoutProcurement = fetchedNodes.filter((node) =>
!node.procurement_id
);

Expand Down
4 changes: 2 additions & 2 deletions src/lib/nodes/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export function createNodesTable(nodes: SFCNodes.Node[]): string {
const startEnd = formatNullableDateRange(startDate, endDate);

const maxPrice = node.max_price_per_node_hour
? (node.max_price_per_node_hour / 100).toFixed(2)
? `$${(node.max_price_per_node_hour / 100).toFixed(2)}/hr`
: "N/A";

const lastVm = node.vms?.data.sort((a, b) => b.updated_at - a.updated_at)
Expand All @@ -132,7 +132,7 @@ export function createNodesTable(nodes: SFCNodes.Node[]): string {
node.gpu_type,
node.zone || "N/A",
startEnd,
`$${maxPrice}/hr`,
maxPrice,
]);
}

Expand Down
Loading