import * as child_process from "child_process"; import { Command } from "commander"; import * as crypto from 'crypto'; import * as fs from "fs"; import inquirer from "inquirer"; import open from "open"; import * as os from 'os'; import * as path from "path"; import { PostHog } from 'posthog-node'; import packageJson from '../package.json'; import { scheduleMcpConfiguration } from "./mcp"; import { invokeCallback } from "./telegram"; import { Colorize, configureVerboseLogging, logVerbose, templateIdentity } from "./util"; export { templateIdentity } from "./util"; const jsLikeFileExtensions: string[] = [ "mtsx", "ctsx", "tsx", "mts", "cts", "ts", "mjsx", "cjsx", "jsx", "mjs", "cjs", "js", ]; class UserError extends Error { constructor(message: string) { super(message); this.name = "UserError"; } } class UnansweredQuestionError extends UserError { constructor(message: string) { super(message + ", or use --on-question to answer questions automatically or interactively"); this.name = "UnansweredQuestionError"; } } type OnQuestionMode = "ask" | "guess" | "error"; function isTruthyEnv(name: string): boolean { const v = process.env[name]; if (!v) return false; const s = String(v).toLowerCase(); return s === "1" || s === "true" || s === "yes"; } function isNonInteractiveEnv(): boolean { if (isTruthyEnv("CI")) return true; if (isTruthyEnv("GITHUB_ACTIONS")) return true; if (isTruthyEnv("NONINTERACTIVE")) return true; if (isTruthyEnv("NO_INTERACTIVE")) return true; if (isTruthyEnv("PNPM_NON_INTERACTIVE")) return true; if (isTruthyEnv("YARN_ENABLE_NON_INTERACTIVE")) return true; if (isTruthyEnv("CURSOR_AGENT")) return true; if (isTruthyEnv("CLAUDECODE")) return true; return false; } function resolveOnQuestionMode(opt: string): OnQuestionMode { if (!opt || opt === "default") { return isNonInteractiveEnv() ? "error" : "ask"; } if (opt === "ask" || opt === "guess" || opt === "error") { return opt; } throw new UserError(`Invalid argument for --on-question: "${opt}". Valid modes are: "ask", "guess", "error", "default".`); } // Setup command line parsing const program = new Command(); program .name(packageJson.name) .description("Stack Auth Initialization Tool") .version(packageJson.version) .argument("[project-path]", "Path to your project") .usage(`[project-path] [options]`) .option("--dry-run", "Run without making any changes") .option("--neon", "Use Neon database") .option("--js", "Initialize for JavaScript project") .option("--next", "Initialize for Next.js project") .option("--react", "Initialize for React project") .option("--npm", "Use npm as package manager") .option("--yarn", "Use yarn as package manager") .option("--pnpm", "Use pnpm as package manager") .option("--bun", "Use bun as package manager") .option("--client", "Initialize client-side only") .option("--server", "Initialize server-side only") .option("--project-id ", "Project ID to use in setup") .option("--publishable-client-key ", "Publishable client key to use in setup") .option("--no-browser", "Don't open browser for environment variable setup") .option("--on-question ", "How to handle interactive questions: ask | guess | error | default", "default") .option("--no-warn-uncommitted-changes", "Don't warn about uncommitted changes in the Git repository") .addHelpText('after', ` For more information, please visit https://docs.stack-auth.com/getting-started/setup`); program.parse(); const options = program.opts(); // Keep existing variables but assign from Commander let savedProjectPath: string | undefined = program.args[0] || undefined; const verboseEnvRaw = process.env.STACK_VERBOSE; const parsedVerboseLevel = typeof verboseEnvRaw === "string" && verboseEnvRaw.trim().length > 0 ? Number.parseInt(verboseEnvRaw.trim(), 10) : 0; const verboseLevel: number = Number.isFinite(parsedVerboseLevel) ? Math.max(0, parsedVerboseLevel) : 0; const isVerbose: boolean = verboseLevel > 0; const isDryRun: boolean = options.dryRun || isTruthyEnv("STACK_DRY_RUN") || false; const isNeon: boolean = options.neon || false; const typeFromArgs: "js" | "next" | "react" | undefined = options.js ? "js" : options.next ? "next" : options.react ? "react" : undefined; const packageManagerFromArgs: string | undefined = options.npm ? "npm" : options.yarn ? "yarn" : options.pnpm ? "pnpm" : options.bun ? "bun" : undefined; const isClient: boolean = options.client || false; const isServer: boolean = options.server || false; const projectIdFromArgs: string | undefined = options.projectId; const publishableClientKeyFromArgs: string | undefined = options.publishableClientKey; const onQuestionMode: OnQuestionMode = resolveOnQuestionMode(options.onQuestion); const warnUncommittedChanges: boolean = options.warnUncommittedChanges ?? true; // Commander negates the boolean options with prefix `--no-` // so `--no-browser` becomes `browser: false` const noBrowser: boolean = !options.browser; type Ansis = { red: string, blue: string, green: string, yellow: string, clear: string, bold: string, }; const ansis: Ansis = { red: "\x1b[31m", blue: "\x1b[34m", green: "\x1b[32m", yellow: "\x1b[33m", clear: "\x1b[0m", bold: "\x1b[1m", }; const colorize: Colorize = { red: (strings, ...values) => ansis.red + templateIdentity(strings, ...values) + ansis.clear, blue: (strings, ...values) => ansis.blue + templateIdentity(strings, ...values) + ansis.clear, green: (strings, ...values) => ansis.green + templateIdentity(strings, ...values) + ansis.clear, yellow: (strings, ...values) => ansis.yellow + templateIdentity(strings, ...values) + ansis.clear, bold: (strings, ...values) => ansis.bold + templateIdentity(strings, ...values) + ansis.clear, }; configureVerboseLogging({ level: verboseLevel, formatter: (message) => colorize.blue`[verbose] ${message}`, }); const filesCreated: string[] = []; const filesModified: string[] = []; const commandsExecuted: string[] = []; const packagesToInstall: string[] = []; const writeFileHandlers: Array<() => Promise> = []; const deferredCommandHandlers: Array<() => Promise> = []; const nextSteps: string[] = [ `Create an account and Stack Auth API key for your project on https://app.stack-auth.com`, ]; const STACK_AUTH_PUBLIC_HOG_KEY = "phc_vIUFi0HzHo7oV26OsaZbUASqxvs8qOmap1UBYAutU4k"; const EVENT_PREFIX = "stack-init-"; const ph_client = new PostHog(STACK_AUTH_PUBLIC_HOG_KEY, { host: "https://eu.i.posthog.com", flushAt: 1, flushInterval: 0, }); const distinctId = crypto.randomUUID(); async function capture(event: string, properties: Record) { logVerbose("capture event", { event, properties }); ph_client.capture({ event: `${EVENT_PREFIX}${event}`, distinctId, properties, }); } async function main(): Promise { // Welcome message console.log(); console.log(` ██████ ██████████████ ████████████████████ ████████████████████ WELCOME TO █████████████████ ╔═╗╔╦╗╔═╗╔═╗╦╔═ ┌─┐┬ ┬┌┬┐┬ ┬ █████████████ ╚═╗ ║ ╠═╣║ ╠╩╗ ├─┤│ │ │ ├─┤ █████████████ ████ ╚═╝ ╩ ╩ ╩╚═╝╩ ╩ ┴ ┴└─┘ ┴ ┴ ┴ █████████████████ ██████ ██ ████ ████ █████ █████ ██████ `); console.log(); logVerbose("Initialization run metadata", { version: packageJson.version, cwd: process.cwd(), args: program.args, options: { isDryRun, isVerbose, isNeon, typeFromArgs, packageManagerFromArgs, isClient, isServer, projectIdFromArgs: Boolean(projectIdFromArgs), publishableClientKeyFromArgs: Boolean(publishableClientKeyFromArgs), noBrowser, onQuestionMode, verboseLevel, }, }); await capture("start", { version: packageJson.version, isDryRun, isNeon, typeFromArgs, packageManagerFromArgs, isClient, isServer, noBrowser, platform: os.platform(), arch: os.arch(), nodeVersion: process.version, }); // Wait just briefly so we can use `Steps` in here (it's defined only after the call to `main()`) await new Promise((resolve) => resolve()); // Prepare some stuff await clearStdin(); const projectPath = await getProjectPath(); await ensureGitWorkspaceIsReady(projectPath); logVerbose("Project path prepared", { projectPath, isDryRun, isVerbose }); scheduleMcpConfiguration({ projectPath, isDryRun, colorize, registerWriteHandler: (handler) => writeFileHandlers.push(handler), registerCommandHandler: (handler) => deferredCommandHandlers.push(handler), recordFileChange, runScheduledCommand, }); nextSteps.push("Restart your MCP-enabled editors so they pick up the Stack Auth MCP."); logVerbose("MCP configuration scheduled", { writeHandlers: writeFileHandlers.length, deferredCommands: deferredCommandHandlers.length, }); // Steps const { packageJson: projectPackageJson } = await Steps.getProject(); const type = await Steps.getProjectType({ packageJson: projectPackageJson }); logVerbose("Project inspection complete", { detectedType: type, dependencies: { hasReact: Boolean(projectPackageJson.dependencies?.["react"]), hasNext: Boolean(projectPackageJson.dependencies?.["next"]), }, }); await capture("project-type-selected", { type, wasSpecifiedInArgs: !!typeFromArgs, }); await Steps.addStackPackage(type); if (isNeon) packagesToInstall.push('@neondatabase/serverless'); await Steps.writeEnvVars(type); const convexIntegration = await Steps.maybeInstallConvexIntegration({ packageJson: projectPackageJson, type }); if (convexIntegration) { nextSteps.push(...convexIntegration.instructions); logVerbose("Convex integration detected", convexIntegration); } if (type === "next") { const projectInfo = await Steps.getNextProjectInfo({ packageJson: projectPackageJson }); await Steps.updateNextLayoutFile(projectInfo); await Steps.writeStackAppFile(projectInfo, "client", true); await Steps.writeStackAppFile(projectInfo, "server", true); await Steps.writeNextHandlerFile(projectInfo); await Steps.writeNextLoadingFile(projectInfo); nextSteps.push(`Copy the environment variables from the new API key into your .env.local file`); } else if (type === "react") { const defaultExtension = await Steps.guessDefaultFileExtension(); const srcPath = await Steps.guessSrcPath(); const hasReactRouterDom = !!(projectPackageJson.dependencies?.["react-router-dom"] || projectPackageJson.devDependencies?.["react-router-dom"]); const { fileName } = await Steps.writeReactClientFile({ srcPath, defaultExtension, indentation: " ", hasReactRouterDom, }); nextSteps.push( `Copy the environment variables from the new API key into your own environment and reference them in ${fileName}`, ); } else { const defaultExtension = await Steps.guessDefaultFileExtension(); const where = await Steps.getServerOrClientOrBoth(); const srcPath = await Steps.guessSrcPath(); const appFiles: string[] = []; for (const w of where) { const { fileName } = await Steps.writeStackAppFile({ type, defaultExtension, indentation: " ", srcPath, }, w, where.includes("client")); appFiles.push(fileName); } nextSteps.push( `Copy the environment variables from the new API key into your own environment and reference them in ${appFiles.join(" and ")}`, `Follow the instructions on how to use Stack Auth's vanilla SDK at http://docs.stack-auth.com/others/js-client`, ); } logVerbose("Primary integration steps completed", { type, nextStepsCount: nextSteps.length }); const { packageManager } = await Steps.getPackageManager(); logVerbose("Package manager determined", { packageManager }); await capture(`package-manager-selected`, { packageManager, wasSpecifiedInArgs: !!packageManagerFromArgs, }); await Steps.ensureReady(type); // Install dependencies console.log(); console.log(colorize.bold`Installing dependencies...`); const installCommandMap = new Map([ ["npm", "npm install"], ["yarn", "yarn add"], ["pnpm", "pnpm add"], ["bun", "bun add"], ]); const installCommand = installCommandMap.get(packageManager) ?? `${packageManager} install`; // Quote each package name to avoid shell interpretation of env-overridden values. const safePackages = packagesToInstall.map((p) => JSON.stringify(p)); await shellNicelyFormatted(`${installCommand} ${safePackages.join(' ')}`, { shell: true, cwd: projectPath, }); logVerbose("Dependency installation finished", { packageManager, packages: packagesToInstall, }); await capture(`dependencies-installed`, { packageManager, packages: packagesToInstall, }); // Write files console.log(); console.log(colorize.bold`Writing files...`); console.log(); for (let i = 0; i < writeFileHandlers.length; i++) { const writeFileHandler = writeFileHandlers[i]; logVerbose("Executing write handler", { index: i }); await writeFileHandler(); } console.log(`${colorize.green`√`} Done writing files`); await runDeferredCommands(); console.log('\n\n\n'); console.log(colorize.bold`${colorize.green`Installation succeeded!`}`); console.log(); console.log("Commands executed:"); for (const command of commandsExecuted) { console.log(` ${colorize.blue`${command}`}`); } console.log(); console.log("MCP servers installed:"); console.log(` ${colorize.green`https://mcp.stack-auth.com`}`); console.log(); console.log("Files written:"); for (const file of filesModified) { console.log(` ${colorize.yellow`${file}`}`); } for (const file of filesCreated) { console.log(` ${colorize.green`${file}`}`); } console.log(); await capture("complete", { success: true, type, packageManager, isNeon, isClient, isServer, noBrowser, filesCreated, filesModified, commandsExecuted, }); await invokeCallback({ success: true, distinctId, options, args: program.args, isNonInteractive: isNonInteractiveEnv(), timestamp: new Date().toISOString(), projectPath, }); // Success! console.log(` ${colorize.green`===============================================`} ${colorize.green`Successfully installed Stack! 🚀🚀🚀`} ${colorize.bold`Next steps:`} 1. ${noBrowser ? `Create a project at https://app.stack-auth.com and get your API keys` : `Complete the setup in your browser to get your API keys`} 2. Add the API keys to your .env.local file 3. Import the Stack components in your app 4. Add authentication to your app For more information, please visit https://docs.stack-auth.com/getting-started/setup `.trim()); if (!noBrowser) { await open(`https://app.stack-auth.com/wizard-congrats?stack-init-id=${encodeURIComponent(distinctId)}`); } await ph_client.shutdown(); } // eslint-disable-next-line no-restricted-syntax main().catch(async (err) => { try { await capture("error", { error: err.message, errorType: err instanceof UserError ? "UserError" : "SystemError", stack: err.stack, }); } catch (e) { } if (!(err instanceof UserError)) { console.error(err); } console.error('\n\n\n\n'); console.log(colorize.red`===============================================`); console.error(); if (err instanceof UserError) { console.error(`${colorize.red`ERROR!`} ${err.message}`); } else { console.error("An error occurred during the initialization process."); } console.error(); console.log(colorize.red`===============================================`); console.error(); console.error( "If you need assistance, please try installing Stack manually as described in https://docs.stack-auth.com/getting-started/setup or join our Discord where we're happy to help: https://discord.stack-auth.com" ); if (!(err instanceof UserError)) { console.error(""); console.error(`Error message: ${err.message}`); } console.error(); const fallbackErrorMessage = (() => { if (err instanceof Error) return err.message; if (typeof err === "string") return err; try { return JSON.stringify(err); } catch { return "Unknown error"; } })(); await invokeCallback({ success: false, distinctId, options, args: program.args, isNonInteractive: isNonInteractiveEnv(), timestamp: new Date().toISOString(), projectPath: savedProjectPath, error: { name: err instanceof Error ? err.name : undefined, message: fallbackErrorMessage, stack: err instanceof Error ? err.stack : undefined, }, }); await ph_client.shutdown(); process.exit(1); }); type PackageJson = { dependencies?: Record, devDependencies?: Record, [key: string]: any, } type ProjectInfo = { type: "js" | "next" | "react", srcPath: string, appPath: string, defaultExtension: string, indentation: string, } type NextProjectInfoError = { error: string, } type NextProjectInfoResult = ProjectInfo | NextProjectInfoError; type StackAppFileOptions = { type: "js" | "next" | "react", srcPath: string, defaultExtension: string, indentation: string, } type StackAppFileResult = { fileName: string, } type ConvexIntegrationResult = { instructions: string[], } const Steps = { async getProject(): Promise<{ packageJson: PackageJson }> { let projectPath = await getProjectPath(); logVerbose("Steps.getProject invoked", { projectPath }); if (!fs.existsSync(projectPath)) { throw new UserError(`The project path ${projectPath} does not exist`); } const packageJsonPath = path.join(projectPath, "package.json"); if (!fs.existsSync(packageJsonPath)) { throw new UserError( `The package.json file does not exist in the project path ${projectPath}. You must initialize a new project first before installing Stack.` ); } const packageJsonText = fs.readFileSync(packageJsonPath, "utf-8"); let packageJson: PackageJson; try { packageJson = JSON.parse(packageJsonText); } catch (e) { throw new UserError(`package.json file is not valid JSON: ${e}`); } logVerbose("Steps.getProject completed", { packageJsonPath, dependencyCounts: { dependencies: Object.keys(packageJson.dependencies ?? {}).length, devDependencies: Object.keys(packageJson.devDependencies ?? {}).length, }, }); return { packageJson }; }, async getProjectType({ packageJson }: { packageJson: PackageJson }): Promise<"js" | "next" | "react"> { if (typeFromArgs) { logVerbose("Steps.getProjectType using CLI override", { typeFromArgs }); return typeFromArgs; } logVerbose("Steps.getProjectType attempting autodetect", { hasNext: Boolean(packageJson.dependencies?.["next"] || packageJson.devDependencies?.["next"]), hasReact: Boolean(packageJson.dependencies?.["react"] || packageJson.dependencies?.["react-dom"]), onQuestionMode, }); const maybeNextProject = await Steps.maybeGetNextProjectInfo({ packageJson }); if (!("error" in maybeNextProject)) { logVerbose("Steps.getProjectType resolved to Next.js project"); return "next"; } if (packageJson.dependencies?.["react"] || packageJson.dependencies?.["react-dom"]) { logVerbose("Steps.getProjectType resolved to React project"); return "react"; } if (onQuestionMode === "guess") { logVerbose("Steps.getProjectType defaulting to JS due to --on-question=guess"); return "js"; } if (onQuestionMode === "error") { throw new UnansweredQuestionError("Unable to auto-detect project type (checked for Next.js and React dependencies). Re-run with one of: --js, --react, or --next."); } const { type } = await inquirer.prompt([ { type: "list", name: "type", message: "Which integration would you like to install?", choices: [ { name: "Vanilla JS (other/no framework)", value: "js" }, { name: "Node.js", value: "js" }, { name: "React", value: "react" }, { name: "Next.js", value: "next" }, ] } ]); logVerbose("Steps.getProjectType received user selection", { type }); return type; }, async getStackPackageName(type: "js" | "next" | "react", install = false): Promise { const mapping = { js: (install && process.env.STACK_JS_INSTALL_PACKAGE_NAME_OVERRIDE) || "@stackframe/js", next: (install && process.env.STACK_NEXT_INSTALL_PACKAGE_NAME_OVERRIDE) || "@stackframe/stack", react: (install && process.env.STACK_REACT_INSTALL_PACKAGE_NAME_OVERRIDE) || "@stackframe/react", } as const; const packageName = mapping[type]; logVerbose("Steps.getStackPackageName resolved", { type, install, packageName }); return packageName; }, async addStackPackage(type: "js" | "next" | "react"): Promise { const pkgName = await Steps.getStackPackageName(type, true); logVerbose("Steps.addStackPackage scheduling install", { pkgName }); packagesToInstall.push(pkgName); }, async getNextProjectInfo({ packageJson }: { packageJson: PackageJson }): Promise { logVerbose("Steps.getNextProjectInfo invoked"); const maybe = await Steps.maybeGetNextProjectInfo({ packageJson }); if ("error" in maybe) { logVerbose("Steps.getNextProjectInfo failed validation", maybe); throw new UserError(maybe.error); } logVerbose("Steps.getNextProjectInfo resolved", maybe); return maybe; }, async maybeGetNextProjectInfo({ packageJson }: { packageJson: PackageJson }): Promise { const projectPath = await getProjectPath(); logVerbose("Steps.maybeGetNextProjectInfo evaluating Next.js eligibility", { projectPath }); const nextVersionInPackageJson = packageJson.dependencies?.["next"] ?? packageJson.devDependencies?.["next"]; if (!nextVersionInPackageJson) { logVerbose("Steps.maybeGetNextProjectInfo missing Next dependency"); return { error: `The project at ${projectPath} does not appear to be a Next.js project, or does not have 'next' installed as a dependency.` }; } if (!nextVersionInPackageJson) { logVerbose("Steps.maybeGetNextProjectInfo found unsupported Next version", { version: nextVersionInPackageJson }); return { error: `The project at ${projectPath} is using an unsupported version of Next.js (found ${nextVersionInPackageJson}).\n\nOnly Next.js 14 & 15 projects are currently supported. See Next's upgrade guide: https://nextjs.org/docs/app/building-your-application/upgrading/version-14` }; } const hasSrcAppFolder = fs.existsSync(path.join(projectPath, "src/app")); const srcPath = path.join(projectPath, hasSrcAppFolder ? "src" : ""); const appPath = path.join(srcPath, "app"); if (!fs.existsSync(appPath)) { logVerbose("Steps.maybeGetNextProjectInfo missing Next app directory", { appPath }); return { error: `The app path ${appPath} does not exist. Only the Next.js App router is supported — are you maybe on the Pages router instead?` }; } const nextConfigPathWithoutExtension = path.join(projectPath, "next.config"); const nextConfigFileExtension = await findJsExtension( nextConfigPathWithoutExtension ); const nextConfigPath = nextConfigPathWithoutExtension + "." + (nextConfigFileExtension ?? "js"); if (!fs.existsSync(nextConfigPath)) { logVerbose("Steps.maybeGetNextProjectInfo missing next.config file", { nextConfigPath }); return { error: `Expected file at ${nextConfigPath} for Next.js projects.` }; } const dryUpdateNextLayoutFileResult = await Steps.dryUpdateNextLayoutFile({ appPath, defaultExtension: "jsx" }); logVerbose("Steps.maybeGetNextProjectInfo success", { projectPath, srcPath, appPath, detectedExtension: dryUpdateNextLayoutFileResult.fileExtension, }); return { type: "next", srcPath, appPath, defaultExtension: dryUpdateNextLayoutFileResult.fileExtension, indentation: dryUpdateNextLayoutFileResult.indentation, }; }, async writeEnvVars(type: "js" | "next" | "react"): Promise { const projectPath = await getProjectPath(); logVerbose("Steps.writeEnvVars invoked", { type, projectPath }); // TODO: in non-Next environments, ask the user what method they prefer for envvars if (type !== "next") { logVerbose("Steps.writeEnvVars skipped", { reason: "non-next-project" }); return false; } const envLocalPath = path.join(projectPath, ".env.local"); const potentialEnvLocations = [ path.join(projectPath, ".env"), path.join(projectPath, ".env.development"), path.join(projectPath, ".env.default"), path.join(projectPath, ".env.defaults"), path.join(projectPath, ".env.example"), envLocalPath, ]; if (potentialEnvLocations.every((p) => !fs.existsSync(p))) { const envContent = noBrowser ? "# Stack Auth keys\n" + "# To get these variables:\n" + "# 1. Go to https://app.stack-auth.com\n" + "# 2. Create a new project\n" + "# 3. Copy the keys below\n" + `NEXT_PUBLIC_STACK_PROJECT_ID="${projectIdFromArgs ?? ""}"\n` + `NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY="${publishableClientKeyFromArgs ?? ""}"\n` + "STACK_SECRET_SERVER_KEY=\n" : "# Stack Auth keys\n" + "# Get these variables by creating a project on https://app.stack-auth.com.\n" + `NEXT_PUBLIC_STACK_PROJECT_ID="${projectIdFromArgs ?? ""}"\n` + `NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY="${publishableClientKeyFromArgs ?? ""}"\n` + "STACK_SECRET_SERVER_KEY=\n"; laterWriteFile(envLocalPath, envContent); logVerbose("Steps.writeEnvVars scheduled .env.local creation", { envLocalPath }); return true; } logVerbose("Steps.writeEnvVars found existing env files", { potentialEnvLocations }); return false; }, async maybeInstallConvexIntegration({ packageJson, type }: { packageJson: PackageJson, type: "js" | "next" | "react" }): Promise { const hasConvexDependency = Boolean(packageJson.dependencies?.["convex"] || packageJson.devDependencies?.["convex"]); if (!hasConvexDependency) { logVerbose("Steps.maybeInstallConvexIntegration skipped", { reason: "no-convex-dependency" }); return null; } const projectPath = await getProjectPath(); const convexDir = path.join(projectPath, "convex"); if (!fs.existsSync(convexDir)) { logVerbose("Steps.maybeInstallConvexIntegration skipped", { reason: "missing-convex-dir", convexDir }); return null; } const stackPackageName = await Steps.getStackPackageName(type); const instructions: string[] = []; logVerbose("Steps.maybeInstallConvexIntegration configuring", { convexDir, stackPackageName }); const authConfigPath = path.join(convexDir, "auth.config.ts"); const desiredAuthConfig = createConvexAuthConfigContent({ stackPackageName, type }); const existingAuthConfig = await readFile(authConfigPath); if (!existingAuthConfig || (!existingAuthConfig.includes("getConvexProvidersConfig") && !existingAuthConfig.includes("@stackframe/"))) { laterWriteFile(authConfigPath, desiredAuthConfig); logVerbose("Steps.maybeInstallConvexIntegration scheduled auth.config.ts update", { authConfigPath }); } const convexConfigPath = path.join(convexDir, "convex.config.ts"); const existingConvexConfig = await readFile(convexConfigPath); const desiredConvexConfig = createConvexIntegrationConvexConfigContent(stackPackageName); let needsManualConvexConfig = false; if (!existingConvexConfig) { laterWriteFile(convexConfigPath, desiredConvexConfig); logVerbose("Steps.maybeInstallConvexIntegration writing convex.config.ts from template", { convexConfigPath }); } else if (existingConvexConfig.includes("app.use(stackAuthComponent") && existingConvexConfig.includes("/convex.config") && existingConvexConfig.includes("stackframe")) { // already integrated logVerbose("Steps.maybeInstallConvexIntegration detected existing convex.config.ts integration", { convexConfigPath }); } else { const integratedContent = integrateConvexConfig(existingConvexConfig, stackPackageName); if (integratedContent) { laterWriteFile(convexConfigPath, integratedContent); logVerbose("Steps.maybeInstallConvexIntegration merged convex.config.ts content", { convexConfigPath }); } else if (isSimpleConvexConfig(existingConvexConfig)) { laterWriteFile(convexConfigPath, desiredConvexConfig); logVerbose("Steps.maybeInstallConvexIntegration replaced simple convex.config.ts", { convexConfigPath }); } else { needsManualConvexConfig = true; logVerbose("Steps.maybeInstallConvexIntegration requiring manual convex.config.ts review", { convexConfigPath }); } } if (needsManualConvexConfig) { instructions.push(`Update convex/convex.config.ts to import ${stackPackageName}/convex.config and call app.use(stackAuthComponent).`); } const convexClientUpdateResult = await updateConvexClients({ projectPath, type }); if (convexClientUpdateResult.skippedFiles.length > 0) { instructions.push("Review your Convex client setup and call stackClientApp.getConvexClientAuth({}) or stackServerApp.getConvexClientAuth({}) manually where needed."); } logVerbose("Steps.maybeInstallConvexIntegration client update summary", convexClientUpdateResult); instructions.push( "Set the Stack Auth environment variables in Convex (Deployment → Settings → Environment Variables).", "Verify your Convex clients call stackClientApp.getConvexClientAuth({}) or stackServerApp.getConvexClientAuth({}) so they share authentication with Stack Auth." ); logVerbose("Steps.maybeInstallConvexIntegration completed", { instructions }); return { instructions }; }, async dryUpdateNextLayoutFile({ appPath, defaultExtension }: { appPath: string, defaultExtension: string }): Promise<{ path: string, updatedContent: string, fileExtension: string, indentation: string, }> { const layoutPathWithoutExtension = path.join(appPath, "layout"); const layoutFileExtension = (await findJsExtension(layoutPathWithoutExtension)) ?? defaultExtension; const layoutPath = layoutPathWithoutExtension + "." + layoutFileExtension; const layoutContent = (await readFile(layoutPath)) ?? throwErr( `The layout file at ${layoutPath} does not exist. Stack requires a layout file to be present in the /app folder.` ); const updatedLayoutResult = (await getUpdatedLayout(layoutContent)) ?? throwErr( "Unable to parse root layout file. Make sure it contains a tag. If it still doesn't work, you may need to manually install Stack. See: https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts#root-layout-required" ); const updatedLayoutContent = updatedLayoutResult.content; return { path: layoutPath, updatedContent: updatedLayoutContent, fileExtension: layoutFileExtension, indentation: updatedLayoutResult.indentation }; }, async updateNextLayoutFile(projectInfo: ProjectInfo): Promise<{ path: string, updatedContent: string, fileExtension: string, indentation: string, }> { logVerbose("Steps.updateNextLayoutFile invoked", projectInfo); const res = await Steps.dryUpdateNextLayoutFile(projectInfo); laterWriteFile(res.path, res.updatedContent); logVerbose("Steps.updateNextLayoutFile scheduled write", { path: res.path }); return res; }, async writeStackAppFile({ type, srcPath, defaultExtension, indentation }: StackAppFileOptions, clientOrServer: "server" | "client", alsoHasClient: boolean): Promise { logVerbose("Steps.writeStackAppFile invoked", { type, srcPath, clientOrServer, alsoHasClient }); const packageName = await Steps.getStackPackageName(type); const clientOrServerCap = { client: "Client", server: "Server", }[clientOrServer as "client" | "server"]; const relativeStackAppPath = `stack/${clientOrServer}`; const stackAppPathWithoutExtension = path.join(srcPath, relativeStackAppPath); const stackAppFileExtension = (await findJsExtension(stackAppPathWithoutExtension)) ?? defaultExtension; const stackAppPath = stackAppPathWithoutExtension + "." + stackAppFileExtension; const stackAppContent = await readFile(stackAppPath); if (stackAppContent) { logVerbose("Steps.writeStackAppFile found existing file", { stackAppPath }); if (!stackAppContent.includes("@stackframe/")) { throw new UserError( `A file at the path ${stackAppPath} already exists. Stack uses the stack/${clientOrServer}.ts file to initialize the Stack SDK. Please remove the existing file and try again.` ); } throw new UserError( `It seems that you already installed Stack in this project.` ); } const tokenStore = type === "next" ? '"nextjs-cookie"' : (clientOrServer === "client" ? '"cookie"' : '"memory"'); const publishableClientKeyWrite = clientOrServer === "server" ? `process.env.STACK_PUBLISHABLE_CLIENT_KEY ${publishableClientKeyFromArgs ? `|| ${JSON.stringify(publishableClientKeyFromArgs)}` : ""}` : `'${publishableClientKeyFromArgs ?? 'INSERT_YOUR_PUBLISHABLE_CLIENT_KEY_HERE'}'`; const jsOptions = type === "js" ? [ `\n\n${indentation}// get your Stack Auth API keys from https://app.stack-auth.com${clientOrServer === "client" ? ` and store them in a safe place (e.g. environment variables)` : ""}`, `${projectIdFromArgs ? `${indentation}projectId: ${JSON.stringify(projectIdFromArgs)},` : ""}`, `${indentation}publishableClientKey: ${publishableClientKeyWrite},`, `${clientOrServer === "server" ? `${indentation}secretServerKey: process.env.STACK_SECRET_SERVER_KEY,` : ""}`, ].filter(Boolean).join("\n") : ""; const nextClientOptions = (type === "next" && clientOrServer === "client") ? (() => { const lines = [ projectIdFromArgs ? `${indentation}projectId: process.env.NEXT_PUBLIC_STACK_PROJECT_ID ?? ${JSON.stringify(projectIdFromArgs)},` : "", publishableClientKeyFromArgs ? `${indentation}publishableClientKey: process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY ?? ${JSON.stringify(publishableClientKeyFromArgs)},` : "", ].filter(Boolean).join("\n"); return lines ? `\n${lines}` : ""; })() : ""; const shouldInheritFromClient = clientOrServer === "server" && alsoHasClient; laterWriteFileIfNotExists( stackAppPath, ` ${type === "next" && clientOrServer === "server" ? `import "server-only";\n\n` : ""}import { Stack${clientOrServerCap}App } from ${JSON.stringify(packageName)}; ${shouldInheritFromClient ? `import { stackClientApp } from "./client";\n\n` : "\n"}export const stack${clientOrServerCap}App = new Stack${clientOrServerCap}App({ ${shouldInheritFromClient ? `${indentation}inheritsFrom: stackClientApp,` : `${indentation}tokenStore: ${tokenStore},${jsOptions}${nextClientOptions}`} }); `.trim() + "\n" ); logVerbose("Steps.writeStackAppFile scheduled creation", { stackAppPath, inheritsFromClient: shouldInheritFromClient }); return { fileName: stackAppPath }; }, async writeReactClientFile({ srcPath, defaultExtension, indentation, hasReactRouterDom }: { srcPath: string, defaultExtension: string, indentation: string, hasReactRouterDom: boolean }): Promise { logVerbose("Steps.writeReactClientFile invoked", { srcPath, hasReactRouterDom }); const packageName = await Steps.getStackPackageName("react"); const relativeStackAppPath = `stack/client`; const stackAppPathWithoutExtension = path.join(srcPath, relativeStackAppPath); const stackAppFileExtension = (await findJsExtension(stackAppPathWithoutExtension)) ?? defaultExtension; const stackAppPath = stackAppPathWithoutExtension + "." + stackAppFileExtension; const stackAppContent = await readFile(stackAppPath); if (stackAppContent) { logVerbose("Steps.writeReactClientFile found existing file", { stackAppPath }); if (!stackAppContent.includes("@stackframe/")) { throw new UserError(`A file at the path ${stackAppPath} already exists. Stack uses the stack/client.ts file to initialize the Stack SDK. Please remove the existing file and try again.`); } throw new UserError(`It seems that you already installed Stack in this project.`); } const publishableClientKeyWrite = `'${publishableClientKeyFromArgs ?? 'INSERT_YOUR_PUBLISHABLE_CLIENT_KEY_HERE'}'`; const projectIdWrite = `'${projectIdFromArgs ?? 'INSERT_PROJECT_ID'}'`; const imports = hasReactRouterDom ? `import { StackClientApp } from ${JSON.stringify(packageName)};\nimport { useNavigate } from "react-router-dom";\n\n` : `import { StackClientApp } from ${JSON.stringify(packageName)};\n\n`; const redirectMethod = hasReactRouterDom ? `,\n${indentation}redirectMethod: {\n${indentation}${indentation}useNavigate,\n${indentation}}` : ""; laterWriteFileIfNotExists( stackAppPath, `${imports}export const stackClientApp = new StackClientApp({ \n${indentation}tokenStore: "cookie", \n${indentation}projectId: ${projectIdWrite}, \n${indentation}publishableClientKey: ${publishableClientKeyWrite}${redirectMethod}, \n}); \n` ); logVerbose("Steps.writeReactClientFile scheduled creation", { stackAppPath }); return { fileName: stackAppPath }; }, async writeNextHandlerFile(projectInfo: ProjectInfo): Promise { logVerbose("Steps.writeNextHandlerFile invoked", projectInfo); const handlerPathWithoutExtension = path.join( projectInfo.appPath, "handler/[...stack]/page" ); const handlerFileExtension = (await findJsExtension(handlerPathWithoutExtension)) ?? projectInfo.defaultExtension; const handlerPath = handlerPathWithoutExtension + "." + handlerFileExtension; const handlerContent = await readFile(handlerPath); if (handlerContent && !handlerContent.includes("@stackframe/")) { logVerbose("Steps.writeNextHandlerFile found conflicting file", { handlerPath }); throw new UserError( `A file at the path ${handlerPath} already exists.Stack uses the / handler path to handle incoming requests.Please remove the existing file and try again.` ); } laterWriteFileIfNotExists( handlerPath, `import { StackHandler } from "@stackframe/stack";\n\nexport default function Handler() {\n${projectInfo.indentation}return ;\n}\n` ); }, async writeNextLoadingFile(projectInfo: ProjectInfo): Promise { logVerbose("Steps.writeNextLoadingFile invoked", projectInfo); let loadingPathWithoutExtension = path.join(projectInfo.appPath, "loading"); const loadingFileExtension = (await findJsExtension(loadingPathWithoutExtension)) ?? projectInfo.defaultExtension; const loadingPath = loadingPathWithoutExtension + "." + loadingFileExtension; laterWriteFileIfNotExists( loadingPath, `export default function Loading() { \n${projectInfo.indentation}// Stack uses React Suspense, which will render this page while user data is being fetched.\n${projectInfo.indentation}// See: https://nextjs.org/docs/app/api-reference/file-conventions/loading\n${projectInfo.indentation}return <>;\n}\n` ); }, async getPackageManager(): Promise<{ packageManager: string }> { if (packageManagerFromArgs) { logVerbose("Steps.getPackageManager using CLI override", { packageManager: packageManagerFromArgs }); return { packageManager: packageManagerFromArgs }; } const packageManager = await promptPackageManager(); const versionCommand = `${packageManager} --version`; logVerbose("Steps.getPackageManager checking binary availability", { packageManager }); try { await shellNicelyFormatted(versionCommand, { shell: true, quiet: true }); } catch (err) { console.error(err); throw new UserError( `Could not run the package manager command '${versionCommand}'. Please make sure ${packageManager} is installed on your system.` ); } logVerbose("Steps.getPackageManager resolved", { packageManager }); return { packageManager }; }, async ensureReady(type: "js" | "next" | "react"): Promise { const projectPath = await getProjectPath(); const typeStringMap = { js: "JavaScript", next: "Next.js", react: "React", } as const; const typeString = typeStringMap[type]; const isReady = (onQuestionMode !== "ask") || (await inquirer.prompt([ { type: "confirm", name: "ready", message: `Found a ${typeString} project at ${projectPath} — ready to install Stack Auth?`, default: true, }, ])).ready; if (!isReady) { throw new UserError("Installation aborted."); } logVerbose("Steps.ensureReady confirmed", { type, projectPath, isReady }); }, async getServerOrClientOrBoth(): Promise> { logVerbose("Steps.getServerOrClientOrBoth invoked", { isClientFlag: isClient, isServerFlag: isServer, onQuestionMode }); if (isClient && isServer) { logVerbose("Steps.getServerOrClientOrBoth using CLI flags", { selection: ["server", "client"] }); return ["server", "client"]; } if (isServer) { logVerbose("Steps.getServerOrClientOrBoth using server flag"); return ["server"]; } if (isClient) { logVerbose("Steps.getServerOrClientOrBoth using client flag"); return ["client"]; } if (onQuestionMode === "guess") { logVerbose("Steps.getServerOrClientOrBoth defaulting to both"); return ["server", "client"]; } if (onQuestionMode === "error") { throw new UnansweredQuestionError("Ambiguous installation type. Re-run with --server, --client, or both."); } const selection = (await inquirer.prompt([{ type: "list", name: "type", message: "Do you want to use Stack Auth on the server, or on the client?", choices: [ { name: "Client (e.g. Vite, HTML)", value: ["client"] }, { name: "Server (e.g. Node.js)", value: ["server"] }, { name: "Both (e.g. Next.js)", value: ["server", "client"] } ] }])).type; logVerbose("Steps.getServerOrClientOrBoth received user selection", { selection }); return selection; }, /** * note: this is a heuristic, specific frameworks may have better heuristics (e.g. the Next.js code uses the extension of the global layout file) */ async guessDefaultFileExtension(): Promise { const projectPath = await getProjectPath(); const hasTsConfig = fs.existsSync( path.join(projectPath, "tsconfig.json") ); const extension = hasTsConfig ? "ts" : "js"; logVerbose("Steps.guessDefaultFileExtension result", { projectPath, hasTsConfig, extension }); return extension; }, /** * note: this is a heuristic, specific frameworks may have better heuristics (e.g. the Next.js code uses the location of the app folder) */ async guessSrcPath(): Promise { const projectPath = await getProjectPath(); const potentialSrcPath = path.join(projectPath, "src"); const hasSrcFolder = fs.existsSync( path.join(projectPath, "src") ); const resolvedPath = hasSrcFolder ? potentialSrcPath : projectPath; logVerbose("Steps.guessSrcPath result", { hasSrcFolder, resolvedPath }); return resolvedPath; }, }; type LayoutResult = { content: string, indentation: string, } async function getUpdatedLayout(originalLayout: string): Promise { logVerbose("getUpdatedLayout invoked", { length: originalLayout.length }); let layout = originalLayout; const indentation = guessIndentation(originalLayout); const firstImportLocationM1 = /\simport\s/.exec(layout)?.index; const hasStringAsFirstLine = layout.startsWith('"') || layout.startsWith("'"); const importInsertLocationM1 = firstImportLocationM1 ?? (hasStringAsFirstLine ? layout.indexOf("\n") : -1); const importInsertLocation = importInsertLocationM1 + 1; const importStatement = `import { StackProvider, StackTheme } from "@stackframe/stack";\nimport { stackClientApp } from "../stack/client";\n`; layout = layout.slice(0, importInsertLocation) + importStatement + layout.slice(importInsertLocation); const bodyOpenTag = /<\s*body[^>]*>/.exec(layout); const bodyCloseTag = /<\s*\/\s*body[^>]*>/.exec(layout); if (!bodyOpenTag || !bodyCloseTag) { logVerbose("getUpdatedLayout missing body tag"); return undefined; } const bodyOpenEndIndex = bodyOpenTag.index + bodyOpenTag[0].length; const bodyCloseStartIndex = bodyCloseTag.index; if (bodyCloseStartIndex <= bodyOpenEndIndex) { logVerbose("getUpdatedLayout invalid body indices", { bodyOpenEndIndex, bodyCloseStartIndex }); return undefined; } const lines = layout.split("\n"); const [bodyOpenEndLine, bodyOpenEndIndexInLine] = getLineIndex( lines, bodyOpenEndIndex ); const [bodyCloseStartLine, bodyCloseStartIndexInLine] = getLineIndex( lines, bodyCloseStartIndex ); const insertOpen = ""; const insertClose = ""; layout = layout.slice(0, bodyCloseStartIndex) + insertClose + layout.slice(bodyCloseStartIndex); layout = layout.slice(0, bodyOpenEndIndex) + insertOpen + layout.slice(bodyOpenEndIndex); logVerbose("getUpdatedLayout success", { updatedLength: layout.length }); return { content: `${layout}`, indentation, }; } function guessIndentation(str: string): string { const lines = str.split("\n"); const linesLeadingWhitespaces = lines .map((line) => line.match(/^\s*/)![0]) .filter((ws) => ws.length > 0); const isMostlyTabs = linesLeadingWhitespaces.filter((ws) => ws.includes("\t")).length >= (linesLeadingWhitespaces.length * 2) / 3; if (isMostlyTabs) return "\t"; const linesLeadingWhitespacesCount = linesLeadingWhitespaces.map( (ws) => ws.length ); const min = Math.min(Infinity, ...linesLeadingWhitespacesCount); return Number.isFinite(min) ? " ".repeat(Math.max(2, min)) : " "; } function getLineIndex(lines: string[], stringIndex: number): [number, number] { let lineIndex = 0; for (let l = 0; l < lines.length; l++) { const line = lines[l]; if (stringIndex < lineIndex + line.length) { return [l, stringIndex - lineIndex]; } lineIndex += line.length + 1; } throw new Error( `Index ${stringIndex} is out of bounds for lines ${JSON.stringify(lines)}` ); } async function getProjectPath(): Promise { logVerbose("getProjectPath invoked", { savedProjectPath }); if (savedProjectPath === undefined) { savedProjectPath = process.cwd(); const askForPathModification = !fs.existsSync( path.join(savedProjectPath, "package.json") ); if (askForPathModification) { logVerbose("getProjectPath did not find package.json in cwd", { cwd: savedProjectPath }); if (onQuestionMode === "guess" || onQuestionMode === "error") { throw new UserError(`No package.json file found in ${savedProjectPath}. Re-run providing the project path argument (e.g. 'init-stack ').`); } savedProjectPath = ( await inquirer.prompt([ { type: "input", name: "newPath", message: "Please enter the path to your project:", default: ".", }, ]) ).newPath; logVerbose("getProjectPath received manual input", { savedProjectPath }); } } logVerbose("getProjectPath resolved", { savedProjectPath }); return savedProjectPath as string; } async function ensureGitWorkspaceIsReady(projectPath: string): Promise { if (!warnUncommittedChanges) { logVerbose("ensureGitWorkspaceIsReady skipped as requested by user"); return; } logVerbose("ensureGitWorkspaceIsReady invoked", { projectPath }); let isGitRepo = false; try { const gitRepoResult = child_process.spawnSync( "git", ["rev-parse", "--is-inside-work-tree"], { shell: true, cwd: projectPath, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], } ); isGitRepo = gitRepoResult.status === 0 && gitRepoResult.stdout.trim() === "true"; } catch (e) { logVerbose("ensureGitWorkspaceIsReady failed to detect git repository", { error: e }); return; } if (!isGitRepo) { logVerbose("ensureGitWorkspaceIsReady skipping", { reason: "not-a-git-repo" }); return; } const statusResult = child_process.spawnSync( "git", ["status", "--porcelain"], { shell: true, cwd: projectPath, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], } ); if (statusResult.error || statusResult.status !== 0) { logVerbose("ensureGitWorkspaceIsReady git status failed", { status: statusResult.status, error: statusResult.error }); return; } const lines = statusResult.stdout .split("\n") .map((line) => line.replace(/\r$/, "")) .filter((line) => line.length > 0); const unstagedLines = lines.filter((line) => { if (line.startsWith("!!")) return false; if (line.startsWith("??")) return true; if (line.length < 2) return false; const workingTreeStatus = line[1]; return Boolean(workingTreeStatus && workingTreeStatus !== " "); }); if (unstagedLines.length === 0) { logVerbose("ensureGitWorkspaceIsReady clean working tree"); return; } const changedFiles = unstagedLines.map((line) => { const filePath = line.slice(3).trim(); return filePath.length > 0 ? filePath : line; }); console.log(); console.log(colorize.yellow`Detected unstaged/uncommitted changes in your Git repository:`); const filesToShow = changedFiles.slice(0, 10); for (const file of filesToShow) { console.log(` - ${file}`); } if (changedFiles.length > filesToShow.length) { console.log(` - ...and ${changedFiles.length - filesToShow.length} more`); } console.log(colorize.yellow`You may want to stage and commit these changes before installing Stack Auth, so you can review the changes afterwards.`); console.log(); if (onQuestionMode === "guess") { console.log(colorize.yellow`Continuing because --on-question=guess.`); return; } if (onQuestionMode === "error") { throw new UnansweredQuestionError("Unstaged changes detected in the project directory"); } const { proceed } = await inquirer.prompt([ { type: "confirm", name: "proceed", message: "Continue with Stack initialization anyway?", default: false, }, ]); if (!proceed) { throw new UserError("Aborting Stack initialization to avoid overwriting unstaged changes."); } logVerbose("ensureGitWorkspaceIsReady user confirmed proceed despite unstaged changes"); } async function findJsExtension(fullPathWithoutExtension: string): Promise { logVerbose("findJsExtension invoked", { fullPathWithoutExtension }); for (const ext of jsLikeFileExtensions) { const fullPath = fullPathWithoutExtension + "." + ext; if (fs.existsSync(fullPath)) { logVerbose("findJsExtension found file", { fullPath, ext }); return ext; } } logVerbose("findJsExtension no matching file", { fullPathWithoutExtension }); return null; } async function promptPackageManager(): Promise { const projectPath = await getProjectPath(); const yarnLock = fs.existsSync(path.join(projectPath, "yarn.lock")); const pnpmLock = fs.existsSync(path.join(projectPath, "pnpm-lock.yaml")); const npmLock = fs.existsSync(path.join(projectPath, "package-lock.json")); const bunLock = fs.existsSync(path.join(projectPath, "bun.lockb")) || fs.existsSync(path.join(projectPath, "bun.lock")); logVerbose("promptPackageManager inspecting lockfiles", { yarnLock, pnpmLock, npmLock, bunLock }); if (yarnLock && !pnpmLock && !npmLock && !bunLock) { logVerbose("promptPackageManager auto-selected yarn"); return "yarn"; } else if (!yarnLock && pnpmLock && !npmLock && !bunLock) { logVerbose("promptPackageManager auto-selected pnpm"); return "pnpm"; } else if (!yarnLock && !pnpmLock && npmLock && !bunLock) { logVerbose("promptPackageManager auto-selected npm"); return "npm"; } else if (!yarnLock && !pnpmLock && !npmLock && bunLock) { logVerbose("promptPackageManager auto-selected bun"); return "bun"; } if (onQuestionMode === "guess") { logVerbose("promptPackageManager defaulting to npm due to guess mode"); return "npm"; } if (onQuestionMode === "error") { throw new UnansweredQuestionError("Unable to determine the package manager. Re-run with one of: --npm, --yarn, --pnpm, or --bun."); } const answers = await inquirer.prompt([ { type: "list", name: "packageManager", message: "Which package manager are you using for this project?", choices: ["npm", "yarn", "pnpm", "bun"], }, ]); logVerbose("promptPackageManager user selected", { packageManager: answers.packageManager }); return answers.packageManager; } type ShellOptions = { quiet?: boolean, shell?: boolean, cwd?: string, [key: string]: any, } async function shellNicelyFormatted(command: string, { quiet, ...options }: ShellOptions): Promise { logVerbose("shellNicelyFormatted invoked", { command, options: { ...options, quiet } }); let ui: any; let interval: NodeJS.Timeout | undefined; if (!quiet) { console.log(); ui = new inquirer.ui.BottomBar(); let dots = 4; ui.updateBottomBar( colorize.blue`Running command: ${command}...` ); interval = setInterval(() => { if (!isDryRun) { ui.updateBottomBar( colorize.blue`Running command: ${command}${".".repeat(dots++ % 5)}` ); } }, 700); } try { if (!isDryRun) { const child = child_process.spawn(command, options); logVerbose("shellNicelyFormatted spawned process", { pid: child.pid, command }); if (!quiet) { child.stdout.pipe(ui.log); child.stderr.pipe(ui.log); } await new Promise((resolve, reject) => { child.on("exit", (code) => { if (code === 0) { resolve(); } else { logVerbose("shellNicelyFormatted command failed", { code }); reject(new Error(`Command ${command} failed with code ${code}`)); } }); }); } else { console.log(`[DRY-RUN] Would have run: ${command}`); logVerbose("shellNicelyFormatted skipped due to dry run", { command }); } if (!quiet) { commandsExecuted.push(command); ui.updateBottomBar( `${colorize.green`√`} Command ${command} succeeded\n` ); } logVerbose("shellNicelyFormatted completed", { command }); } catch (e) { logVerbose("shellNicelyFormatted encountered error", { command, error: e instanceof Error ? { message: e.message, stack: e.stack } : e }); if (!quiet) { ui.updateBottomBar( `${colorize.red`X`} Command ${command} failed\n` ); } throw e; } finally { if (interval) { clearTimeout(interval); } if (!quiet) { ui.close(); } } } async function readFile(fullPath: string): Promise { logVerbose("readFile invoked", { fullPath, isDryRun }); try { if (!isDryRun) { const content = fs.readFileSync(fullPath, "utf-8"); logVerbose("readFile succeeded", { fullPath, length: content.length }); return content; } logVerbose("readFile skipped due to dry run", { fullPath }); return null; } catch (err: any) { if (err.code === "ENOENT") { logVerbose("readFile file missing", { fullPath }); return null; } logVerbose("readFile errored", { fullPath, error: err instanceof Error ? { message: err.message, stack: err.stack } : err }); throw err; } } async function writeFile(fullPath: string, content: string): Promise { logVerbose("writeFile invoked", { fullPath, length: content.length, isDryRun }); let create = !fs.existsSync(fullPath); if (!isDryRun) { fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.writeFileSync(fullPath, content); logVerbose("writeFile wrote to disk", { fullPath, created: create }); } else { console.log(`[DRY-RUN] Would have written to ${fullPath}`); logVerbose("writeFile skipped due to dry run", { fullPath }); } const relativeToProjectPath = path.relative(await getProjectPath(), fullPath); if (!create) { filesModified.push(relativeToProjectPath); } else { filesCreated.push(relativeToProjectPath); } logVerbose("writeFile recorded change", { relativeToProjectPath, created: create }); } function laterWriteFile(fullPath: string, content: string): void { logVerbose("laterWriteFile scheduled", { fullPath, length: content.length }); writeFileHandlers.push(async () => { await writeFile(fullPath, content); }); } async function writeFileIfNotExists(fullPath: string, content: string): Promise { if (!fs.existsSync(fullPath)) { logVerbose("writeFileIfNotExists writing new file", { fullPath }); await writeFile(fullPath, content); } else { logVerbose("writeFileIfNotExists skipped", { fullPath }); } } function laterWriteFileIfNotExists(fullPath: string, content: string): void { logVerbose("laterWriteFileIfNotExists scheduled", { fullPath }); writeFileHandlers.push(async () => { await writeFileIfNotExists(fullPath, content); }); } async function runDeferredCommands(): Promise { if (!deferredCommandHandlers.length) { logVerbose("runDeferredCommands skipped", { reason: "no-handlers" }); return; } logVerbose("runDeferredCommands executing handlers", { count: deferredCommandHandlers.length }); for (let index = 0; index < deferredCommandHandlers.length; index++) { logVerbose("runDeferredCommands executing handler", { index }); const handler = deferredCommandHandlers[index]; await handler(); } logVerbose("runDeferredCommands completed"); } type RunScheduledCommandMetadata = { recordInCommandsExecuted?: boolean, }; async function runScheduledCommand( command: string, args: string[], options: child_process.SpawnSyncOptions = {}, metadata: RunScheduledCommandMetadata = {}, ): Promise { logVerbose("runScheduledCommand invoked", { command, args, options, isDryRun }); const display = [command, ...args].join(" "); if (isDryRun) { console.log(`[DRY-RUN] Would run: ${display}`); logVerbose("runScheduledCommand skipped due to dry run", { display }); return; } const result = child_process.spawnSync(command, args, { stdio: "pipe", ...options, }); const recordInCommandsExecuted = metadata.recordInCommandsExecuted; if (recordInCommandsExecuted && !commandsExecuted.includes(display)) { commandsExecuted.push(display); } if (result.status === 0) { console.log(`${colorize.green`√`} ${display}`); logVerbose("runScheduledCommand succeeded", { display }); } else { logVerbose("runScheduledCommand failed", { display, result, stderr: result.stderr.toString(), stdout: result.stdout.toString() }); throw new Error(`Command ${display} failed with status ${result.status}: ${result.stderr.toString()}`); } } async function recordFileChange(fullPath: string, existed: boolean): Promise { logVerbose("recordFileChange invoked", { fullPath, existed }); const projectRoot = path.resolve(await getProjectPath()); const relative = path.relative(projectRoot, fullPath); const insideProject = relative && !relative.startsWith("..") && !path.isAbsolute(relative); const entry = insideProject ? relative : fullPath; if (existed) { if (!filesModified.includes(entry)) { filesModified.push(entry); } logVerbose("recordFileChange marked modified", { entry }); } else if (!filesCreated.includes(entry)) { filesCreated.push(entry); logVerbose("recordFileChange marked created", { entry }); } } function createConvexAuthConfigContent(options: { stackPackageName: string, type: "js" | "next" | "react" }): string { const envVarName = getPublicProjectEnvVarName(options.type); return `import { getConvexProvidersConfig } from ${JSON.stringify(options.stackPackageName)}; export default { providers: getConvexProvidersConfig({ projectId: process.env.${envVarName}, }), }; `; } function createConvexIntegrationConvexConfigContent(stackPackageName: string): string { const importPath = `${stackPackageName}/convex.config`; return `import stackAuthComponent from ${JSON.stringify(importPath)}; import { defineApp } from "convex/server"; const app = defineApp(); app.use(stackAuthComponent); export default app; `; } function integrateConvexConfig(existingContent: string, stackPackageName: string): string | null { if (!existingContent.includes("defineApp")) { return null; } const newline = existingContent.includes("\r\n") ? "\r\n" : "\n"; const normalizedLines = existingContent.replace(/\r\n/g, "\n").split("\n"); const importPath = `${stackPackageName}/convex.config`; const hasImport = normalizedLines.some((line) => line.includes(importPath)); if (!hasImport) { let insertIndex = 0; while (insertIndex < normalizedLines.length && normalizedLines[insertIndex].trim() === "") { insertIndex++; } while (insertIndex < normalizedLines.length && normalizedLines[insertIndex].trim().startsWith("import")) { insertIndex++; } normalizedLines.splice(insertIndex, 0, `import stackAuthComponent from "${importPath}";`); } let lastImportIndex = -1; for (let i = 0; i < normalizedLines.length; i++) { if (normalizedLines[i].trim().startsWith("import")) { lastImportIndex = i; continue; } if (normalizedLines[i].trim() === "") { continue; } break; } if (lastImportIndex >= 0) { const nextIndex = lastImportIndex + 1; if (!normalizedLines[nextIndex] || normalizedLines[nextIndex].trim() !== "") { normalizedLines.splice(nextIndex, 0, ""); } } const hasStackUse = normalizedLines.some((line) => line.includes("app.use(stackAuthComponent")); if (!hasStackUse) { const appLineIndex = normalizedLines.findIndex((line) => /const\s+app\s*=\s*defineApp/.test(line)); if (appLineIndex === -1) { return null; } const indent = normalizedLines[appLineIndex].match(/^\s*/)?.[0] ?? ""; const insertIndexForUse = appLineIndex + 1; normalizedLines.splice(insertIndexForUse, 0, `${indent}app.use(stackAuthComponent);`); const nextLineIndex = insertIndexForUse + 1; if (!normalizedLines[nextLineIndex] || normalizedLines[nextLineIndex].trim() !== "") { normalizedLines.splice(nextLineIndex, 0, ""); } } let updated = normalizedLines.join(newline); if (!updated.endsWith(newline)) { updated += newline; } return updated; } function isSimpleConvexConfig(content: string): boolean { const normalized = content .replace(/\r\n/g, "\n") .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0); if (normalized.length !== 3) { return false; } const [line1, line2, line3] = normalized; const importRegex = /^import\s+\{\s*defineApp\s*\}\s+from\s+['"]convex\/server['"];?$/; const appRegex = /^const\s+app\s*=\s*defineApp\(\s*\);?$/; const exportRegex = /^export\s+default\s+app;?$/; return importRegex.test(line1) && appRegex.test(line2) && exportRegex.test(line3); } function getPublicProjectEnvVarName(type: "js" | "next" | "react"): string { if (type === "react") { return "VITE_STACK_PROJECT_ID"; } if (type === "next") { return "NEXT_PUBLIC_STACK_PROJECT_ID"; } return "STACK_PROJECT_ID"; } type ConvexClientUpdateResult = { updatedFiles: string[], skippedFiles: string[], }; type AddSetAuthResult = { updatedContent: string, changed: boolean, usedClientApp: boolean, usedServerApp: boolean, instantiationCount: number, skippedHttpCount: number, }; async function updateConvexClients({ projectPath, type }: { projectPath: string, type: "js" | "next" | "react" }): Promise { const files = collectConvexClientCandidateFiles(projectPath); logVerbose("updateConvexClients collected files", { projectPath, count: files.length }); const updatedFiles: string[] = []; const skippedFiles: string[] = []; for (const filePath of files) { logVerbose("updateConvexClients inspecting file", { filePath }); const fileContent = await readFile(filePath); if (!fileContent) { logVerbose("updateConvexClients skipped file (no content)", { filePath }); continue; } if (!/new\s+Convex(?:React|Http)?Client\b/.test(fileContent)) { logVerbose("updateConvexClients skipped file (no Convex client)", { filePath }); continue; } const addResult = addSetAuthToConvexClients(fileContent, type); logVerbose("updateConvexClients processed file", { filePath, addResult }); if (!addResult.changed) { if (addResult.instantiationCount > 0 && addResult.skippedHttpCount > 0) { skippedFiles.push(filePath); } continue; } let finalContent = addResult.updatedContent; if (addResult.usedClientApp) { logVerbose("updateConvexClients ensuring client import", { filePath }); finalContent = await ensureStackAppImport(finalContent, filePath, "client"); } if (addResult.usedServerApp) { logVerbose("updateConvexClients ensuring server import", { filePath }); finalContent = await ensureStackAppImport(finalContent, filePath, "server"); } if (finalContent !== fileContent) { laterWriteFile(filePath, finalContent); updatedFiles.push(filePath); logVerbose("updateConvexClients scheduled update", { filePath }); } } logVerbose("updateConvexClients finished", { updatedFiles, skippedFiles }); return { updatedFiles, skippedFiles, }; } type StackAppKind = "client" | "server"; async function ensureStackAppImport(content: string, filePath: string, kind: StackAppKind): Promise { logVerbose("ensureStackAppImport invoked", { filePath, kind }); const identifier = kind === "client" ? "stackClientApp" : "stackServerApp"; if (new RegExp(`import\\s+[^;]*\\b${identifier}\\b`).test(content)) { logVerbose("ensureStackAppImport found existing import", { filePath, identifier }); return content; } const stackBasePath = await getStackAppBasePath(kind); const relativeImportPath = convertToModuleSpecifier(path.relative(path.dirname(filePath), stackBasePath)); const newline = content.includes("\r\n") ? "\r\n" : "\n"; const lines = content.split(/\r?\n/); const importLine = `import { ${identifier} } from "${relativeImportPath}";`; let insertIndex = 0; while (insertIndex < lines.length) { const line = lines[insertIndex]; if (/^\s*['"]use (client|server)['"];?\s*$/.test(line)) { insertIndex += 1; continue; } if (/^\s*import\b/.test(line)) { insertIndex += 1; continue; } if (line.trim() === "") { insertIndex += 1; continue; } break; } lines.splice(insertIndex, 0, importLine); const nextLine = lines[insertIndex + 1]; if (nextLine && nextLine.trim() !== "" && !/^\s*import\b/.test(nextLine)) { lines.splice(insertIndex + 1, 0, ""); } logVerbose("ensureStackAppImport added import", { filePath, importLine }); return lines.join(newline); } function convertToModuleSpecifier(relativePath: string): string { let specifier = relativePath.replace(/\\/g, "/"); if (!specifier.startsWith(".")) { specifier = "./" + specifier; } return specifier; } async function getStackAppBasePath(kind: StackAppKind): Promise { const srcPath = await Steps.guessSrcPath(); const basePath = path.join(srcPath, "stack", kind); logVerbose("getStackAppBasePath resolved", { kind, basePath }); return basePath; } function addSetAuthToConvexClients(content: string, type: "js" | "next" | "react"): AddSetAuthResult { logVerbose("addSetAuthToConvexClients invoked", { type, length: content.length }); const newline = content.includes("\r\n") ? "\r\n" : "\n"; const instantiationRegex = /^[ \t]*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*new\s+(Convex(?:React|Http)?Client)\b([\s\S]*?);/gm; const replacements: Array<{ start: number, end: number, text: string }> = []; let instantiationCount = 0; let skippedHttpCount = 0; let usedClientApp = false; let usedServerApp = false; let match: RegExpExecArray | null; while ((match = instantiationRegex.exec(content)) !== null) { instantiationCount += 1; const fullMatch = match[0]; const variableName = match[1]; const className = match[2]; if (className === "ConvexHttpClient") { skippedHttpCount += 1; logVerbose("addSetAuthToConvexClients skipping ConvexHttpClient", { variableName, fileLength: content.length }); continue; } const remainder = content.slice(match.index + fullMatch.length); const setAuthRegex = new RegExp(`\\b${escapeRegExp(variableName)}\\s*\\.setAuth\\s*\\(`); if (setAuthRegex.test(remainder)) { logVerbose("addSetAuthToConvexClients found existing setAuth", { variableName }); continue; } const indentation = fullMatch.match(/^[\t ]*/)?.[0] ?? ""; const authCall = determineAuthCallExpression({ type, className, content }); if (authCall.identifier === "stackClientApp") { usedClientApp = true; } else { usedServerApp = true; } const replacementText = `${fullMatch}${newline}${indentation}${variableName}.setAuth(${authCall.expression});`; replacements.push({ start: match.index, end: match.index + fullMatch.length, text: replacementText, }); logVerbose("addSetAuthToConvexClients queued replacement", { variableName, authCall }); } if (replacements.length === 0) { logVerbose("addSetAuthToConvexClients no replacements", { instantiationCount, skippedHttpCount }); return { updatedContent: content, changed: false, usedClientApp, usedServerApp, instantiationCount, skippedHttpCount, }; } let updatedContent = content; for (let i = replacements.length - 1; i >= 0; i--) { const replacement = replacements[i]; updatedContent = `${updatedContent.slice(0, replacement.start)}${replacement.text}${updatedContent.slice(replacement.end)}`; } logVerbose("addSetAuthToConvexClients completed replacements", { replacements: replacements.length }); logVerbose("addSetAuthToConvexClients result", { changed: true, instantiationCount, skippedHttpCount, usedClientApp, usedServerApp }); return { updatedContent, changed: true, usedClientApp, usedServerApp, instantiationCount, skippedHttpCount, }; } function determineAuthCallExpression({ type, className, content }: { type: "js" | "next" | "react", className: string, content: string }): { expression: string, identifier: "stackClientApp" | "stackServerApp" } { const hasClientAppReference = /\bstackClientApp\b/.test(content); const hasServerAppReference = /\bstackServerApp\b/.test(content); logVerbose("determineAuthCallExpression context", { type, className, hasClientAppReference, hasServerAppReference }); if (type === "js") { const result = { expression: "stackServerApp.getConvexClientAuth({})", identifier: "stackServerApp" as const }; logVerbose("determineAuthCallExpression returning for JS", result); return result; } if (hasClientAppReference) { const result = { expression: getClientAuthCall(type), identifier: "stackClientApp" as const }; logVerbose("determineAuthCallExpression using client reference", result); return result; } if (hasServerAppReference && className !== "ConvexReactClient") { const result = { expression: "stackServerApp.getConvexClientAuth({})", identifier: "stackServerApp" as const }; logVerbose("determineAuthCallExpression using server reference", result); return result; } const fallback = { expression: getClientAuthCall(type), identifier: "stackClientApp" as const }; logVerbose("determineAuthCallExpression fallback", fallback); return fallback; } function getClientAuthCall(type: "js" | "next" | "react"): string { logVerbose("getClientAuthCall invoked", { type }); return "stackClientApp.getConvexClientAuth({})"; } function collectConvexClientCandidateFiles(projectPath: string): string[] { logVerbose("collectConvexClientCandidateFiles invoked", { projectPath }); const roots = getConvexSearchRoots(projectPath); logVerbose("collectConvexClientCandidateFiles roots", { roots }); const files = new Set(); const visited = new Set(); for (const root of roots) { walkDirectory(root, files, visited); } const result = Array.from(files); logVerbose("collectConvexClientCandidateFiles result", { count: result.length }); return result; } function getConvexSearchRoots(projectPath: string): string[] { const candidateDirs = ["convex", "src", "app", "components"]; const existing = candidateDirs .map((dir) => path.join(projectPath, dir)) .filter((dirPath) => { try { return fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory(); } catch { return false; } }); if (existing.length > 0) { logVerbose("getConvexSearchRoots using existing directories", { existing }); return existing; } logVerbose("getConvexSearchRoots defaulting to project root", { projectPath }); return [projectPath]; } const directorySkipList = new Set([ "node_modules", ".git", ".next", ".turbo", ".output", ".vercel", "dist", "build", "coverage", ".cache", ".storybook", "storybook-static", ]); function walkDirectory(currentDir: string, files: Set, visited: Set): void { const realPath = (() => { try { return fs.realpathSync(currentDir); } catch { return currentDir; } })(); if (visited.has(realPath)) return; visited.add(realPath); logVerbose("walkDirectory scanning", { currentDir: realPath }); let dirEntries: fs.Dirent[]; try { dirEntries = fs.readdirSync(realPath, { withFileTypes: true }); } catch { return; } for (const entry of dirEntries) { const entryName = entry.name; if (entry.isDirectory()) { if (directorySkipList.has(entryName)) { logVerbose("walkDirectory skipping directory in skip list", { directory: entryName, parent: realPath }); continue; } if (entryName.startsWith(".") || entryName.startsWith("_")) { logVerbose("walkDirectory skipping hidden directory", { directory: entryName, parent: realPath }); continue; } walkDirectory(path.join(realPath, entryName), files, visited); continue; } if (!entry.isFile()) continue; if (entryName.endsWith(".d.ts")) continue; if (!hasJsLikeExtension(entryName)) continue; const filePath = path.join(realPath, entryName); files.add(filePath); logVerbose("walkDirectory added file", { filePath }); } } function hasJsLikeExtension(fileName: string): boolean { return jsLikeFileExtensions.some((ext) => fileName.endsWith(`.${ext}`)); } function escapeRegExp(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function throwErr(message: string): never { throw new Error(message); } async function clearStdin(): Promise { logVerbose("clearStdin invoked"); await new Promise((resolve) => { if (process.stdin.isTTY) { process.stdin.setRawMode(true); } process.stdin.resume(); process.stdin.removeAllListeners('data'); const flush = () => { while (process.stdin.read() !== null) { } if (process.stdin.isTTY) { process.stdin.setRawMode(false); } logVerbose("clearStdin flushed"); resolve(); }; // Add a small delay to allow any buffered input to clear setTimeout(flush, 10); }); logVerbose("clearStdin completed"); }