diff --git a/packages/create-payload-app/package.json b/packages/create-payload-app/package.json index bb5720263..78bb757db 100644 --- a/packages/create-payload-app/package.json +++ b/packages/create-payload-app/package.json @@ -31,7 +31,7 @@ "detect-package-manager": "^3.0.1", "esprima": "^4.0.1", "execa": "^5.0.0", - "figures": "^3.2.0", + "figures": "^6.1.0", "fs-extra": "^9.0.1", "globby": "11.1.0", "terminal-link": "^2.1.1" diff --git a/packages/create-payload-app/src/lib/create-project.spec.ts b/packages/create-payload-app/src/lib/create-project.spec.ts index af5b84029..ba57302e0 100644 --- a/packages/create-payload-app/src/lib/create-project.spec.ts +++ b/packages/create-payload-app/src/lib/create-project.spec.ts @@ -128,9 +128,4 @@ describe('createProject', () => { }) }) }) - - describe('Templates', () => { - it.todo('Verify that all templates are valid') - // Loop through all templates.ts that should have replacement comments, and verify that they are present - }) }) diff --git a/packages/create-payload-app/src/lib/init-next.ts b/packages/create-payload-app/src/lib/init-next.ts index d4c39279a..b8374ef74 100644 --- a/packages/create-payload-app/src/lib/init-next.ts +++ b/packages/create-payload-app/src/lib/init-next.ts @@ -25,7 +25,7 @@ import { wrapNextConfig } from './wrap-next-config.js' type InitNextArgs = Pick & { dbType: DbType - nextConfigPath: string + nextAppDetails?: NextAppDetails packageManager: PackageManager projectDir: string useDistFiles?: boolean @@ -43,24 +43,16 @@ type InitNextResult = export async function initNext(args: InitNextArgs): Promise { const { dbType: dbType, packageManager, projectDir } = args - const isSrcDir = fs.existsSync(path.resolve(projectDir, 'src')) + const nextAppDetails = args.nextAppDetails || (await getNextAppDetails(projectDir)) - // Get app directory. Could be top-level or src/app - const nextAppDir = ( - await globby(['**/app'], { - absolute: true, - cwd: projectDir, - onlyDirectories: true, - }) - )?.[0] + const { hasTopLevelLayout, isSrcDir, nextAppDir } = + nextAppDetails || (await getNextAppDetails(projectDir)) if (!nextAppDir) { return { isSrcDir, reason: `Could not find app directory in ${projectDir}`, success: false } } - // Check for top-level layout.tsx - const layoutPath = path.resolve(nextAppDir, 'layout.tsx') - if (fs.existsSync(layoutPath)) { + if (hasTopLevelLayout) { // Output directions for user to move all files from app to top-level directory named `(app)` p.log.warn(moveMessage({ nextAppDir, projectDir })) return { @@ -76,8 +68,7 @@ export async function initNext(args: InitNextArgs): Promise { const configurationResult = installAndConfigurePayload({ ...args, - isSrcDir, - nextAppDir, + nextAppDetails, useDistFiles: true, // Requires running 'pnpm pack-template-files' in cpa }) @@ -115,7 +106,10 @@ async function addPayloadConfigToTsConfig(projectDir: string, isSrcDir: boolean) userTsConfig.compilerOptions = {} } - if (!userTsConfig.compilerOptions.paths?.['@payload-config']) { + if ( + !userTsConfig.compilerOptions?.paths?.['@payload-config'] && + userTsConfig.compilerOptions?.paths + ) { userTsConfig.compilerOptions.paths = { ...(userTsConfig.compilerOptions.paths || {}), '@payload-config': [`./${isSrcDir ? 'src/' : ''}payload.config.ts`], @@ -125,14 +119,23 @@ async function addPayloadConfigToTsConfig(projectDir: string, isSrcDir: boolean) } function installAndConfigurePayload( - args: InitNextArgs & { - isSrcDir: boolean - nextAppDir: string - }, + args: InitNextArgs & { nextAppDetails: NextAppDetails; useDistFiles?: boolean }, ): | { payloadConfigPath: string; success: true } | { payloadConfigPath?: string; reason: string; success: false } { - const { '--debug': debug, isSrcDir, nextAppDir, nextConfigPath, projectDir, useDistFiles } = args + const { + '--debug': debug, + nextAppDetails: { isSrcDir, nextAppDir, nextConfigPath } = {}, + projectDir, + useDistFiles, + } = args + + if (!nextAppDir || !nextConfigPath) { + return { + reason: 'Could not find app directory or next.config.js', + success: false, + } + } const logDebug = (message: string) => { if (debug) origDebug(message) @@ -164,7 +167,6 @@ function installAndConfigurePayload( logDebug(`Copying template files from ${templateFilesPath} to ${nextAppDir}`) const templateSrcDir = path.resolve(templateFilesPath, isSrcDir ? '' : 'src') - // const templateSrcDir = path.resolve(templateFilesPath) logDebug(`templateSrcDir: ${templateSrcDir}`) logDebug(`nextAppDir: ${nextAppDir}`) @@ -218,3 +220,43 @@ async function installDeps(projectDir: string, packageManager: PackageManager, d return { success: exitCode === 0 } } + +type NextAppDetails = { + hasTopLevelLayout: boolean + isSrcDir: boolean + nextAppDir?: string + nextConfigPath?: string +} + +export async function getNextAppDetails(projectDir: string): Promise { + const isSrcDir = fs.existsSync(path.resolve(projectDir, 'src')) + + const nextConfigPath: string | undefined = ( + await globby('next.config.*js', { absolute: true, cwd: projectDir }) + )?.[0] + if (!nextConfigPath || nextConfigPath.length === 0) { + return { + hasTopLevelLayout: false, + isSrcDir, + nextConfigPath: undefined, + } + } + + let nextAppDir: string | undefined = ( + await globby(['**/app'], { + absolute: true, + cwd: projectDir, + onlyDirectories: true, + }) + )?.[0] + + if (!nextAppDir || nextAppDir.length === 0) { + nextAppDir = undefined + } + + const hasTopLevelLayout = nextAppDir + ? fs.existsSync(path.resolve(nextAppDir, 'layout.tsx')) + : false + + return { hasTopLevelLayout, isSrcDir, nextAppDir, nextConfigPath } +} diff --git a/packages/create-payload-app/src/lib/wrap-next-config.ts b/packages/create-payload-app/src/lib/wrap-next-config.ts index 6964f9842..93722e0c3 100644 --- a/packages/create-payload-app/src/lib/wrap-next-config.ts +++ b/packages/create-payload-app/src/lib/wrap-next-config.ts @@ -40,10 +40,10 @@ export function parseAndModifyConfigContent(content: string): { throw new Error('Could not find ExportDefaultDeclaration in next.config.js') } - if (exportDefaultDeclaration) { + if (exportDefaultDeclaration && exportDefaultDeclaration.declaration?.loc) { const modifiedConfigContent = insertBeforeAndAfter( content, - exportDefaultDeclaration.declaration?.loc, + exportDefaultDeclaration.declaration.loc, ) return { modifiedConfigContent, success: true } } else if (exportNamedDeclaration) { @@ -65,13 +65,13 @@ export function parseAndModifyConfigContent(content: string): { success: false, } } - } else { - warning('Could not automatically wrap next.config.js with withPayload.') - warnUserWrapNotSuccessful() - return { - modifiedConfigContent: content, - success: false, - } + } + + warning('Could not automatically wrap next.config.js with withPayload.') + warnUserWrapNotSuccessful() + return { + modifiedConfigContent: content, + success: false, } } diff --git a/packages/create-payload-app/src/lib/write-env-file.ts b/packages/create-payload-app/src/lib/write-env-file.ts index bff1b7522..04c10455e 100644 --- a/packages/create-payload-app/src/lib/write-env-file.ts +++ b/packages/create-payload-app/src/lib/write-env-file.ts @@ -11,7 +11,7 @@ export async function writeEnvFile(args: { databaseUri: string payloadSecret: string projectDir: string - template: ProjectTemplate + template?: ProjectTemplate }): Promise { const { cliArgs, databaseUri, payloadSecret, projectDir, template } = args @@ -21,7 +21,7 @@ export async function writeEnvFile(args: { } try { - if (template.type === 'starter' && fs.existsSync(path.join(projectDir, '.env.example'))) { + if (template?.type === 'starter' && fs.existsSync(path.join(projectDir, '.env.example'))) { // Parse .env file into key/value pairs const envFile = await fs.readFile(path.join(projectDir, '.env.example'), 'utf8') const envWithValues: string[] = envFile @@ -47,7 +47,7 @@ export async function writeEnvFile(args: { // Write new .env file await fs.writeFile(path.join(projectDir, '.env'), envWithValues.join('\n')) } else { - const content = `MONGODB_URI=${databaseUri}\nPAYLOAD_SECRET=${payloadSecret}` + const content = `DATABASE_URI=${databaseUri}\nPAYLOAD_SECRET=${payloadSecret}` await fs.outputFile(`${projectDir}/.env`, content) } } catch (err: unknown) { diff --git a/packages/create-payload-app/src/main.ts b/packages/create-payload-app/src/main.ts index c35ce5577..f32e14f75 100644 --- a/packages/create-payload-app/src/main.ts +++ b/packages/create-payload-app/src/main.ts @@ -2,8 +2,9 @@ import * as p from '@clack/prompts' import slugify from '@sindresorhus/slugify' import arg from 'arg' import chalk from 'chalk' +// @ts-expect-error no types import { detect } from 'detect-package-manager' -import globby from 'globby' +import figures from 'figures' import path from 'path' import type { CliArgs, PackageManager } from './types.js' @@ -11,14 +12,20 @@ import type { CliArgs, PackageManager } from './types.js' import { configurePayloadConfig } from './lib/configure-payload-config.js' import { createProject } from './lib/create-project.js' import { generateSecret } from './lib/generate-secret.js' -import { initNext } from './lib/init-next.js' +import { getNextAppDetails, initNext } from './lib/init-next.js' import { parseProjectName } from './lib/parse-project-name.js' import { parseTemplate } from './lib/parse-template.js' import { selectDb } from './lib/select-db.js' import { getValidTemplates, validateTemplate } from './lib/templates.js' import { writeEnvFile } from './lib/write-env-file.js' import { error, info } from './utils/log.js' -import { feedbackOutro, helpMessage, successMessage, successfulNextInit } from './utils/messages.js' +import { + feedbackOutro, + helpMessage, + moveMessage, + successMessage, + successfulNextInit, +} from './utils/messages.js' export class Main { args: CliArgs @@ -62,12 +69,6 @@ export class Main { } async init(): Promise { - const initContext: { - nextConfigPath: string | undefined - } = { - nextConfigPath: undefined, - } - try { if (this.args['--help']) { helpMessage() @@ -77,15 +78,14 @@ export class Main { // eslint-disable-next-line no-console console.log('\n') p.intro(chalk.bgCyan(chalk.black(' create-payload-app '))) - p.log.message("Welcome to Payload. Let's create a project!") - // Detect if inside Next.js projeckpt - const nextConfigPath = ( - await globby('next.config.*js', { absolute: true, cwd: process.cwd() }) - )?.[0] - initContext.nextConfigPath = nextConfigPath + p.note("Welcome to Payload. Let's create a project!") - if (initContext.nextConfigPath) { - this.args['--name'] = slugify(path.basename(path.dirname(initContext.nextConfigPath))) + // Detect if inside Next.js project + const nextAppDetails = await getNextAppDetails(process.cwd()) + const { hasTopLevelLayout, nextAppDir, nextConfigPath } = nextAppDetails + + if (nextConfigPath) { + this.args['--name'] = slugify(path.basename(path.dirname(nextConfigPath))) } const projectName = await parseProjectName(this.args) @@ -96,13 +96,23 @@ export class Main { const packageManager = await getPackageManager(this.args, projectDir) if (nextConfigPath) { - // p.note('Detected existing Next.js project.') - p.log.step(chalk.bold('Detected existing Next.js project.')) + p.log.step( + chalk.bold(`${chalk.bgBlack(` ${figures.triangleUp} Next.js `)} project detected!`), + ) + const proceed = await p.confirm({ initialValue: true, - message: 'Install Payload in this project?', + message: chalk.bold(`Install ${chalk.green('Payload')} in this project?`), }) if (p.isCancel(proceed) || !proceed) { + p.outro(feedbackOutro()) + process.exit(0) + } + + // Check for top-level layout.tsx + if (nextAppDir && hasTopLevelLayout) { + p.log.warn(moveMessage({ nextAppDir, projectDir })) + p.outro(feedbackOutro()) process.exit(0) } @@ -111,12 +121,13 @@ export class Main { const result = await initNext({ ...this.args, dbType: dbDetails.type, - nextConfigPath, + nextAppDetails, packageManager, projectDir, }) if (result.success === false) { + p.outro(feedbackOutro()) process.exit(1) } @@ -127,7 +138,14 @@ export class Main { }, }) - info('Payload project successfully created!') + await writeEnvFile({ + cliArgs: this.args, + databaseUri: dbDetails.dbUri, + payloadSecret: generateSecret(), + projectDir, + }) + + info('Payload project successfully initialized!') p.note(successfulNextInit(), chalk.bgGreen(chalk.black(' Documentation '))) p.outro(feedbackOutro()) return @@ -146,6 +164,7 @@ export class Main { const template = await parseTemplate(this.args, validTemplates) if (!template) { p.log.error('Invalid template given') + p.outro(feedbackOutro()) process.exit(1) } diff --git a/packages/create-payload-app/src/utils/copy-recursive-sync.ts b/packages/create-payload-app/src/utils/copy-recursive-sync.ts index 9acf09798..c2b4eda6a 100644 --- a/packages/create-payload-app/src/utils/copy-recursive-sync.ts +++ b/packages/create-payload-app/src/utils/copy-recursive-sync.ts @@ -7,7 +7,7 @@ import path from 'path' export function copyRecursiveSync(src: string, dest: string, debug?: boolean) { const exists = fs.existsSync(src) const stats = exists && fs.statSync(src) - const isDirectory = exists && stats.isDirectory() + const isDirectory = exists && stats !== false && stats.isDirectory() if (isDirectory) { fs.mkdirSync(dest, { recursive: true }) fs.readdirSync(src).forEach((childItemName) => { diff --git a/packages/create-payload-app/src/utils/messages.ts b/packages/create-payload-app/src/utils/messages.ts index 9a4cd9890..3d5caa976 100644 --- a/packages/create-payload-app/src/utils/messages.ts +++ b/packages/create-payload-app/src/utils/messages.ts @@ -84,18 +84,18 @@ export function moveMessage(args: { nextAppDir: string; projectDir: string }): s return ` ${header('Next Steps:')} -Payload does not support a top-level layout.tsx file in your Next.js app directory. +Payload does not support a top-level layout.tsx file in the app directory. ${chalk.bold('To continue:')} -Move all files from ./${relativePath} to a named directory such as ${chalk.bold('(app)')} +Move all files from ./${relativePath} to a named directory such as ./${relativePath}/${chalk.bold('(app)')} Once moved, rerun the create-payload-app command again. ` } export function feedbackOutro(): string { - return `${chalk.bgCyan(chalk.black(' Have feedback? '))} Visit ${createTerminalLink('GitHub', 'https://github.com/payloadcms/payload')}` + return `${chalk.bgCyan(chalk.black(' Have feedback? '))} Visit us on ${createTerminalLink('GitHub', 'https://github.com/payloadcms/payload')}.` } // Create terminalLink with fallback for unsupported terminals diff --git a/packages/create-payload-app/tsconfig.json b/packages/create-payload-app/tsconfig.json index 4ae9b2f4d..271a35b89 100644 --- a/packages/create-payload-app/tsconfig.json +++ b/packages/create-payload-app/tsconfig.json @@ -5,7 +5,8 @@ "noEmit": false /* Do not emit outputs. */, "emitDeclarationOnly": true, "outDir": "./dist" /* Specify an output folder for all emitted files. */, - "rootDir": "./src" /* Specify the root folder within your source files. */ + "rootDir": "./src" /* Specify the root folder within your source files. */, + "strict": true, }, "exclude": ["dist", "build", "tests", "test", "node_modules", ".eslintrc.js"], "include": ["src/**/*.ts", "src/**/*.spec.ts", "src/**/*.tsx", "src/**/*.d.ts", "src/**/*.json"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5cc10d7b6..ad9981abd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -326,8 +326,8 @@ importers: specifier: ^5.0.0 version: 5.1.1 figures: - specifier: ^3.2.0 - version: 3.2.0 + specifier: ^6.1.0 + version: 6.1.0 fs-extra: specifier: ^9.0.1 version: 9.1.0 @@ -10041,6 +10041,14 @@ packages: engines: {node: '>=8'} dependencies: escape-string-regexp: 1.0.5 + dev: true + + /figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + dependencies: + is-unicode-supported: 2.0.0 + dev: false /file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} @@ -11409,7 +11417,6 @@ packages: /is-unicode-supported@2.0.0: resolution: {integrity: sha512-FRdAyx5lusK1iHG0TWpVtk9+1i+GjrzRffhDg4ovQ7mcidMQ6mj+MhKPmvh7Xwyv5gIS06ns49CA7Sqg7lC22Q==} engines: {node: '>=18'} - dev: true /is-weakmap@2.0.1: resolution: {integrity: sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==} diff --git a/test/create-payload-app/int.spec.ts b/test/create-payload-app/int.spec.ts index c081b018e..25e3be9a8 100644 --- a/test/create-payload-app/int.spec.ts +++ b/test/create-payload-app/int.spec.ts @@ -4,6 +4,7 @@ import * as CommentJson from 'comment-json' import { initNext } from 'create-payload-app/commands' import execa from 'execa' import fs from 'fs' +import fse from 'fs-extra' import path from 'path' import shelljs from 'shelljs' import tempy from 'tempy' @@ -66,9 +67,8 @@ describe('create-payload-app', () => { const firstResult = await initNext({ '--debug': true, projectDir, - nextConfigPath: path.resolve(projectDir, 'next.config.mjs'), dbType: 'mongodb', - useDistFiles: true, // create-payload-app/dist/app/(payload) + useDistFiles: true, // create-payload-app/dist/template packageManager: 'pnpm', }) @@ -91,7 +91,6 @@ describe('create-payload-app', () => { const result = await initNext({ '--debug': true, projectDir, - nextConfigPath: path.resolve(projectDir, 'next.config.mjs'), dbType: 'mongodb', useDistFiles: true, // create-payload-app/dist/app/(payload) packageManager: 'pnpm', @@ -117,11 +116,22 @@ describe('create-payload-app', () => { const userTsConfig = CommentJson.parse(userTsConfigContent) as { compilerOptions?: CompilerOptions } + + // Check that `@payload-config` path is added to tsconfig expect(userTsConfig.compilerOptions.paths?.['@payload-config']).toStrictEqual([ `./${result.isSrcDir ? 'src/' : ''}payload.config.ts`, ]) - // TODO: Start the Next.js app and check if it runs + // Payload dependencies should be installed + const packageJson = fse.readJsonSync(path.resolve(projectDir, 'package.json')) as { + dependencies: Record + } + expect(packageJson.dependencies).toMatchObject({ + payload: expect.any(String), + '@payloadcms/db-mongodb': expect.any(String), + '@payloadcms/richtext-lexical': expect.any(String), + '@payloadcms/next': expect.any(String), + }) }) }) })