Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Binary file modified bun.lockb
Binary file not shown.
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
"lint": "biome lint --write ./src",
"check": "biome check ./src",
"dev": "IS_DEVELOPMENT_CLI_ENV=true bun run src/index.ts",
"pack": "bun build src/index.ts --outfile dist/cli.js",
"release": "bun run src/scripts/release.ts",
"prod": "bun run src/index.ts"
},
Expand All @@ -15,7 +14,7 @@
"chrono-node": "^2.7.6",
"cli-table3": "^0.6.5",
"commander": "^12.1.0",
"dayjs": "^1.11.11",
"dayjs": "^1.11.12",
"dotenv": "^16.4.5",
"inquirer": "^10.1.2",
"node-fetch": "^3.3.2",
Expand Down
3 changes: 3 additions & 0 deletions src/helpers/command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function getCommandBase() {
return process.env.IS_DEVELOPMENT_CLI_ENV ? "bun dev" : "sf";
}
25 changes: 22 additions & 3 deletions src/helpers/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,20 @@ const ConfigDefaults = process.env.IS_DEVELOPMENT_CLI_ENV
? DevelopmentConfigDefaults
: ProductionConfigDefaults;

export async function saveConfig(config: Partial<Config>): Promise<void> {
// --

export async function saveConfig(
config: Partial<Config>,
): Promise<{ success: boolean }> {
const configPath = getConfigPath();
const configData = JSON.stringify(config, null, 2);

try {
await Bun.write(configPath, configData);
console.log("Config saved successfully.");

return { success: true };
} catch (error) {
console.error("Failed to save config:", error);
return { success: false };
}
}

Expand All @@ -41,6 +46,15 @@ export async function loadConfig(): Promise<Config> {
return { ...ConfigDefaults, ...configFileData };
}

export async function clearAuthFromConfig() {
const config = await loadConfig();

await saveConfig({
...config,
auth_token: undefined,
});
}

// only for development
export async function deleteConfig() {
const exists = await configFileExists();
Expand Down Expand Up @@ -104,3 +118,8 @@ export async function getAuthorizationHeader() {
const token = await getAuthToken();
return { Authorization: `Bearer ${token}` };
}

export async function isLoggedIn() {
const authToken = await getAuthToken();
return !!authToken;
}
52 changes: 49 additions & 3 deletions src/helpers/errors.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,58 @@
import { getCommandBase } from "./command";
import { clearAuthFromConfig } from "./config";

export interface ApiError {
object: "error";
code: string;
message: string;
details: Record<string, any>;
}

export const ApiErrorCode = {
Base: {
InvalidRequest: "invalid_request",
NotAuthenticated: "not_authenticated",
Unauthorized: "unauthorized",
NotFound: "not_found",
RouteNotFound: "route_not_found",
TooManyRequests: "too_many_requests",
InternalServer: "internal_server",
},
Accounts: {
NotFound: "account.not_found",
},
Orders: {
InvalidId: "order.invalid_id",
InvalidPrice: "order.invalid_price",
InvalidQuantity: "order.invalid_quantity",
InvalidStart: "order.invalid_start",
InvalidDuration: "order.invalid_duration",
InsufficientFunds: "order.insufficient_funds",
NotFound: "order.not_found",
},
Tokens: {
TokenNotFound: "token.not_found",
InvalidTokenCreateOriginClient: "token.invalid_token_create_origin_client",
InvalidTokenExpirationDuration: "token.invalid_token_expiration_duration",
MaxTokenLimitReached: "token.max_token_limit_reached",
},
};

// --

export function logAndQuit(message: string) {
console.error(message);
process.exit(1);
}

export function logLoginMessageAndQuit() {
const loginCommand = process.env.IS_DEVELOPMENT_CLI_ENV
? "bun dev login"
: "sf login";
const base = getCommandBase();
const loginCommand = `${base} login`;

logAndQuit(`You need to login first.\n\n\t$ ${loginCommand}\n`);
}

export async function logSessionTokenExpiredAndQuit() {
await clearAuthFromConfig();
logAndQuit("\nYour session has expired. Please login again.");
}
21 changes: 11 additions & 10 deletions src/helpers/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ const webPaths = {
};

const apiPaths = {
index: "/",
ping: "/v0/ping",

orders_create: "/v0/orders",
orders_list: "/v0/orders",
orders_get: ({ id }: { id: string }) => `/v0/orders/${id}`,
Expand All @@ -21,13 +24,14 @@ const apiPaths = {
contracts_get: ({ id }: { id: string }) => `/v0/contracts/${id}`,

balance_get: "/v0/balance",

tokens_create: "/v0/tokens",
tokens_list: "/v0/tokens",
tokens_delete_by_id: ({ id }: { id: string }) => `/v0/tokens/${id}`,
};

export async function getWebAppUrl<
K extends keyof typeof webPaths,
V extends Extract<(typeof webPaths)[K], (...args: any) => any>,
>(key: K, params: Parameters<V>[0]): Promise<string>;
export async function getWebAppUrl(key: keyof typeof webPaths): Promise<string>;
// --

export async function getWebAppUrl(
key: keyof typeof webPaths,
params?: any,
Expand All @@ -37,14 +41,10 @@ export async function getWebAppUrl(
if (typeof path === "function") {
return config.webapp_url + path(params);
}

return config.webapp_url + path;
}

export async function getApiUrl<
K extends keyof typeof apiPaths,
V extends Extract<(typeof apiPaths)[K], (...args: any) => any>,
>(key: K, params: Parameters<V>[0]): Promise<string>;
export async function getApiUrl(key: keyof typeof apiPaths): Promise<string>;
export async function getApiUrl(
key: keyof typeof apiPaths,
params?: any,
Expand All @@ -54,5 +54,6 @@ export async function getApiUrl(
if (typeof path === "function") {
return config.api_url + path(params);
}

return config.api_url + path;
}
6 changes: 4 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ import { registerLogin } from "./lib/login";
import { registerOrders } from "./lib/orders";
import { registerSell } from "./lib/sell";
import { registerSSH } from "./lib/ssh";
import { registerTokens } from "./lib/tokens";
import { registerUpgrade } from "./lib/upgrade";

const program = new Command();

program
.name("sf")
.description("San Francisco Compute command line tool.")
.description("The San Francisco Compute command line tool.")
.version(version);

// commands
Expand All @@ -29,9 +30,10 @@ registerInstances(program);
registerSSH(program);
registerSell(program);
registerBalance(program);
registerTokens(program);
registerUpgrade(program);

// (only development commands)
// (development commands)
registerDev(program);

program.parse(Bun.argv);
18 changes: 13 additions & 5 deletions src/lib/balance.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import chalk from "chalk";
import Table from "cli-table3";
import type { Command } from "commander";
import { loadConfig } from "../helpers/config";
import { logAndQuit, logLoginMessageAndQuit } from "../helpers/errors";
import { isLoggedIn, loadConfig } from "../helpers/config";
import {
logAndQuit,
logLoginMessageAndQuit,
logSessionTokenExpiredAndQuit,
} from "../helpers/errors";
import type { Centicents } from "../helpers/units";
import { getApiUrl } from "../helpers/urls";

Expand Down Expand Up @@ -71,14 +75,16 @@ async function getBalance(): Promise<{
available: { centicents: Centicents; whole: number };
reserved: { centicents: Centicents; whole: number };
}> {
const config = await loadConfig();
if (!config.auth_token) {
const loggedIn = await isLoggedIn();
if (!loggedIn) {
logLoginMessageAndQuit();

return {
available: { centicents: 0, whole: 0 },
reserved: { centicents: 0, whole: 0 },
};
}
const config = await loadConfig();

const response = await fetch(await getApiUrl("balance_get"), {
method: "GET",
Expand All @@ -90,14 +96,16 @@ async function getBalance(): Promise<{

if (!response.ok) {
if (response.status === 401) {
logLoginMessageAndQuit();
logSessionTokenExpiredAndQuit();

return {
available: { centicents: 0, whole: 0 },
reserved: { centicents: 0, whole: 0 },
};
}

logAndQuit(`Failed to fetch balance: ${response.statusText}`);

return {
available: { centicents: 0, whole: 0 },
reserved: { centicents: 0, whole: 0 },
Expand Down
40 changes: 13 additions & 27 deletions src/lib/buy.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import readline from "node:readline";
import { confirm } from "@inquirer/prompts";
import c from "chalk";
import * as chrono from "chrono-node";
import type { Command } from "commander";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import relativeTime from "dayjs/plugin/relativeTime";
import parseDuration from "parse-duration";
import { loadConfig } from "../helpers/config";
import { getAuthToken, isLoggedIn } from "../helpers/config";
import { logAndQuit, logLoginMessageAndQuit } from "../helpers/errors";
import { getApiUrl } from "../helpers/urls";
import {
Expand All @@ -33,30 +33,14 @@ export function registerBuy(program: Command) {
});
}

const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});

async function prompt(msg: string) {
const answer = await new Promise((resolve) =>
rl.question(msg, (ans) => {
rl.close();
resolve(ans);
}),
);

return answer;
}

function confirmPlaceOrderParametersMessage(params: PlaceOrderParameters) {
const { quantity, price, instance_type, duration, start_at } = params;

const startDate = new Date(start_at);

const fromNowTime = dayjs(startDate).fromNow();
const humanReadableStartAt = dayjs(startDate).format("MM/DD/YYYY hh:mm A");
const centicentsAsDollars = (price / 10000).toFixed(2);
const centicentsAsDollars = (price / 10_000).toFixed(2);
const durationHumanReadable = formatDuration(duration * 1000);

const topLine = `${c.green(quantity)} ${c.green(instance_type)} nodes for ${c.green(durationHumanReadable)} starting ${c.green(humanReadableStartAt)} (${c.green(fromNowTime)})`;
Expand Down Expand Up @@ -95,11 +79,11 @@ interface PlaceBuyOrderArguments {
}

async function placeBuyOrder(props: PlaceBuyOrderArguments) {
const { type, duration, price, quantity, start } = props;
const config = await loadConfig();
if (!config.auth_token) {
const loggedIn = await isLoggedIn();
if (loggedIn) {
return logLoginMessageAndQuit();
}
const { type, duration, price, quantity, start } = props;

const orderQuantity = quantity ? Number(quantity) : 1;
const durationMs = parseDuration(duration);
Expand All @@ -120,11 +104,13 @@ async function placeBuyOrder(props: PlaceBuyOrderArguments) {
start_at: startDate.toISOString(),
};

const msg = confirmPlaceOrderParametersMessage(params);

if (!props.yes) {
const answer = await prompt(msg);
if (answer !== "y") {
const placeBuyOrderConfirmed = await confirm({
message: confirmPlaceOrderParametersMessage(params),
default: false,
});

if (!placeBuyOrderConfirmed) {
return logAndQuit("Order cancelled");
}
}
Expand All @@ -134,7 +120,7 @@ async function placeBuyOrder(props: PlaceBuyOrderArguments) {
body: JSON.stringify(params),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.auth_token}`,
Authorization: `Bearer ${await getAuthToken()}`,
},
});

Expand Down
8 changes: 4 additions & 4 deletions src/lib/contracts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Table from "cli-table3";
import { Command } from "commander";
import { loadConfig } from "../helpers/config";
import { getAuthToken, isLoggedIn } from "../helpers/config";
import { logLoginMessageAndQuit } from "../helpers/errors";
import { getApiUrl } from "../helpers/urls";

Expand Down Expand Up @@ -70,16 +70,16 @@ export function registerContracts(program: Command) {
}

async function listContracts() {
const config = await loadConfig();
if (!config.auth_token) {
const loggedIn = await isLoggedIn();
if (!loggedIn) {
return logLoginMessageAndQuit();
}

const response = await fetch(await getApiUrl("contracts_list"), {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.auth_token}`,
Authorization: `Bearer ${await getAuthToken()}`,
},
});

Expand Down
Loading