Skip to content

Commit bde139a

Browse files
authored
sf tokens create (#6)
* isLoggedIn() * await * consistent prompting * create token * token create * tokens list * list tokens * delete token * rm alias * bun check
1 parent ac0419d commit bde139a

14 files changed

Lines changed: 466 additions & 70 deletions

File tree

bun.lockb

324 Bytes
Binary file not shown.

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
"lint": "biome lint --write ./src",
55
"check": "biome check ./src",
66
"dev": "IS_DEVELOPMENT_CLI_ENV=true bun run src/index.ts",
7-
"pack": "bun build src/index.ts --outfile dist/cli.js",
87
"release": "bun run src/scripts/release.ts",
98
"prod": "bun run src/index.ts"
109
},
@@ -15,7 +14,7 @@
1514
"chrono-node": "^2.7.6",
1615
"cli-table3": "^0.6.5",
1716
"commander": "^12.1.0",
18-
"dayjs": "^1.11.11",
17+
"dayjs": "^1.11.12",
1918
"dotenv": "^16.4.5",
2019
"inquirer": "^10.1.2",
2120
"node-fetch": "^3.3.2",

src/helpers/command.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function getCommandBase() {
2+
return process.env.IS_DEVELOPMENT_CLI_ENV ? "bun dev" : "sf";
3+
}

src/helpers/config.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,20 @@ const ConfigDefaults = process.env.IS_DEVELOPMENT_CLI_ENV
2323
? DevelopmentConfigDefaults
2424
: ProductionConfigDefaults;
2525

