UNPKG

@adocasts.com/jumpstart

Version:

Jumpstart your next AdonisJS 6 web project with authentication, forgot password, and more features out-of-the-box.

342 lines (337 loc) 17.3 kB
import { readFile, writeFile } from 'node:fs/promises'; import { stubsRoot } from '../../stubs/main.js'; import BaseScaffold from './base_scaffold.js'; import TailwindScaffold from './tailwind_scaffold.js'; import { exec } from '../utils/child_process.js'; export default class JumpstartScaffold extends BaseScaffold { command; #isWeb = true; constructor(command) { super(command); this.command = command; } static installs = [ ...TailwindScaffold.installs, { name: 'edge-iconify', isDevDependency: false }, { name: '@iconify-json/ph', isDevDependency: false }, { name: '@iconify-json/svg-spinners', isDevDependency: false }, ]; async run() { // have user install & configure required missing core packages await this.#verifyCoreDependencies(); // once ace commands are done, let's get our codemods set up await this.boot(); // first install packages, we'll install these into the user's project so they can // update them as they see fit const packageNames = JumpstartScaffold.installs.map((install) => install.name).join(', '); this.logger.info(`We're going to install ${packageNames} to add TailwindCSS & Iconify`); await this.codemods.installPackages(JumpstartScaffold.installs); // complete tailwindcss scaffolding await new TailwindScaffold(this.command).run(); // complete jumpstart scaffolding await this.#updateEnv(); await this.#enableHttpMethodSpoofing(); await this.#registerPreloads(); await this.#generateStubs(); await this.#updateRoutes(); await this.#updateUserModel(); this.logger.success('Jumpstart is all set! Visit /jumpstart to get started.'); const selfDestruct = await this.command.prompt.confirm("We're all done here, would you like to uninstall @adocasts.com/jumpstart?"); if (selfDestruct) { await exec('npm uninstall @adocasts.com/jumpstart'); } } async #verifyCoreDependencies() { const ace = await this.app.container.make('ace'); const isViteConfigured = await this.hasProvider('@adonisjs/vite/vite_provider'); const isVineConfigured = await this.hasProvider('@adonisjs/core/providers/vinejs_provider'); const isEdgeConfigured = await this.hasProvider('@adonisjs/core/providers/edge_provider'); const isSessionConfigured = await this.hasProvider('@adonisjs/session/session_provider'); const isShieldConfigured = await this.hasProvider('@adonisjs/shield/shield_provider'); const isAuthConfigured = await this.hasProvider('@adonisjs/auth/auth_provider'); const isLucidConfigured = await this.hasProvider('@adonisjs/lucid/database_provider'); const isMailConfigured = await this.hasProvider('@adonisjs/mail/mail_provider'); if (!isViteConfigured) { this.logger.log(''); // let's add a blank line in-between these this.logger.log(this.colors.bgBlue("Vite is needed to bundle tailwind assets, let's add @adonisjs/vite")); await ace.exec('add', ['@adonisjs/vite']); } if (!isVineConfigured) { this.logger.log(''); // let's add a blank line in-between these this.logger.log(this.colors.bgBlue("VineJS is needed for Jumpstart's validations, let's add vinejs")); await ace.exec('add', ['vinejs']); } if (!isEdgeConfigured) { this.logger.log(''); // let's add a blank line in-between these this.logger.log(this.colors.bgBlue("Jumpstart uses EdgeJS for its pages & emails, let's add edge")); await ace.exec('add', ['edge']); } if (!isSessionConfigured) { this.logger.log(''); // let's add a blank line in-between these this.logger.log(this.colors.bgBlue("Session is needed for authentication & toast messaging, let's add @adonisjs/session")); await ace.exec('add', ['@adonisjs/session']); } if (!isShieldConfigured) { this.logger.log(''); // let's add a blank line in-between these this.logger.log(this.colors.bgBlue("Shield is recommended for CSRF & other protections, let's add @adonisjs/shield")); await ace.exec('add', ['@adonisjs/shield']); } if (!isLucidConfigured) { this.logger.log(''); // let's add a blank line in-between these this.logger.log(this.colors.bgBlue("Jumpstart uses Lucid as it's ORM for models & queries, let's add @adonisjs/lucid")); await ace.exec('add', ['@adonisjs/lucid']); } if (!isAuthConfigured) { this.logger.log(''); // let's add a blank line in-between these this.logger.log(this.colors.bgBlue("Jumpstart adds authentication scaffolding, let's add @adonisjs/auth")); await ace.exec('add', ['@adonisjs/auth', '--guard=session']); } if (!isMailConfigured) { this.logger.log(''); // let's add a blank line in-between these this.logger.log(this.colors.bgBlue("Jumpstart includes emails for the forgot password flow & email change notifications. Let's add @adonisjs/mail")); await ace.exec('add', ['@adonisjs/mail']); } } async #updateEnv() { await this.codemods.defineEnvVariables({ APP_URL: `http:$HOST:$PORT` }); await this.codemods.defineEnvValidations({ leadingComment: 'URL pointing to your app; useful for emails', variables: { APP_URL: 'Env.schema.string()', }, }); } async #enableHttpMethodSpoofing() { if (!this.#isWeb) return; const appConfigPath = this.app.makePath('config/app.ts'); let appConfig = await readFile(appConfigPath, 'utf8'); appConfig = appConfig.replace('allowMethodSpoofing: false', 'allowMethodSpoofing: true'); await writeFile(appConfigPath, appConfig); this.logger.action('update config/app.ts -> enabled HTTP Method Spoofing').succeeded(); } async #registerPreloads() { if (!this.#isWeb) return; await this.codemods.makeUsingStub(stubsRoot, 'start/globals.stub', {}); await this.codemods.updateRcFile((rcFile) => { rcFile.addPreloadFile('#start/globals', ['web']); }); } async #generateStubs() { //* NOTE: copy utils from base_scaffold exist because Tempura throws an exception on the backticked contents (escaped or not) // stubs -> views if (this.#isWeb) { await this.copyView('components'); await this.copyView('pages'); } await this.copyView('emails'); // stubs -> migrations await this.stubMigration('migrations/create_email_histories_table.stub'); await this.stubMigration('migrations/create_password_reset_tokens_table.stub'); await this.stubMigration('migrations/create_remember_me_tokens_table.stub'); // stubs -> models await this.copyModel('email_history.stub'); await this.copyModel('password_reset_token.stub'); // stubs -> validators await this.codemods.makeUsingStub(stubsRoot, 'validators/auth.stub', {}); await this.codemods.makeUsingStub(stubsRoot, 'validators/settings.stub', {}); // stubs -> services if (this.#isWeb) { await this.codemods.makeUsingStub(stubsRoot, 'services/edge_form_service.stub', {}); } // stubs -> controllers await this.copyController('auth/forgot_password_controller.stub'); await this.copyController('auth/login_controller.stub'); await this.copyController('auth/logout_controller.stub'); await this.copyController('auth/register_controller.stub'); await this.copyController('settings/account_controller.stub'); await this.copyController('settings/profile_controller.stub'); } async #updateRoutes() { const project = await this.codemods.getTsMorphProject(); const file = project?.getSourceFile(this.app.startPath('routes.ts')); if (!file) { this.logger.warning('skipped route updates, routes file not found'); return; } if (!file.getImportDeclaration('#start/kernel')) { file.addImportDeclaration({ namedImports: ['middleware'], moduleSpecifier: '#start/kernel' }); } const contents = file.getText(); const lastImportIndex = file.getImportDeclarations().reverse().at(0)?.getChildIndex() ?? 0; file.insertVariableStatements(lastImportIndex + 1, [ this.getConstDeclaration(file, { name: 'LoginController', initializer: "() => import('#controllers/auth/login_controller')", }), this.getConstDeclaration(file, { name: 'LogoutController', initializer: "() => import('#controllers/auth/logout_controller')", }), this.getConstDeclaration(file, { name: 'RegisterController', initializer: "() => import('#controllers/auth/register_controller')", }), this.getConstDeclaration(file, { name: 'ForgotPasswordController', initializer: "() => import('#controllers/auth/forgot_password_controller')", }), this.getConstDeclaration(file, { name: 'ProfileController', initializer: "() => import('#controllers/settings/profile_controller')", }), this.getConstDeclaration(file, { name: 'AccountController', initializer: "() => import('#controllers/settings/account_controller')", }), ].filter((declaration) => declaration !== undefined)); if (!file.getStatement((statement) => statement.getText().includes('settings.profile.update'))) { file.addStatements([ '\n', "router.on('/jumpstart').render('pages/jumpstart').as('jumpstart')", '\n', '//* AUTH -> LOGIN, REGISTER, LOGOUT', "router.get('/login', [LoginController, 'show']).as('auth.login.show').use(middleware.guest())", "router.post('/login', [LoginController, 'store']).as('auth.login.store').use([middleware.guest()])", "router.get('/register', [RegisterController, 'show']).as('auth.register.show').use(middleware.guest())", "router.post('/register', [RegisterController, 'store']).as('auth.register.store').use([middleware.guest()])", "router.post('/logout', [LogoutController, 'handle']).as('auth.logout').use(middleware.auth())", '\n', '//* AUTH -> FORGOT PASSWORD', "router.get('/forgot-password', [ForgotPasswordController, 'index']).as('auth.password.index').use([middleware.guest()])", "router.post('/forgot-password', [ForgotPasswordController, 'send']).as('auth.password.send').use([middleware.guest()])", "router.get('/forgot-password/reset/:value', [ForgotPasswordController, 'reset']).as('auth.password.reset').use([middleware.guest()])", "router.post('/forgot-password/reset', [ForgotPasswordController, 'update']).as('auth.password.update').use([middleware.guest()])", '\n', '//* SETTINGS -> ACCOUNT', "router.get('/settings/account', [AccountController, 'index']).as('settings.account').use(middleware.auth())", "router.put('/settings/account/email', [AccountController, 'updateEmail']).as('settings.account.email').use(middleware.auth())", "router.delete('/settings/account', [AccountController, 'destroy']).as('settings.account.destroy').use(middleware.auth())", '\n', '//* SETTINGS -> PROFILE', "router.get('/settings/profile', [ProfileController, 'index']).as('settings.profile').use(middleware.auth())", "router.put('/settings/profile', [ProfileController, 'update']).as('settings.profile.update').use(middleware.auth())", ].filter((statement) => statement === '\n' || !contents.includes(statement))); } await file.save(); this.logger.action('update start/routes.ts -> added jumpstart routes').succeeded(); } async #updateUserModel() { const project = await this.codemods.getTsMorphProject(); const file = project?.getSourceFile(this.app.modelsPath('user.ts')); const model = file?.getClass('User'); const imports = new Set(); if (!model) { this.logger.warning('skipped user model updates, user model not found'); return; } imports.add({ namedImports: ['Authenticator'], module: '@adonisjs/auth' }); imports.add({ namedImports: ['Authenticators'], module: '@adonisjs/auth/types' }); imports.add({ namedImports: ['Infer'], module: '@vinejs/vine/types' }); imports.add({ namedImports: ['loginValidator', 'registerValidator'], module: '#validators/auth', }); imports.add({ namedImports: ['updateEmailValidator'], module: '#validators/settings' }); imports.add({ namedImports: ['DbRememberMeTokensProvider'], module: '@adonisjs/auth/session' }); imports.add({ defaultImport: 'db', module: '@adonisjs/lucid/services/db' }); imports.add({ defaultImport: 'mail', module: '@adonisjs/mail/services/main' }); imports.add({ defaultImport: 'app', module: '@adonisjs/core/services/app' }); imports.add({ defaultImport: 'EmailHistory', module: '#models/email_history' }); if (!model.getProperty('rememberMeTokens')) { model.insertProperty(0, { isStatic: true, name: 'rememberMeTokens', initializer: 'DbRememberMeTokensProvider.forModel(User)', }); } if (!model.getMethod('login')) { const login = model.addMethod({ isStatic: true, isAsync: true, name: 'login', parameters: [ { name: 'auth', type: 'Authenticator<Authenticators>' }, { name: '{ email, password, remember }', type: 'Infer<typeof loginValidator>', }, ], }); login.setBodyText(` const user = await this.verifyCredentials(email, password) await auth.use('web').login(user, remember) return user `); } if (!model.getMethod('register')) { const register = model.addMethod({ isStatic: true, isAsync: true, name: 'register', parameters: [ { name: 'auth', type: 'Authenticator<Authenticators>' }, { name: 'data', type: 'Infer<typeof registerValidator>' }, ], }); register.setBodyText(` const user = await this.create(data) await auth.use('web').login(user) return user `); } if (!model.getMethod('logout')) { const logout = model.addMethod({ isStatic: true, isAsync: true, name: 'logout', parameters: [{ name: 'auth', type: 'Authenticator<Authenticators>' }], }); logout.setBodyText(`await auth.use('web').logout()`); } if (!model.getMethod('updateEmail')) { const updateEmail = model.addMethod({ isAsync: true, name: 'updateEmail', parameters: [{ name: 'data', type: 'Infer<typeof updateEmailValidator>' }], }); updateEmail.setBodyText(` const emailOld = this.email // verify the password is correct for auth user await User.verifyCredentials(emailOld, data.password) await db.transaction(async (trx) => { this.useTransaction(trx) await this.merge({ email: data.email }).save() await EmailHistory.create( { userId: this.id, emailNew: data.email, emailOld, }, { client: trx } ) }) await mail.sendLater((message) => { message .to(emailOld) .subject(\`Your \${app.appName} email has been successfully changed\`) .htmlView('emails/account/email_changed', { user: this }) }) `); } imports?.forEach((imp) => { const exists = file?.getImportDeclaration(imp.module); if (exists) return; file?.addImportDeclaration({ defaultImport: imp.defaultImport, namedImports: imp.namedImports, moduleSpecifier: imp.module, }); }); file?.formatText(); await file?.save(); this.logger.action('update app/models/user -> added auth methods').succeeded(); } }