#!/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 envLocalPath = path.join(projectPath, '.env.local'); 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..."); await writeFileIfNotExists(envLocalPath, "NEXT_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/signin. 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 occured 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); }