26-
export async function saveConfig(config: Partial<Config>): Promise<void> {
26+
// --
27+
28+
export async function saveConfig(
29+
config: Partial<Config>,
30+
): Promise<{ success: boolean }> {
2731
const configPath = getConfigPath();
2832
const configData = JSON.stringify(config, null, 2);
2933

3034
try {
3135
await Bun.write(configPath, configData);
32-
console.log("Config saved successfully.");
36+
37+
return { success: true };
3338
} catch (error) {
34-
console.error("Failed to save config:", error);
39+
return { success: false };
3540
}
3641
}
3742

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

49+
export async function clearAuthFromConfig() {
50+
const config = await loadConfig();
51+
52+
await saveConfig({
53+
...config,
54+
auth_token: undefined,
55+
});
56+
}
57+
4458
// only for development
4559
export async function deleteConfig() {
4660
const exists = await configFileExists();
@@ -104,3 +118,8 @@ export async function getAuthorizationHeader() {
104118
const token = await getAuthToken();
105119
return { Authorization: `Bearer ${token}` };
106120
}
121+
122+
export async function isLoggedIn() {
123+
const authToken = await getAuthToken();
124+
return !!authToken;
125+
}

src/helpers/errors.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,58 @@
1+
import { getCommandBase } from "./command";
2+
import { clearAuthFromConfig } from "./config";
3+
4+
export interface ApiError {
5+
object: "error";
6+
code: string;
7+
message: string;
8+
details: Record<string, any>;
9+
}
10+
11+
export const ApiErrorCode = {
12+
Base: {
13+
InvalidRequest: "invalid_request",
14+
NotAuthenticated: "not_authenticated",
15+
Unauthorized: "unauthorized",
16+
NotFound: "not_found",
17+
RouteNotFound: "route_not_found",
18+
TooManyRequests: "too_many_requests",
19+
InternalServer: "internal_server",
20+
},
21+
Accounts: {
22+
NotFound: "account.not_found",
23+
},
24+
Orders: {
25+
InvalidId: "order.invalid_id",
26+
InvalidPrice: "order.invalid_price",
27+
InvalidQuantity: "order.invalid_quantity",
28+
InvalidStart: "order.invalid_start",
29+
InvalidDuration: "order.invalid_duration",
30+
InsufficientFunds: "order.insufficient_funds",
31+
NotFound: "order.not_found",
32+
},
33+
Tokens: {
34+
TokenNotFound: "token.not_found",
35+
InvalidTokenCreateOriginClient: "token.invalid_token_create_origin_client",
36+
InvalidTokenExpirationDuration: "token.invalid_token_expiration_duration",
37+
MaxTokenLimitReached: "token.max_token_limit_reached",
38+
},
39+
};
40+
41+
// --
42+
143
export function logAndQuit(message: string) {
244
console.error(message);
345
process.exit(1);
446
}
547

648
export function logLoginMessageAndQuit() {
7-
const loginCommand = process.env.IS_DEVELOPMENT_CLI_ENV
8-
? "bun dev login"
9-
: "sf login";
49+
const base = getCommandBase();
50+
const loginCommand = `${base} login`;
1051

1152
logAndQuit(`You need to login first.\n\n\t$ ${loginCommand}\n`);
1253
}
54+
55+
export async function logSessionTokenExpiredAndQuit() {
56+
await clearAuthFromConfig();
57+
logAndQuit("\nYour session has expired. Please login again.");
58+
}

src/helpers/urls.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ const webPaths = {
77
};
88

99
const apiPaths = {
10+
index: "/",
11+
ping: "/v0/ping",
12+
1013
orders_create: "/v0/orders",
1114
orders_list: "/v0/orders",
1215
orders_get: ({ id }: { id: string }) => `/v0/orders/${id}`,
@@ -21,13 +24,14 @@ const apiPaths = {
2124
contracts_get: ({ id }: { id: string }) => `/v0/contracts/${id}`,
2225

2326
balance_get: "/v0/balance",
27+
28+
tokens_create: "/v0/tokens",
29+
tokens_list: "/v0/tokens",
30+
tokens_delete_by_id: ({ id }: { id: string }) => `/v0/tokens/${id}`,
2431
};
2532

26-
export async function getWebAppUrl<
27-
K extends keyof typeof webPaths,
28-
V extends Extract<(typeof webPaths)[K], (...args: any) => any>,
29-
>(key: K, params: Parameters<V>[0]): Promise<string>;
30-
export async function getWebAppUrl(key: keyof typeof webPaths): Promise<string>;
33+
// --
34+
3135
export async function getWebAppUrl(
3236
key: keyof typeof webPaths,
3337
params?: any,
@@ -37,14 +41,10 @@ export async function getWebAppUrl(
3741
if (typeof path === "function") {
3842
return config.webapp_url + path(params);
3943
}
44+
4045
return config.webapp_url + path;
4146
}
4247

43-
export async function getApiUrl<
44-
K extends keyof typeof apiPaths,
45-
V extends Extract<(typeof apiPaths)[K], (...args: any) => any>,
46-
>(key: K, params: Parameters<V>[0]): Promise<string>;
47-
export async function getApiUrl(key: keyof typeof apiPaths): Promise<string>;
4848
export async function getApiUrl(
4949
key: keyof typeof apiPaths,
5050
params?: any,
@@ -54,5 +54,6 @@ export async function getApiUrl(
5454
if (typeof path === "function") {
5555
return config.api_url + path(params);
5656
}
57+
5758
return config.api_url + path;
5859
}

src/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@ import { registerLogin } from "./lib/login";
1111
import { registerOrders } from "./lib/orders";
1212
import { registerSell } from "./lib/sell";
1313
import { registerSSH } from "./lib/ssh";
14+
import { registerTokens } from "./lib/tokens";
1415
import { registerUpgrade } from "./lib/upgrade";
1516

1617
const program = new Command();
1718

1819
program
1920
.name("sf")
20-
.description("San Francisco Compute command line tool.")
21+
.description("The San Francisco Compute command line tool.")
2122
.version(version);
2223

2324
// commands
@@ -29,9 +30,10 @@ registerInstances(program);
2930
registerSSH(program);
3031
registerSell(program);
3132
registerBalance(program);
33+
registerTokens(program);
3234
registerUpgrade(program);
3335

34-
// (only development commands)
36+
// (development commands)
3537
registerDev(program);
3638

3739
program.parse(Bun.argv);

src/lib/balance.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import chalk from "chalk";
22
import Table from "cli-table3";
33
import type { Command } from "commander";
4-
import { loadConfig } from "../helpers/config";
5-
import { logAndQuit, logLoginMessageAndQuit } from "../helpers/errors";
4+
import { isLoggedIn, loadConfig } from "../helpers/config";
5+
import {
6+
logAndQuit,
7+
logLoginMessageAndQuit,
8+
logSessionTokenExpiredAndQuit,
9+
} from "../helpers/errors";
610
import type { Centicents } from "../helpers/units";
711
import { getApiUrl } from "../helpers/urls";
812

@@ -71,14 +75,16 @@ async function getBalance(): Promise<{
7175
available: { centicents: Centicents; whole: number };
7276
reserved: { centicents: Centicents; whole: number };
7377
}> {
74-
const config = await loadConfig();
75-
if (!config.auth_token) {
78+
const loggedIn = await isLoggedIn();
79+
if (!loggedIn) {
7680
logLoginMessageAndQuit();
81+
7782
return {
7883
available: { centicents: 0, whole: 0 },
7984
reserved: { centicents: 0, whole: 0 },
8085
};
8186
}
87+
const config = await loadConfig();
8288

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

9197
if (!response.ok) {
9298
if (response.status === 401) {
93-
logLoginMessageAndQuit();
99+
logSessionTokenExpiredAndQuit();
100+
94101
return {
95102
available: { centicents: 0, whole: 0 },
96103
reserved: { centicents: 0, whole: 0 },
97104
};
98105
}
99106

100107
logAndQuit(`Failed to fetch balance: ${response.statusText}`);
108+
101109
return {
102110
available: { centicents: 0, whole: 0 },
103111
reserved: { centicents: 0, whole: 0 },

src/lib/buy.ts

Lines changed: 13 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import readline from "node:readline";
1+
import { confirm } from "@inquirer/prompts";
22
import c from "chalk";
33
import * as chrono from "chrono-node";
44
import type { Command } from "commander";
55
import dayjs from "dayjs";
66
import duration from "dayjs/plugin/duration";
77
import relativeTime from "dayjs/plugin/relativeTime";
88
import parseDuration from "parse-duration";
9-
import { loadConfig } from "../helpers/config";
9+
import { getAuthToken, isLoggedIn } from "../helpers/config";
1010
import { logAndQuit, logLoginMessageAndQuit } from "../helpers/errors";
1111
import { getApiUrl } from "../helpers/urls";
1212
import {
@@ -33,30 +33,14 @@ export function registerBuy(program: Command) {
3333
});
3434
}
3535

36-
const rl = readline.createInterface({
37-
input: process.stdin,
38-
output: process.stdout,
39-
});
40-
41-
async function prompt(msg: string) {
42-
const answer = await new Promise((resolve) =>
43-
rl.question(msg, (ans) => {
44-
rl.close();
45-
resolve(ans);
46-
}),
47-
);
48-
49-
return answer;
50-
}
51-
5236
function confirmPlaceOrderParametersMessage(params: PlaceOrderParameters) {
5337
const { quantity, price, instance_type, duration, start_at } = params;
5438

5539
const startDate = new Date(start_at);
5640

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

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

9781
async function placeBuyOrder(props: PlaceBuyOrderArguments) {
98-
const { type, duration, price, quantity, start } = props;
99-
const config = await loadConfig();
100-
if (!config.auth_token) {
82+
const loggedIn = await isLoggedIn();
83+
if (loggedIn) {
10184
return logLoginMessageAndQuit();
10285
}
86+
const { type, duration, price, quantity, start } = props;
10387

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

123-
const msg = confirmPlaceOrderParametersMessage(params);
124-
125107
if (!props.yes) {
126-
const answer = await prompt(msg);
127-
if (answer !== "y") {
108+
const placeBuyOrderConfirmed = await confirm({
109+
message: confirmPlaceOrderParametersMessage(params),
110+
default: false,
111+
});
112+
113+
if (!placeBuyOrderConfirmed) {
128114
return logAndQuit("Order cancelled");
129115
}
130116
}
@@ -134,7 +120,7 @@ async function placeBuyOrder(props: PlaceBuyOrderArguments) {
134120
body: JSON.stringify(params),
135121
headers: {
136122
"Content-Type": "application/json",
137-
Authorization: `Bearer ${config.auth_token}`,
123+
Authorization: `Bearer ${await getAuthToken()}`,
138124
},
139125
});
140126

src/lib/contracts.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import Table from "cli-table3";
22
import { Command } from "commander";
3-
import { loadConfig } from "../helpers/config";
3+
import { getAuthToken, isLoggedIn } from "../helpers/config";
44
import { logLoginMessageAndQuit } from "../helpers/errors";
55
import { getApiUrl } from "../helpers/urls";
66

@@ -70,16 +70,16 @@ export function registerContracts(program: Command) {
7070
}
7171

7272
async function listContracts() {
73-
const config = await loadConfig();
74-
if (!config.auth_token) {
73+
const loggedIn = await isLoggedIn();
74+
if (!loggedIn) {
7575
return logLoginMessageAndQuit();
7676
}
7777

7878
const response = await fetch(await getApiUrl("contracts_list"), {
7979
method: "GET",
8080
headers: {
8181
"Content-Type": "application/json",
82-
Authorization: `Bearer ${config.auth_token}`,
82+
Authorization: `Bearer ${await getAuthToken()}`,
8383
},
8484
});
8585

0 commit comments

Comments
 (0)