diff --git a/bun.lockb b/bun.lockb index 4bcc9054..004236af 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 30354c66..4bb34bba 100644 --- a/package.json +++ b/package.json @@ -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" }, @@ -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", diff --git a/src/helpers/command.ts b/src/helpers/command.ts new file mode 100644 index 00000000..2bf64442 --- /dev/null +++ b/src/helpers/command.ts @@ -0,0 +1,3 @@ +export function getCommandBase() { + return process.env.IS_DEVELOPMENT_CLI_ENV ? "bun dev" : "sf"; +} diff --git a/src/helpers/config.ts b/src/helpers/config.ts index de042f2b..f30e7329 100644 --- a/src/helpers/config.ts +++ b/src/helpers/config.ts @@ -23,15 +23,20 @@ const ConfigDefaults = process.env.IS_DEVELOPMENT_CLI_ENV ? DevelopmentConfigDefaults : ProductionConfigDefaults; -export async function saveConfig(config: Partial): Promise { +// -- + +export async function saveConfig( + config: Partial, +): 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 }; } } @@ -41,6 +46,15 @@ export async function loadConfig(): Promise { 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(); @@ -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; +} diff --git a/src/helpers/errors.ts b/src/helpers/errors.ts index 3b5ed27e..5ff16b0b 100644 --- a/src/helpers/errors.ts +++ b/src/helpers/errors.ts @@ -1,12 +1,58 @@ +import { getCommandBase } from "./command"; +import { clearAuthFromConfig } from "./config"; + +export interface ApiError { + object: "error"; + code: string; + message: string; + details: Record; +} + +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."); +} diff --git a/src/helpers/urls.ts b/src/helpers/urls.ts index 694e8e28..7a8eeb45 100644 --- a/src/helpers/urls.ts +++ b/src/helpers/urls.ts @@ -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}`, @@ -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[0]): Promise; -export async function getWebAppUrl(key: keyof typeof webPaths): Promise; +// -- + export async function getWebAppUrl( key: keyof typeof webPaths, params?: any, @@ -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[0]): Promise; -export async function getApiUrl(key: keyof typeof apiPaths): Promise; export async function getApiUrl( key: keyof typeof apiPaths, params?: any, @@ -54,5 +54,6 @@ export async function getApiUrl( if (typeof path === "function") { return config.api_url + path(params); } + return config.api_url + path; } diff --git a/src/index.ts b/src/index.ts index 6f13d2d6..899c5da9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 @@ -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); diff --git a/src/lib/balance.ts b/src/lib/balance.ts index e639b11e..024864a7 100644 --- a/src/lib/balance.ts +++ b/src/lib/balance.ts @@ -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"; @@ -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", @@ -90,7 +96,8 @@ async function getBalance(): Promise<{ if (!response.ok) { if (response.status === 401) { - logLoginMessageAndQuit(); + logSessionTokenExpiredAndQuit(); + return { available: { centicents: 0, whole: 0 }, reserved: { centicents: 0, whole: 0 }, @@ -98,6 +105,7 @@ async function getBalance(): Promise<{ } logAndQuit(`Failed to fetch balance: ${response.statusText}`); + return { available: { centicents: 0, whole: 0 }, reserved: { centicents: 0, whole: 0 }, diff --git a/src/lib/buy.ts b/src/lib/buy.ts index 0dd47962..e5bff1ab 100644 --- a/src/lib/buy.ts +++ b/src/lib/buy.ts @@ -1,4 +1,4 @@ -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"; @@ -6,7 +6,7 @@ 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 { @@ -33,22 +33,6 @@ 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; @@ -56,7 +40,7 @@ function confirmPlaceOrderParametersMessage(params: PlaceOrderParameters) { 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)})`; @@ -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); @@ -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"); } } @@ -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()}`, }, }); diff --git a/src/lib/contracts.ts b/src/lib/contracts.ts index 071508f2..f1e7782a 100644 --- a/src/lib/contracts.ts +++ b/src/lib/contracts.ts @@ -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"; @@ -70,8 +70,8 @@ export function registerContracts(program: Command) { } async function listContracts() { - const config = await loadConfig(); - if (!config.auth_token) { + const loggedIn = await isLoggedIn(); + if (!loggedIn) { return logLoginMessageAndQuit(); } @@ -79,7 +79,7 @@ async function listContracts() { method: "GET", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${config.auth_token}`, + Authorization: `Bearer ${await getAuthToken()}`, }, }); diff --git a/src/lib/instances.ts b/src/lib/instances.ts index 23980294..d04fd9ab 100644 --- a/src/lib/instances.ts +++ b/src/lib/instances.ts @@ -1,7 +1,7 @@ import chalk, { type ChalkInstance } from "chalk"; import Table from "cli-table3"; import type { Command } from "commander"; -import { loadConfig } from "../helpers/config"; +import { getAuthToken, isLoggedIn } from "../helpers/config"; import { logLoginMessageAndQuit } from "../helpers/errors"; import { getApiUrl } from "../helpers/urls"; @@ -148,8 +148,8 @@ const colorInstanceType = (instanceType: InstanceType) => async function getInstances({ clusterId, }: { clusterId?: string }): Promise> { - const config = await loadConfig(); - if (!config.auth_token) { + const loggedIn = await isLoggedIn(); + if (!loggedIn) { logLoginMessageAndQuit(); } @@ -161,7 +161,7 @@ async function getInstances({ method: "GET", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${config.auth_token}`, + Authorization: `Bearer ${await getAuthToken()}`, }, }); diff --git a/src/lib/orders.ts b/src/lib/orders.ts index 44d42eb5..f2a000d2 100644 --- a/src/lib/orders.ts +++ b/src/lib/orders.ts @@ -3,7 +3,7 @@ import type { Command } from "commander"; import dayjs from "dayjs"; import duration from "dayjs/plugin/duration"; import relativeTime from "dayjs/plugin/relativeTime"; -import { loadConfig } from "../helpers/config"; +import { getAuthToken, isLoggedIn } from "../helpers/config"; import { logAndQuit, logLoginMessageAndQuit } from "../helpers/errors"; import { getApiUrl } from "../helpers/urls"; import type { ListResponseBody, Order } from "./types"; @@ -157,8 +157,8 @@ export async function getOrders(props: { side?: "buy" | "sell"; include_public?: boolean; }) { - const config = await loadConfig(); - if (!config.auth_token) { + const loggedIn = await isLoggedIn(); + if (!loggedIn) { logLoginMessageAndQuit(); } @@ -175,7 +175,7 @@ export async function getOrders(props: { method: "GET", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${config.auth_token}`, + Authorization: `Bearer ${await getAuthToken()}`, }, }); diff --git a/src/lib/sell.ts b/src/lib/sell.ts index f0e6e604..b94c9b0e 100644 --- a/src/lib/sell.ts +++ b/src/lib/sell.ts @@ -1,7 +1,7 @@ import * as chrono from "chrono-node"; import type { Command } from "commander"; 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 { priceToCenticents, type PlaceSellOrderParameters } from "./orders"; @@ -46,8 +46,8 @@ async function placeSellOrder(options: { duration: string; flags?: Record; }) { - const config = await loadConfig(); - if (!config.auth_token) { + const loggedIn = await isLoggedIn(); + if (!loggedIn) { return logLoginMessageAndQuit(); } @@ -73,7 +73,7 @@ async function placeSellOrder(options: { ...flags, }; - const res = await postSellOrder(config.auth_token, params); + const res = await postSellOrder(params); if (!res.ok) { return logAndQuit("Failed to place sell order"); } @@ -82,13 +82,13 @@ async function placeSellOrder(options: { process.exit(0); } -async function postSellOrder(token: string, params: PlaceSellOrderParameters) { +async function postSellOrder(params: PlaceSellOrderParameters) { return await fetch(await getApiUrl("orders_create"), { method: "POST", body: JSON.stringify(params), headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${await getAuthToken()}`, }, }); } diff --git a/src/lib/tokens.ts b/src/lib/tokens.ts new file mode 100644 index 00000000..1f9f8e2b --- /dev/null +++ b/src/lib/tokens.ts @@ -0,0 +1,332 @@ +import { input, select } from "@inquirer/prompts"; +import { confirm } from "@inquirer/prompts"; +import chalk from "chalk"; +import Table from "cli-table3"; +import type { Command } from "commander"; +import dayjs from "dayjs"; +import ora from "ora"; +import { getCommandBase } from "../helpers/command"; +import { getAuthToken, isLoggedIn } from "../helpers/config"; +import { + type ApiError, + ApiErrorCode, + logLoginMessageAndQuit, + logSessionTokenExpiredAndQuit, +} from "../helpers/errors"; +import { getApiUrl } from "../helpers/urls"; + +export const TOKEN_EXPIRATION_SECONDS = { + IN_7_DAYS: 7 * 24 * 60 * 60, + IN_14_DAYS: 14 * 24 * 60 * 60, + IN_30_DAYS: 30 * 24 * 60 * 60, + IN_60_DAYS: 60 * 24 * 60 * 60, + IN_90_DAYS: 90 * 24 * 60 * 60, + IN_100_YEARS: 100 * 365 * 24 * 60 * 60, +}; + +export function registerTokens(program: Command) { + const tokens = program + .command("tokens") + .description("Manage account access tokens."); + + tokens + .command("create") + .description("Create a new access token") + .action(createTokenAction); + + tokens + .command("list") + .alias("ls") + .description("List all tokens") + .action(listTokensAction); + + tokens + .command("delete") + .command("rm") + .description("Delete a token") + .requiredOption("--id ", "Specify the token ID") + .option("--force", "Force delete the token, skipping confirmation") + .action(deleteTokenAction); +} + +// -- + +interface TokenObject { + id: string; + token?: string; + name?: string; + description?: string; + is_sandbox: boolean; + created_at: string; + last_active_at: string; + expires_at: string; + origin_client: string; + is_system: boolean; +} + +interface PostTokenRequestBody { + expires_in_seconds: number; + name?: string; + description?: string; + origin_client: string; +} + +async function createTokenAction() { + const loggedIn = await isLoggedIn(); + if (!loggedIn) { + logLoginMessageAndQuit(); + } + + // collect duration + const expiresInSeconds = await select({ + message: "Select token expiration:", + default: TOKEN_EXPIRATION_SECONDS.IN_100_YEARS, + choices: [ + { name: "1 week", value: TOKEN_EXPIRATION_SECONDS.IN_7_DAYS }, + { name: "2 weeks", value: TOKEN_EXPIRATION_SECONDS.IN_14_DAYS }, + { name: "1 month", value: TOKEN_EXPIRATION_SECONDS.IN_30_DAYS }, + { name: "2 months", value: TOKEN_EXPIRATION_SECONDS.IN_60_DAYS }, + { name: "3 months", value: TOKEN_EXPIRATION_SECONDS.IN_90_DAYS }, + { + name: "Never Expire", + value: TOKEN_EXPIRATION_SECONDS.IN_100_YEARS, + }, + ], + }); + + // collect name & description + const name = await input({ + message: `Name your token ${chalk.gray("(optional, ↵ to skip)")}:`, + default: "", + }); + const description = await input({ + message: `Description for your token ${chalk.gray("(optional, ↵ to skip)")}:`, + default: "", + }); + + // generate token + console.log("\n"); + const loadingSpinner = ora("Generating token").start(); + + const response = await fetch(await getApiUrl("tokens_create"), { + method: "POST", + body: JSON.stringify({ + expires_in_seconds: expiresInSeconds, + name, + description, + origin_client: "cli", + } as PostTokenRequestBody), + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${await getAuthToken()}`, + }, + }); + if (!response.ok) { + if (response.status === 401) { + await logSessionTokenExpiredAndQuit(); + } + + // TODO: handle specific errors + + loadingSpinner.fail("Failed to create token"); + process.exit(1); + } + + // display token to user + const data = await response.json(); + loadingSpinner.succeed(chalk.gray("Access token created 🎉")); + console.log(chalk.green(data.token) + "\n"); + + // tell them they will set this in the Authorization header + console.log( + `${chalk.gray(`Pass this in the 'Authorization' header of API requests:`)}`, + ); + console.log( + [ + chalk.gray("{ "), + chalk.white("Authorization"), + chalk.gray(": "), + chalk.green('"Bearer '), + chalk.magenta(""), + chalk.green('"'), + chalk.gray(" }"), + ].join(""), + ); + console.log("\n"); + + // give them a sample curl + const pingUrl = await getApiUrl("ping"); + console.log(`${chalk.gray("Here is a sample curl to get your started:")}`); + console.log( + chalk.white(`curl --request GET \\ + --url ${pingUrl} \\ + --header 'Authorization: Bearer ${data.token}'`), + ); + console.log("\n"); + + // tip user on other commands + const base = getCommandBase(); + + const table = new Table({ + colWidths: [20, 30], + }); + table.push(["View All Tokens", chalk.magenta(`${base} tokens list`)]); + table.push(["Delete a Token", chalk.magenta(`${base} tokens delete`)]); + + console.log(`${chalk.gray("And other commands you can try:")}`); + console.log(table.toString()); + + process.exit(0); +} + +// -- + +async function listTokensAction() { + const loggedIn = await isLoggedIn(); + if (!loggedIn) { + logLoginMessageAndQuit(); + } + + const loadingSpinner = ora("Fetching tokens...").start(); + + // fetch tokens + const tokensListUrl = await getApiUrl("tokens_list"); + const response = await fetch(tokensListUrl, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${await getAuthToken()}`, + }, + }); + if (!response.ok) { + if (response.status === 401) { + await logSessionTokenExpiredAndQuit(); + } + + // TODO: handle specific errors + + loadingSpinner.fail("Failed to fetch tokens"); + process.exit(1); + } + loadingSpinner.stop(); // hide spinner + + // show account tokens + const responseBody = await response.json(); + const tokens = responseBody.data as Array; + + // show empty table if no tokens + if (tokens.length === 0) { + const table = new Table({ + head: [chalk.gray("Access Tokens")], + colWidths: [50], + }); + table.push([ + { colSpan: 1, content: "No access tokens found", hAlign: "center" }, + ]); + console.log(table.toString() + "\n"); + + // prompt user that they can generate one + const base = getCommandBase(); + console.log( + chalk.gray("Generate your first token with: ") + + chalk.magenta(`${base} tokens create`), + ); + + process.exit(0); + } + + // display table + const tokensTable = new Table({ + head: [ + chalk.gray("Token ID"), + chalk.gray("Name"), + chalk.gray("Last Active At"), + chalk.gray("Expires"), + ], + colWidths: [40, 15, 25, 25], + }); + for (const token of tokens) { + tokensTable.push([ + chalk.gray(token.id), + token.name ? token.name : chalk.gray("(empty)"), + chalk.green(formatDate(token.last_active_at)), + chalk.white(formatDate(token.expires_at)), + ]); + } + console.log(tokensTable.toString()); + + process.exit(0); +} + +function formatDate(isoString: string): string { + return dayjs(isoString).format("MMM D, YYYY [at] h:mma").toLowerCase(); +} + +// -- + +async function deleteTokenAction({ + id, + force, +}: { id: string; force?: boolean }) { + const loggedIn = await isLoggedIn(); + if (!loggedIn) { + logLoginMessageAndQuit(); + } + + if (force) { + await deleteTokenById(id); + } + + const deleteTokenConfirmed = await confirm({ + message: `Are you sure you want to delete this token? ${chalk.gray("(it will stop working immediately.)")}`, + default: false, + }); + if (!deleteTokenConfirmed) { + process.exit(0); + } else { + const verySureConfirmed = await confirm({ + message: + chalk.red("Very sure?") + " " + chalk.gray("(just double-checking)"), + default: false, + }); + + if (!verySureConfirmed) { + process.exit(0); + } else { + await deleteTokenById(id); + } + } +} +async function deleteTokenById(id: string) { + const deleteTokenByIdUrl = await getApiUrl("tokens_delete_by_id", { id }); + const loadingSpinner = ora("Deleting token...").start(); + + const response = await fetch(deleteTokenByIdUrl, { + method: "DELETE", + headers: { + Authorization: `Bearer ${await getAuthToken()}`, + }, + }); + if (!response.ok) { + if (response.status === 401) { + await logSessionTokenExpiredAndQuit(); + } + + const error = (await response.json()) as ApiError; + if (error.code === ApiErrorCode.Tokens.TokenNotFound) { + loadingSpinner.fail("Token not found"); + process.exit(1); + } + + // TODO: handle more specific errors + + // generic catch-all + loadingSpinner.fail("Failed to delete token"); + process.exit(1); + } + + loadingSpinner.stop(); + console.log(chalk.gray("Token deleted. 🧼")); + + process.exit(0); +}