UNPKG

convex

Version:

Client for the Convex Cloud

532 lines (502 loc) 15.7 kB
// ---------------------------------------------------------------------------- // Anonymous (No account) import path from "path"; import { Context } from "../../../bundler/context.js"; import { logFinishedStep, logMessage, logVerbose, logWarning, } from "../../../bundler/log.js"; import { promptSearch, promptYesNo } from "../utils/prompts.js"; import { bigBrainGenerateAdminKeyForAnonymousDeployment, bigBrainPause, bigBrainStart, } from "./bigBrain.js"; import { LocalDeploymentError, printLocalDeploymentOnError } from "./errors.js"; import { LocalDeploymentKind, deploymentStateDir, ensureUuidForAnonymousUser, legacyDeploymentStateDir, loadDeploymentConfig, loadDeploymentConfigFromDir, loadProjectLocalConfig, saveDeploymentConfig, } from "./filePaths.js"; import { rootDeploymentStateDir } from "./filePaths.js"; import { LocalDeploymentConfig } from "./filePaths.js"; import { DeploymentDetails } from "./localDeployment.js"; import { ensureBackendStopped, localDeploymentUrl } from "./run.js"; import { ensureBackendRunning } from "./run.js"; import { handlePotentialUpgrade } from "./upgrade.js"; import { isOffline, generateInstanceSecret, choosePorts, LOCAL_BACKEND_INSTANCE_SECRET, } from "./utils.js"; import { handleDashboard } from "./dashboard.js"; import { recursivelyDelete, recursivelyCopy } from "../fsUtils.js"; import { ensureBackendBinaryDownloaded } from "./download.js"; import { isAnonymousDeployment } from "../deployment.js"; import { createProject } from "../api.js"; import { removeAnonymousPrefix } from "../deployment.js"; import { nodeFs } from "../../../bundler/fs.js"; import { doInitConvexFolder } from "../codegen.js"; export async function handleAnonymousDeployment( ctx: Context, options: { ports?: | { cloud: number; site: number; } | undefined; backendVersion?: string | undefined; dashboardVersion?: string | undefined; forceUpgrade: boolean; deploymentName: string | null; chosenConfiguration: "new" | "existing" | "ask" | null; }, ): Promise<DeploymentDetails> { if (await isOffline()) { return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: "Cannot run a local deployment while offline", }); } const deployment = await chooseDeployment(ctx, { deploymentName: options.deploymentName, chosenConfiguration: options.chosenConfiguration, }); if ( deployment.kind === "first" && process.env.CONVEX_AGENT_MODE !== "anonymous" ) { logMessage( "This command, `npx convex dev`, will run your Convex backend locally and update it with the function you write in the `convex/` directory.", ); logMessage( "Use `npx convex dashboard` to view and interact with your project from a web UI.", ); logMessage( "Use `npx convex docs` to read the docs and `npx convex help` to see other commands.", ); ensureUuidForAnonymousUser(ctx); if (process.stdin.isTTY) { const result = await promptYesNo(ctx, { message: "Continue?", default: true, }); if (!result) { return ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: "Exiting", }); } } } ctx.registerCleanup(async (_exitCode, err) => { if (err instanceof LocalDeploymentError) { printLocalDeploymentOnError(); } }); const { binaryPath, version } = await ensureBackendBinaryDownloaded( ctx, options.backendVersion === undefined ? { kind: "latest", } : { kind: "version", version: options.backendVersion }, ); await handleDashboard(ctx, version); let adminKey: string; let instanceSecret: string; if (deployment.kind === "existing") { adminKey = deployment.config.adminKey; instanceSecret = deployment.config.instanceSecret ?? LOCAL_BACKEND_INSTANCE_SECRET; // If it's still running for some reason, exit and tell the user to kill it. // It's fine if a different backend is running on these ports though since we'll // pick new ones. await ensureBackendStopped(ctx, { ports: { cloud: deployment.config.ports.cloud, }, maxTimeSecs: 5, deploymentName: deployment.deploymentName, allowOtherDeployments: true, }); } else { instanceSecret = generateInstanceSecret(); const data = await bigBrainGenerateAdminKeyForAnonymousDeployment(ctx, { instanceName: deployment.deploymentName, instanceSecret, }); adminKey = data.adminKey; } const [cloudPort, sitePort] = await choosePorts(ctx, { count: 2, startPort: 3210, requestedPorts: [options.ports?.cloud ?? null, options.ports?.site ?? null], }); const onActivity = async (isOffline: boolean, _wasOffline: boolean) => { await ensureBackendRunning(ctx, { cloudPort, deploymentName: deployment.deploymentName, maxTimeSecs: 5, }); if (isOffline) { return; } }; const { cleanupHandle } = await handlePotentialUpgrade(ctx, { deploymentName: deployment.deploymentName, deploymentKind: "anonymous", oldVersion: deployment.kind === "existing" ? deployment.config.backendVersion : null, newBinaryPath: binaryPath, newVersion: version, ports: { cloud: cloudPort, site: sitePort }, adminKey, instanceSecret, forceUpgrade: options.forceUpgrade, }); const cleanupFunc = ctx.removeCleanup(cleanupHandle); ctx.registerCleanup(async (exitCode, err) => { if (cleanupFunc !== null) { await cleanupFunc(exitCode, err); } }); if (deployment.kind === "new") { await doInitConvexFolder(ctx); } return { adminKey, deploymentName: deployment.deploymentName, deploymentUrl: localDeploymentUrl(cloudPort), onActivity, }; } export async function loadAnonymousDeployment( ctx: Context, deploymentName: string, ): Promise<LocalDeploymentConfig> { const config = loadDeploymentConfig(ctx, "anonymous", deploymentName); if (config === null) { return ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: `Could not find deployment with name ${deploymentName}!`, }); } return config; } /** * List legacy anonymous deployments from the home directory. * These are deployments stored in ~/.convex/anonymous-convex-backend-state/ */ export function listLegacyAnonymousDeployments(ctx: Context): Array<{ deploymentName: string; config: LocalDeploymentConfig; }> { const deployments: Array<{ deploymentName: string; config: LocalDeploymentConfig; }> = []; const dir = rootDeploymentStateDir("anonymous"); if (ctx.fs.exists(dir)) { const deploymentNames = ctx.fs .listDir(dir) .map((d) => d.name) .filter((d) => isAnonymousDeployment(d)); for (const deploymentName of deploymentNames) { const legacyDir = legacyDeploymentStateDir("anonymous", deploymentName); const config = loadDeploymentConfigFromDir(ctx, legacyDir); if (config !== null) { deployments.push({ deploymentName, config }); } } } return deployments; } export async function listExistingAnonymousDeployments(ctx: Context): Promise< Array<{ deploymentName: string; config: LocalDeploymentConfig; }> > { const deployments: Array<{ deploymentName: string; config: LocalDeploymentConfig; }> = []; // Check project-local storage first const projectLocal = loadProjectLocalConfig(ctx); if ( projectLocal !== null && isAnonymousDeployment(projectLocal.deploymentName) ) { deployments.push(projectLocal); } // Check legacy home directory, avoiding duplicates for (const legacy of listLegacyAnonymousDeployments(ctx)) { if (!deployments.some((d) => d.deploymentName === legacy.deploymentName)) { deployments.push(legacy); } } return deployments; } async function chooseDeployment( ctx: Context, options: { deploymentName: string | null; chosenConfiguration: "new" | "existing" | "ask" | null; }, ): Promise< | { kind: "existing"; deploymentName: string; config: LocalDeploymentConfig; } | { kind: "new"; deploymentName: string; } | { kind: "first"; deploymentName: string; } > { // Check for existing project-local deployment first - use it if it exists const projectLocal = loadProjectLocalConfig(ctx); if (projectLocal !== null) { if (isAnonymousDeployment(projectLocal.deploymentName)) { // Already an anonymous deployment - use it as-is return { kind: "existing", deploymentName: projectLocal.deploymentName, config: projectLocal.config, }; } // Project-local has data from a different deployment type (e.g., "local-*") // Create a new anonymous deployment that will reuse this data and update the config logVerbose( `Project-local has ${projectLocal.deploymentName}, switching to anonymous`, ); return { deploymentName: generateDeploymentName(), kind: "new" }; } // Check if a specific deployment name was requested (legacy support) if (options.deploymentName !== null && options.chosenConfiguration === null) { const deployments = await listExistingAnonymousDeployments(ctx); const existing = deployments.find( (d) => d.deploymentName === options.deploymentName, ); if (existing === undefined) { logWarning(`Could not find project with name ${options.deploymentName}!`); } else { return { kind: "existing", deploymentName: existing.deploymentName, config: existing.config, }; } } // Handle agent mode - use fixed name since there's one deployment per project if (process.env.CONVEX_AGENT_MODE === "anonymous") { const deploymentName = "anonymous-agent"; logVerbose(`Deployment name: ${deploymentName}`); return { kind: "new", deploymentName, }; } // No project-local data - check for legacy deployments in home directory const legacyDeployments = listLegacyAnonymousDeployments(ctx); // No legacy deployments - auto-create a new project without prompting if (legacyDeployments.length === 0) { logMessage("Setting up a new project..."); return { deploymentName: generateDeploymentName(), kind: "first" }; } // User explicitly wants a new deployment - create without prompting for name if (options.chosenConfiguration === "new") { return { deploymentName: generateDeploymentName(), kind: "new" }; } // Legacy deployments exist - prompt user to choose const newOrExisting = await promptSearch(ctx, { message: "Which project would you like to use?", choices: [ ...(options.chosenConfiguration === "existing" ? [] : [ { name: "Create a new one", value: "new", }, ]), ...legacyDeployments.map((d) => ({ name: d.deploymentName, value: d.deploymentName, })), ], }); if (newOrExisting !== "new") { const existingDeployment = legacyDeployments.find( (d) => d.deploymentName === newOrExisting, ); if (existingDeployment === undefined) { return ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: `Could not find project with name ${newOrExisting}!`, }); } return { kind: "existing", deploymentName: existingDeployment.deploymentName, config: existingDeployment.config, }; } // User chose to create a new one - no name prompt needed return { deploymentName: generateDeploymentName(), kind: "new" }; } /** * Returns a name for a new anonymous deployment. */ function generateDeploymentName() { const baseName = path.basename(process.cwd()); const deploymentName = `anonymous-${baseName}`; logVerbose(`Deployment name: ${deploymentName}`); return deploymentName; } /** * This takes an "anonymous" deployment and makes it a "local" deployment * that is associated with a project in the given team. */ export async function handleLinkToProject( ctx: Context, args: { deploymentName: string; teamSlug: string; projectSlug: string | null; }, ): Promise<{ deploymentName: string; deploymentUrl: string; projectSlug: string; }> { logVerbose( `Linking ${args.deploymentName} to a project in team ${args.teamSlug}`, ); const config = loadDeploymentConfig(ctx, "anonymous", args.deploymentName); if (config === null) { return ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: "Failed to load deployment config - try running `npx convex dev --configure`", }); } await ensureBackendStopped(ctx, { ports: { cloud: config.ports.cloud, }, deploymentName: args.deploymentName, allowOtherDeployments: true, maxTimeSecs: 5, }); const projectName = removeAnonymousPrefix(args.deploymentName); let projectSlug: string; if (args.projectSlug !== null) { projectSlug = args.projectSlug; } else { const { projectSlug: newProjectSlug } = await createProject(ctx, { teamSlug: args.teamSlug, projectName, deploymentToProvision: null, }); projectSlug = newProjectSlug; } logVerbose(`Creating local deployment in project ${projectSlug}`); // Register it in big brain const { deploymentName: localDeploymentName, adminKey } = await bigBrainStart( ctx, { port: config.ports.cloud, projectSlug, teamSlug: args.teamSlug, instanceName: null, }, ); const localConfig = loadDeploymentConfig(ctx, "local", localDeploymentName); if (localConfig !== null) { return ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: `Project ${projectSlug} already has a local deployment, so we cannot link this anonymous local deployment to it.`, }); } logVerbose(`Moving ${args.deploymentName} to ${localDeploymentName}`); await moveDeployment( ctx, { deploymentKind: "anonymous", deploymentName: args.deploymentName, }, { deploymentKind: "local", deploymentName: localDeploymentName, }, ); logVerbose(`Saving deployment config for ${localDeploymentName}`); saveDeploymentConfig(ctx, "local", localDeploymentName, { adminKey, backendVersion: config.backendVersion, ports: config.ports, }); await bigBrainPause(ctx, { projectSlug, teamSlug: args.teamSlug, }); logFinishedStep(`Linked ${args.deploymentName} to project ${projectSlug}`); return { projectSlug, deploymentName: localDeploymentName, deploymentUrl: localDeploymentUrl(config.ports.cloud), }; } export async function moveDeployment( ctx: Context, oldDeployment: { deploymentKind: LocalDeploymentKind; deploymentName: string; }, newDeployment: { deploymentKind: LocalDeploymentKind; deploymentName: string; }, ) { const oldPath = deploymentStateDir( ctx, oldDeployment.deploymentKind, oldDeployment.deploymentName, ); const newPath = deploymentStateDir( ctx, newDeployment.deploymentKind, newDeployment.deploymentName, ); // If both paths are the same (project-local storage), no file movement needed. // The config will be updated separately by saveDeploymentConfig. if (oldPath === newPath) { logVerbose( `Source and destination are the same (${oldPath}), skipping file copy`, ); return; } await recursivelyCopy(ctx, nodeFs, oldPath, newPath); recursivelyDelete(ctx, oldPath); }