#!/usr/bin/env node import inquirer from "inquirer"; import * as fs from "fs"; import * as child_process from "child_process"; import * as path from "path"; import open from "open"; const jsLikeFileExtensions = [ "mtsx", "ctsx", "tsx", "mts", "cts", "ts", "mjsx", "cjsx", "jsx", "mjs", "cjs", "js", ]; class UserError extends Error { constructor(message) { super(message); this.name = "UserError"; } } let savedProjectPath = process.argv[2] || undefined; async function main() { console.log("Welcome to the Stack installation wizard! 🧙‍♂️"); let projectPath = await getProjectPath(); 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 nextConfigPathWithoutExtension = path.join(projectPath, "next.config"); const nextConfigFileExtension = await findJsExtension( nextConfigPathWithoutExtension ); const nextConfigPath = nextConfigPathWithoutExtension + "." + (nextConfigFileExtension ?? "js"); if (!fs.existsSync(nextConfigPath)) { throw new UserError( `Expected file at ${nextConfigPath}. Only Next.js projects are currently supported.` ); } const envPath = path.join(projectPath, ".env"); const envDevelopmentPath = path.join(projectPath, ".env.development"); const envDefaultPath = path.join(projectPath, ".env.default"); const envDefaultsPath = path.join(projectPath, ".env.defaults"); const envExamplePath = path.join(projectPath, ".env.example"); const envLocalPath = path.join(projectPath, ".env.local"); const potentialEnvLocations = [ envPath, envDevelopmentPath, envDefaultPath, envDefaultsPath, envExamplePath, envLocalPath, ]; 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)) { throw new UserError( `The app path ${appPath} does not exist. Only the Next.js app router is supported.` ); } const layoutPathWithoutExtension = path.join(appPath, "layout"); const layoutFileExtension = (await findJsExtension(layoutPathWithoutExtension)) ?? "jsx"; 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; const defaultExtension = layoutFileExtension; const ind = updatedLayoutResult.indentation; const stackAppPathWithoutExtension = path.join(srcPath, "stack"); const stackAppFileExtension = (await findJsExtension(stackAppPathWithoutExtension)) ?? defaultExtension; const stackAppPath = stackAppPathWithoutExtension + "." + stackAppFileExtension; const stackAppContent = await readFile(stackAppPath); if (stackAppContent) { if (!stackAppContent.includes("@stackframe/stack")) { throw new UserError( `A file at the path ${stackAppPath} already exists. Stack uses the /src/stack-app file to initialize the Stack SDK. Please remove the existing file and try again.` ); } throw new UserError( `It seems that you've already installed Stack in this project.` ); } const handlerPathWithoutExtension = path.join( appPath, "handler/[...stack]/page" ); const handlerFileExtension = (await findJsExtension(handlerPathWithoutExtension)) ?? defaultExtension; const handlerPath = handlerPathWithoutExtension + "." + handlerFileExtension; const handlerContent = await readFile(handlerPath); if (handlerContent && !handlerContent.includes("@stackframe/stack")) { 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.` ); } let loadingPathWithoutExtension = path.join(appPath, "loading"); const loadingFileExtension = (await findJsExtension(loadingPathWithoutExtension)) ?? defaultExtension; const loadingPath = loadingPathWithoutExtension + "." + loadingFileExtension; console.log(); console.log("Found supported project at:", projectPath); console.log("Installing now! 💃"); const packageManager = await getPackageManager(); const versionCommand = `${packageManager} --version`; const installCommand = packageManager === "yarn" ? "yarn add" : `${packageManager} install`; process.stdout.write("\nChecking package manager version... "); try { await shellNicelyFormatted(versionCommand, { shell: true }); } catch (err) { throw new UserError( `Could not run the package manager command ${versionCommand}. Please make sure ${packageManager} is installed on your system.` ); } console.log(); console.log("Installing dependencies..."); await shellNicelyFormatted(`${installCommand} @stackframe/stack`, { shell: true, cwd: projectPath, }); console.log(); console.log("Writing files..."); if (potentialEnvLocations.every((p) => !fs.existsSync(p))) { await writeFile( envLocalPath, "# Stack Auth keys\n# Get these variables by creating a project on https://app.stack-auth.com.\nNEXT_PUBLIC_STACK_PROJECT_ID=\nNEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=\nSTACK_SECRET_SERVER_KEY=\n" ); } await writeFileIfNotExists( loadingPath, `export default function Loading() {\n${ind}// Stack uses React Suspense, which will render this page while user data is being fetched.\n${ind}// See: https://nextjs.org/docs/app/api-reference/file-conventions/loading\n${ind}return <>;\n}\n` ); await writeFileIfNotExists( handlerPath, `import { StackHandler } from "@stackframe/stack";\nimport { stackServerApp } from "../../../stack";\n\nexport default function Handler(props${handlerFileExtension.includes("ts") ? ": any" : ""}) {\n${ind}return ;\n}\n` ); await writeFileIfNotExists( stackAppPath, `import "server-only";\n\nimport { StackServerApp } from "@stackframe/stack";\n\nexport const stackServerApp = new StackServerApp({\n${ind}tokenStore: "nextjs-cookie",\n});\n` ); await writeFile(layoutPath, updatedLayoutContent); console.log("Files written successfully!"); } main() .then(async () => { console.log(); console.log(); console.log(); console.log(); console.log("==============================================="); console.log(); console.log("Successfully installed Stack! 🚀🚀🚀"); console.log(); console.log( "Next up, please create an account on https://app.stack-auth.com to create a new project, and copy the Next.js environment variables from a new API key into your .env.local file." ); console.log(); console.log( "Then, you will be able to access your sign-in page on http://your-website.example.com/handler/sign-in. Congratulations!" ); console.log(); console.log( "For more information, please visit https://docs.stack-auth.com/docs/getting-started/setup" ); console.log(); console.log("==============================================="); console.log(); await open("https://app.stack-auth.com/wizard-congrats"); }) .catch((err) => { console.error(err); console.error(); console.error(); console.error(); console.error(); console.error("==============================================="); console.error(); if (err instanceof UserError) { console.error("[ERR] Error: " + err.message); } else { console.error( "[ERR] An error occurred during the initialization process." ); } console.error("[ERR]"); console.error( "[ERR] If you need assistance, please try installing Slack manually as described in https://docs.stack-auth.com/docs/getting-started/setup or join our Discord where we're happy to help: https://discord.stack-auth.com" ); if (!(err instanceof UserError)) { console.error("[ERR]"); console.error(`[ERR] Error message: ${err.message}`); } console.error(); console.error("==============================================="); console.error(); process.exit(1); }); async function getUpdatedLayout(originalLayout) { 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 { stackServerApp } from "../stack";\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) { return undefined; } const bodyOpenEndIndex = bodyOpenTag.index + bodyOpenTag[0].length; const bodyCloseStartIndex = bodyCloseTag.index; if (bodyCloseStartIndex <= bodyOpenEndIndex) { 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); return { content: `${layout}`, indentation, }; } function guessIndentation(str) { 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, stringIndex) { 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() { if (savedProjectPath === undefined) { savedProjectPath = process.cwd(); const askForPathModification = !fs.existsSync( path.join(savedProjectPath, "package.json") ); if (askForPathModification) { savedProjectPath = ( await inquirer.prompt([ { type: "input", name: "newPath", message: "Please enter the path to your project:", default: ".", }, ]) ).newPath; } } return savedProjectPath; } async function findJsExtension(fullPathWithoutExtension) { for (const ext of jsLikeFileExtensions) { const fullPath = fullPathWithoutExtension + "." + ext; if (fs.existsSync(fullPath)) { return ext; } } return null; } async function getPackageManager() { 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")); if (yarnLock && !pnpmLock && !npmLock) { return "yarn"; } else if (!yarnLock && pnpmLock && !npmLock) { return "pnpm"; } else if (!yarnLock && !pnpmLock && npmLock) { return "npm"; } const answers = await inquirer.prompt([ { type: "list", name: "packageManager", message: "Which package manager are you using for this project?", choices: ["npm", "yarn", "pnpm"], }, ]); return answers.packageManager; } async function shellNicelyFormatted(command, options) { console.log(); const ui = new inquirer.ui.BottomBar(); ui.updateBottomBar(`Running command: ${command}...`); const child = child_process.spawn(command, options); child.stdout.pipe(ui.log); child.stderr.pipe(ui.log); await new Promise((resolve, reject) => { child.on("exit", (code) => { if (code === 0) { resolve(); } else { reject(new Error(`Command ${command} failed with code ${code}`)); } }); }); ui.updateBottomBar(`Command ${command} finished successfully!\n`); ui.close(); } async function readFile(fullPath) { try { return fs.readFileSync(fullPath, "utf-8"); } catch (err) { if (err.code === "ENOENT") { return null; } throw err; } } async function writeFile(fullPath, content) { fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.writeFileSync(fullPath, content); } async function writeFileIfNotExists(fullPath, content) { if (!fs.existsSync(fullPath)) { await writeFile(fullPath, content); } } async function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function throwErr(message) { throw new Error(message); }