convex
Version:
Client for the Convex Cloud
531 lines (496 loc) • 14.8 kB
text/typescript
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);
}
}