UNPKG

convex

Version:

Client for the Convex Cloud

531 lines (496 loc) 14.8 kB
import axios, { AxiosInstance, AxiosResponse, Method } from "axios"; import chalk from "chalk"; import inquirer from "inquirer"; import * as readline from "readline"; import path from "path"; import os from "os"; import { z } from "zod"; import type { ProjectConfig } from "./config.js"; import { configFilepath } from "./config.js"; import { Context } from "./context.js"; import { init } from "./init.js"; import { version } from "../../index.js"; import { Project } from "./api.js"; export const productionProvisionHost = "https://provision.convex.dev"; export const provisionHost = process.env.CONVEX_PROVISION_HOST || productionProvisionHost; const BIG_BRAIN_URL = `${provisionHost}/api/${version}`; /** Prompt for keyboard input with the given `query` string and return a promise * that resolves to the input. */ export function prompt(query: string) { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); return new Promise(resolve => rl.question(query, answer => { rl.close(); resolve(answer); }) ); } export async function fatalServerErr(ctx: Context, err: any): Promise<never> { if (ctx.spinner) { // Fail the spinner so the console logs appear ctx.spinner.fail(); } const res = err.response; if (res) { await deprecationCheckError(ctx, res); console.error( chalk.gray( `${res.status} ${res.statusText}: ${res.data.code}: ${res.data.message}` ) ); if (res.status === 401) { console.error( chalk.red("Log in to get an access token with `npx convex login`.") ); } } else { console.error(chalk.gray(err)); } return await ctx.fatalError(1, "network", err); } async function deprecationCheckError( ctx: Context, resp: AxiosResponse<any, any> ) { if (ctx.deprecationMessagePrinted) { return; } const headers = resp.headers; if (headers) { const deprecationState = headers["x-convex-deprecation-state"]; const deprecationMessage = headers["x-convex-deprecation-message"]; switch (deprecationState) { case undefined: break; case "Upgradable": console.log(chalk.yellow(deprecationMessage)); break; case "Deprecated": case "UpgradeCritical": console.log(chalk.red(deprecationMessage)); return await ctx.fatalError(1, "network"); default: console.log(deprecationMessage); break; } } ctx.deprecationMessagePrinted = true; } /// Call this method after a successful API response to conditionally print the /// "please upgrade" message. export function deprecationCheckWarning( ctx: Context, resp: AxiosResponse<any, any> ) { if (ctx.deprecationMessagePrinted) { return; } const headers = resp.headers; if (headers) { const deprecationState = headers["x-convex-deprecation-state"]; const deprecationMessage = headers["x-convex-deprecation-message"]; switch (deprecationState) { case undefined: break; case "Deprecated": case "UpgradeCritical": // These should never happen because such states are errors, not warnings. throw new Error( "Called deprecationCheckWarning on a fatal error. This is a bug." ); case "Upgradable": console.log(chalk.yellow(deprecationMessage)); break; default: console.log(deprecationMessage); break; } } ctx.deprecationMessagePrinted = true; } type Team = { id: number; name: string; slug: string; }; export async function validateOrSelectTeam( ctx: Context, teamSlug: string | null, promptMessage: string ): Promise<string> { const teams = await bigBrainAPI(ctx, "GET", "teams"); if (teams.length === 0) { console.error(chalk.red("Error: No teams found")); throw new Error("No teams found"); } if (!teamSlug) { // Prompt the user to select if they belong to more than one team. switch (teams.length) { case 1: return teams[0].slug; default: return ( await inquirer.prompt([ { name: "teamSlug", message: promptMessage, type: "list", choices: teams.map((team: Team) => ({ name: `${team.name} (${team.slug})`, value: team.slug, })), }, ]) ).teamSlug; } } else { // Validate the chosen team. if (!teams.find((team: Team) => team.slug === teamSlug)) { console.error(chalk.red(`Error: Team ${teamSlug} not found`)); throw new Error("Team not found"); } return teamSlug; } } export async function validateOrSelectProject( ctx: Context, projectSlug: string | null, teamSlug: string, singleProjectPrompt: string, multiProjectPrompt: string ): Promise<string | null> { const projects = await bigBrainAPI(ctx, "GET", `/teams/${teamSlug}/projects`); if (projects.length === 0) { console.error(chalk.red("Error: No projects found")); throw new Error("No projects found"); } if (!projectSlug) { // Prompt the user to select project. switch (projects.length) { case 1: { console.log("Found 1 project."); const project = projects[0]; const confirmed = ( await inquirer.prompt([ { type: "confirm", name: "confirmed", message: `${singleProjectPrompt} ${project.name} (${project.slug})?`, }, ]) ).confirmed; if (!confirmed) { return null; } return projects[0].slug; } default: console.log(`Found ${projects.length} projects.`); return ( await inquirer.prompt([ { name: "project", message: multiProjectPrompt, type: "list", choices: projects.map((project: Project) => ({ name: `${project.name} (${project.slug})`, value: project.slug, })), }, ]) ).project; } } else { // Validate the chosen project. if (!projects.find((project: Project) => project.slug === projectSlug)) { console.error(chalk.red(`Error: Project ${projectSlug} not found`)); throw new Error("Project not found"); } return projectSlug; } } class PackageJsonLoadError extends Error {} export interface Package { name: string; version: string; } export async function loadPackageJson(ctx: Context): Promise<Package[]> { let packageJson; try { packageJson = ctx.fs.readUtf8File("package.json"); } catch (err) { console.error( chalk.red( `Unable to read your package.json: ${err}. Make sure you're running this command from the root directory of a Convex app that contains the package.json` ) ); return await ctx.fatalError(1, "fs"); } let obj; try { obj = JSON.parse(packageJson); } catch (err) { console.error(chalk.red(`Unable to parse package.json: ${err}`)); return await ctx.fatalError(1, "fs", err); } if (typeof obj !== "object") { throw new PackageJsonLoadError( "Expected to parse an object from package.json" ); } const packages = []; if (obj.dependencies) { for (const dep in obj.dependencies) { packages.push({ name: dep, version: obj.dependencies[dep] }); } } if (obj.devDependencies) { for (const dep in obj.devDependencies) { packages.push({ name: dep, version: obj.devDependencies[dep] }); } } return packages; } export async function ensureHasConvexDependency(ctx: Context, cmd: string) { const packages = await loadPackageJson(ctx); const hasConvexDependency = !!packages.filter(({ name }) => name === "convex") .length; if (!hasConvexDependency) { console.error( chalk.red( `In order to ${cmd}, add \`convex\` to your package.json dependencies.` ) ); return await ctx.fatalError(1, "fs"); } } /** Return a new array with elements of the passed in array sorted by a key lambda */ export const sorted = <T>(arr: T[], key: (el: T) => any): T[] => { const newArr = [...arr]; const cmp = (a: T, b: T) => { if (key(a) < key(b)) return -1; if (key(a) > key(b)) return 1; return 0; }; return newArr.sort(cmp); }; export function functionsDir( configPath: string, projectConfig: ProjectConfig ): string { return path.join(path.dirname(configPath), projectConfig.functions); } export function rootDirectory(): string { let dirName; // Use a different directory for config files generated for tests if (process.env.CONVEX_PROVISION_HOST) { dirName = ".convex-test"; } else { dirName = ".convex"; } return path.join(os.homedir(), dirName); } export function globalConfigPath(): string { return path.join(rootDirectory(), "config.json"); } async function readGlobalConfig(ctx: Context): Promise<GlobalConfig | null> { const configPath = globalConfigPath(); let configFile; try { configFile = ctx.fs.readUtf8File(configPath); } catch (err) { return null; } try { const schema = z.object({ accessToken: z.string().min(1), }); const config: GlobalConfig = schema.parse(JSON.parse(configFile)); return config; } catch (err) { // Print an error an act as if the file does not exist. console.error( chalk.red( `Failed to parse global config in ${configPath} with error ${err}.` ) ); return null; } } export async function getAuthHeader(ctx: Context): Promise<string | null> { if (process.env.CONVEX_OVERRIDE_ACCESS_TOKEN) { return `Bearer ${process.env.CONVEX_OVERRIDE_ACCESS_TOKEN}`; } const globalConfig = await readGlobalConfig(ctx); if (globalConfig) { return `Bearer ${globalConfig.accessToken}`; } return null; } export async function bigBrainClient(ctx: Context): Promise<AxiosInstance> { const authHeader = await getAuthHeader(ctx); const headers: Record<string, string> = authHeader ? { Authorization: authHeader } : {}; return axios.create({ headers, baseURL: BIG_BRAIN_URL, }); } export async function bigBrainAPI( ctx: Context, method: Method, url: string, data?: any ): Promise<any> { let res; try { const client = await bigBrainClient(ctx); res = await client.request({ url, method, data }); deprecationCheckWarning(ctx, res); return res.data; } catch (err) { return await fatalServerErr(ctx, err); } } export type GlobalConfig = { accessToken: string; }; /** * Polls an arbitrary function until a condition is met. * * @param fetch Function performing a fetch, returning resulting data. * @param condition This function will terminate polling when it returns `true`. * @param waitMs How long to wait in between fetches. * @returns The resulting data from `fetch`. */ export const poll = async function <Result>( fetch: () => Promise<Result>, condition: (data: Result) => boolean, waitMs = 1000 ) { let result = await fetch(); while (!condition(result)) { await wait(waitMs); result = await fetch(); } return result; }; const wait = function (waitMs: number) { return new Promise(resolve => { setTimeout(resolve, waitMs); }); }; // We can eventually switch to something like `filesize` for i18n and // more robust formatting, but let's keep our CLI bundle small for now. export function formatSize(n: number): string { if (n < 1024) { return `${n} B`; } if (n < 1024 * 1024) { return `${Math.floor(n / 1024)} KB`; } if (n < 1024 * 1024 * 1024) { return `${Math.floor(n / 1024 / 1024)} MB`; } return `${n} B`; } export function formatDuration(ms: number): string { const twoDigits = (n: number, unit: string) => `${n.toLocaleString("en-US", { maximumFractionDigits: 2 })}${unit}`; if (ms < 1e-3) { return twoDigits(ms * 1e9, "ns"); } if (ms < 1) { return twoDigits(ms * 1e3, "µs"); } if (ms < 1e3) { return twoDigits(ms, "ms"); } const s = ms / 1e3; if (s < 60) { return twoDigits(ms / 1e3, "s"); } return twoDigits(s / 60, "m"); } // We don't allow running commands in project subdirectories yet, // but we can provide better errors if we look around. function findParentConfigs(ctx: Context): { parentPackageJson?: string; parentConvexJson?: string; } { const parentPackageJson = findUp(ctx, "package.json"); const candidateConvexJson = parentPackageJson && path.join(path.dirname(parentPackageJson), "convex.json"); const parentConvexJson = candidateConvexJson && ctx.fs.exists(candidateConvexJson) ? candidateConvexJson : undefined; return { parentPackageJson, parentConvexJson, }; } /** * Finds a file in the current working directory or a parent. * * @returns The absolute path of the first file found or undefined. */ function findUp(ctx: Context, filename: string): string | undefined { let curDir = path.resolve("."); let parentDir = curDir; do { const candidate = path.join(curDir, filename); if (ctx.fs.exists(candidate)) { return candidate; } curDir = parentDir; parentDir = path.dirname(curDir); } while (parentDir !== curDir); return; } /** * Ensures the current working directory contains package.json and convex.json * files by printing error messages or interactively offering to run `init()`. * * @param ctx * @param ensureConvexJson Offer to run init() if no convex.json file is present. */ export async function ensureProjectDirectory( ctx: Context, ensureConvexJson = false ) { const { parentPackageJson, parentConvexJson } = findParentConfigs(ctx); if (!parentPackageJson) { console.error( "No package.json found. If you meant to create a new project, try" ); console.error(`npx create-next-app@latest -e convex my-convex-app`); await ctx.fatalError(1); } if (parentPackageJson !== path.resolve("package.json")) { console.error("Run this command from the root directory of a project."); return await ctx.fatalError(1, "fs"); } if (ensureConvexJson && parentPackageJson && !parentConvexJson) { const expected = await configFilepath(ctx); console.error(`No convex.json file found at ${expected}`); const { confirmed } = await inquirer.prompt([ { type: "confirm", name: "confirmed", message: `Would you like to create a new Convex project here? (\`npx convex init\`)`, }, ]); if (!confirmed) { console.error("Run `npx convex dev` in a directory with a convex.json."); return await ctx.fatalError(1, "fs"); } await init(ctx, null, null); } }