From 40a0a0083f5ec3a85a4da400cf4dee2306804127 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Mon, 4 Mar 2024 16:55:43 -0500 Subject: [PATCH] chore(create-payload-app): init-next now create payload config and modifies tsconfig.json (#5242) --- package.json | 1 + packages/create-payload-app/package.json | 4 +- .../create-payload-app/src/lib/init-next.ts | 198 +++++++++++++++--- packages/create-payload-app/src/main.ts | 7 +- pnpm-lock.yaml | 56 ++++- test/create-payload-app/int.spec.ts | 73 +++++-- 6 files changed, 285 insertions(+), 54 deletions(-) diff --git a/package.json b/package.json index 456bceb61..870c6560d 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "@types/testing-library__jest-dom": "5.14.8", "add-stream": "^1.0.0", "chalk": "^4.1.2", + "comment-json": "^4.2.3", "concat-stream": "^2.0.0", "conventional-changelog": "^5.1.0", "conventional-changelog-conventionalcommits": "^7.0.2", diff --git a/packages/create-payload-app/package.json b/packages/create-payload-app/package.json index 98218ab02..41d82c091 100644 --- a/packages/create-payload-app/package.json +++ b/packages/create-payload-app/package.json @@ -7,7 +7,7 @@ }, "scripts": { "build": "pnpm copyfiles && pnpm build:swc", - "copyfiles": "copyfiles -u 4 \"../next/src/app/(payload)/**\" \"dist/app\"", + "copyfiles": "copyfiles -u 2 \"../../app/(payload)/**\" \"dist\"", "build:swc": "swc ./src -d ./dist --config-file .swcrc", "clean": "rimraf {dist,*.tsbuildinfo}", "test": "jest", @@ -23,7 +23,9 @@ "arg": "^5.0.0", "chalk": "^4.1.0", "command-exists": "^1.2.9", + "comment-json": "^4.2.3", "degit": "^2.8.4", + "detect-package-manager": "^3.0.1", "execa": "^5.0.0", "figures": "^3.2.0", "fs-extra": "^9.0.1", diff --git a/packages/create-payload-app/src/lib/init-next.ts b/packages/create-payload-app/src/lib/init-next.ts index 9e323e2fe..153333b00 100644 --- a/packages/create-payload-app/src/lib/init-next.ts +++ b/packages/create-payload-app/src/lib/init-next.ts @@ -1,16 +1,88 @@ +import type { CompilerOptions } from 'typescript' + +import chalk from 'chalk' +import * as CommentJson from 'comment-json' +import { detect } from 'detect-package-manager' +import execa from 'execa' import fs from 'fs' +import fse from 'fs-extra' import globby from 'globby' import path from 'path' import type { CliArgs } from '../types' import { copyRecursiveSync } from '../utils/copy-recursive-sync' -import { error, info, debug as origDebug, success } from '../utils/log' +import { error, info, debug as origDebug, success, warning } from '../utils/log' -export async function initNext( - args: Pick & { nextDir?: string; useDistFiles?: boolean }, -): Promise<{ success: boolean }> { - const { '--debug': debug, nextDir, useDistFiles } = args +type InitNextArgs = Pick & { + projectDir?: string + useDistFiles?: boolean +} +type InitNextResult = { reason?: string; success: boolean; userAppDir?: string } + +export async function initNext(args: InitNextArgs): Promise { + args.projectDir = args.projectDir || process.cwd() + const { projectDir } = args + const templateResult = await applyPayloadTemplateFiles(args) + if (!templateResult.success) return templateResult + + const { success: installSuccess } = await installDeps(projectDir) + if (!installSuccess) { + return { ...templateResult, reason: 'Failed to install dependencies', success: false } + } + + // Create or find payload.config.ts + const createConfigResult = findOrCreatePayloadConfig(projectDir) + if (!createConfigResult.success) { + return { ...templateResult, ...createConfigResult } + } + + // Add `@payload-config` to tsconfig.json `paths` + await addPayloadConfigToTsConfig(projectDir) + + // Output directions for user to update next.config.js + const withPayloadMessage = ` + + ${chalk.bold(`Wrap your existing next.config.js with the withPayload function. Here is an example:`)} + + const { withPayload } = require("@payloadcms/next"); + + const nextConfig = { + // Your Next.js config + }; + + module.exports = withPayload(nextConfig); + +` + + console.log(withPayloadMessage) + + return templateResult +} + +async function addPayloadConfigToTsConfig(projectDir: string) { + const tsConfigPath = path.resolve(projectDir, 'tsconfig.json') + const userTsConfigContent = await fse.readFile(tsConfigPath, { + encoding: 'utf8', + }) + const userTsConfig = CommentJson.parse(userTsConfigContent) as { + compilerOptions?: CompilerOptions + } + if (!userTsConfig.compilerOptions && !('extends' in userTsConfig)) { + userTsConfig.compilerOptions = {} + } + + if (!userTsConfig.compilerOptions.paths?.['@payload-config']) { + userTsConfig.compilerOptions.paths = { + ...(userTsConfig.compilerOptions.paths || {}), + '@payload-config': ['./payload.config.ts'], + } + await fse.writeFile(tsConfigPath, CommentJson.stringify(userTsConfig, null, 2)) + } +} + +async function applyPayloadTemplateFiles(args: InitNextArgs): Promise { + const { '--debug': debug, projectDir, useDistFiles } = args info('Initializing Payload app in Next.js project', 1) @@ -18,24 +90,18 @@ export async function initNext( if (debug) origDebug(message) } - let projectDir = process.cwd() - if (nextDir) { - projectDir = path.resolve(projectDir, nextDir) - if (debug) logDebug(`Overriding project directory to ${projectDir}`) - } - if (!fs.existsSync(projectDir)) { - error(`Could not find specified project directory at ${projectDir}`) - return { success: false } + return { reason: `Could not find specified project directory at ${projectDir}`, success: false } } + // Next.js configs can be next.config.js, next.config.mjs, etc. const foundConfig = (await globby('next.config.*js', { cwd: projectDir }))?.[0] const nextConfigPath = path.resolve(projectDir, foundConfig) if (!fs.existsSync(nextConfigPath)) { - error( - `No next.config.js found at ${nextConfigPath}. Ensure you are in a Next.js project directory.`, - ) - return { success: false } + return { + reason: `No next.config.js found at ${nextConfigPath}. Ensure you are in a Next.js project directory.`, + success: false, + } } else { if (debug) logDebug(`Found Next config at ${nextConfigPath}`) } @@ -43,21 +109,27 @@ export async function initNext( const templateFilesPath = __dirname.endsWith('dist') || useDistFiles ? path.resolve(__dirname, '../..', 'dist/app') - : path.resolve(__dirname, '../../../next/src/app') + : path.resolve(__dirname, '../../../../app') if (debug) logDebug(`Using template files from: ${templateFilesPath}`) if (!fs.existsSync(templateFilesPath)) { - error(`Could not find template source files from ${templateFilesPath}`) - return { success: false } + return { + reason: `Could not find template source files from ${templateFilesPath}`, + success: false, + } } else { if (debug) logDebug('Found template source files') } - const userAppDir = path.resolve(projectDir, 'src/app') + // src/app or app + const userAppDirGlob = await globby(['**/app'], { + cwd: projectDir, + onlyDirectories: true, + }) + const userAppDir = path.resolve(projectDir, userAppDirGlob?.[0]) if (!fs.existsSync(userAppDir)) { - error(`Could not find user app directory at ${userAppDir}`) - return { success: false } + return { reason: `Could not find user app directory inside ${projectDir}`, success: false } } else { logDebug(`Found user app directory: ${userAppDir}`) } @@ -65,5 +137,83 @@ export async function initNext( logDebug(`Copying template files from ${templateFilesPath} to ${userAppDir}`) copyRecursiveSync(templateFilesPath, userAppDir, debug) success('Successfully initialized.') - return { success: true } + return { success: true, userAppDir } +} + +async function installDeps(projectDir: string) { + const packageManager = await detect({ cwd: projectDir }) + if (!packageManager) { + throw new Error('Could not detect package manager') + } + + info(`Installing dependencies with ${packageManager}`, 1) + const packagesToInstall = [ + 'payload', + '@payloadcms/db-mongodb', + '@payloadcms/next', + '@payloadcms/richtext-slate', + '@payloadcms/ui', + ].map((pkg) => `${pkg}@alpha`) + + let exitCode = 0 + switch (packageManager) { + case 'npm': { + ;({ exitCode } = await execa('npm', ['install', '--save', ...packagesToInstall], { + cwd: projectDir, + })) + break + } + case 'yarn': + case 'pnpm': { + ;({ exitCode } = await execa(packageManager, ['add', ...packagesToInstall], { + cwd: projectDir, + })) + break + } + case 'bun': { + warning('Bun support is untested.') + ;({ exitCode } = await execa('bun', ['add', ...packagesToInstall], { cwd: projectDir })) + break + } + } + + if (exitCode !== 0) { + error(`Failed to install dependencies with ${packageManager}`) + } else { + success(`Successfully installed dependencies`) + } + return { success: exitCode === 0 } +} +function findOrCreatePayloadConfig(projectDir: string) { + const configPath = path.resolve(projectDir, 'payload.config.ts') + if (fs.existsSync(configPath)) { + return { message: 'Found existing payload.config.ts', success: true } + } else { + // Create default config + // TODO: Pull this from templates + const defaultConfig = `import path from "path"; + +import { mongooseAdapter } from "@payloadcms/db-mongodb"; // database-adapter-import +import { slateEditor } from "@payloadcms/richtext-slate"; // editor-import +import { buildConfig } from "payload/config"; + +export default buildConfig({ + editor: slateEditor({}), // editor-config + collections: [], + secret: "asdfasdf", + typescript: { + outputFile: path.resolve(__dirname, "payload-types.ts"), + }, + graphQL: { + schemaOutputFile: path.resolve(__dirname, "generated-schema.graphql"), + }, + db: mongooseAdapter({ + url: "mongodb://localhost:27017/next-payload-3", + }), +}); +` + + fse.writeFileSync(configPath, defaultConfig) + return { message: 'Created default payload.config.ts', success: true } + } } diff --git a/packages/create-payload-app/src/main.ts b/packages/create-payload-app/src/main.ts index 05459c58f..01190332c 100644 --- a/packages/create-payload-app/src/main.ts +++ b/packages/create-payload-app/src/main.ts @@ -12,7 +12,7 @@ import { parseTemplate } from './lib/parse-template' import { selectDb } from './lib/select-db' import { getValidTemplates, validateTemplate } from './lib/templates' import { writeEnvFile } from './lib/write-env-file' -import { success } from './utils/log' +import { error, success } from './utils/log' import { helpMessage, successMessage, welcomeMessage } from './utils/messages' export class Main { @@ -61,6 +61,11 @@ export class Main { if (this.args['--init-next']) { const result = await initNext(this.args) + if (!result.success) { + error(result.reason || 'Failed to initialize Payload app in Next.js project') + } else { + success('Payload app successfully initialized in Next.js project') + } process.exit(result.success ? 0 : 1) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b789f121f..ccf665b14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,9 @@ importers: chalk: specifier: ^4.1.2 version: 4.1.2 + comment-json: + specifier: ^4.2.3 + version: 4.2.3 concat-stream: specifier: ^2.0.0 version: 2.0.0 @@ -278,9 +281,15 @@ importers: command-exists: specifier: ^1.2.9 version: 1.2.9 + comment-json: + specifier: ^4.2.3 + version: 4.2.3 degit: specifier: ^2.8.4 version: 2.8.4 + detect-package-manager: + specifier: ^3.0.1 + version: 3.0.1 execa: specifier: ^5.0.0 version: 5.1.1 @@ -4305,7 +4314,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.6.3 - '@types/node': 20.6.2 + '@types/node': 16.18.85 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -4366,7 +4375,7 @@ packages: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.6.2 + '@types/node': 16.18.85 jest-mock: 29.7.0 /@jest/expect-utils@29.7.0: @@ -4390,7 +4399,7 @@ packages: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 20.6.2 + '@types/node': 16.18.85 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -4421,7 +4430,7 @@ packages: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.23 - '@types/node': 20.6.2 + '@types/node': 16.18.85 chalk: 4.1.2 collect-v8-coverage: 1.0.2 exit: 0.1.2 @@ -7491,6 +7500,9 @@ packages: is-string: 1.0.7 dev: false + /array-timsort@1.0.3: + resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + /array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} @@ -8290,6 +8302,16 @@ packages: engines: {node: ^12.20.0 || >=14} dev: false + /comment-json@4.2.3: + resolution: {integrity: sha512-SsxdiOf064DWoZLH799Ata6u7iV658A11PlWtZATDlXPpKGJnbJZ5Z24ybixAi+LUUqJ/GKowAejtC5GFUG7Tw==} + engines: {node: '>= 6'} + dependencies: + array-timsort: 1.0.3 + core-util-is: 1.0.3 + esprima: 4.0.1 + has-own-prop: 2.0.0 + repeat-string: 1.6.1 + /comment-parser@1.4.1: resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==} engines: {node: '>= 12.0.0'} @@ -8559,7 +8581,6 @@ packages: /core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - dev: true /cosmiconfig@7.1.0: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} @@ -9177,6 +9198,13 @@ packages: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} + /detect-package-manager@3.0.1: + resolution: {integrity: sha512-qoHDH6+lMcpJPAScE7+5CYj91W0mxZNXTwZPrCqi1KMk+x+AoQScQ2V1QyqTln1rHU5Haq5fikvOGHv+leKD8A==} + engines: {node: '>=12'} + dependencies: + execa: 5.1.1 + dev: false + /diff-sequences@27.5.1: resolution: {integrity: sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -11166,6 +11194,10 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + /has-own-prop@2.0.0: + resolution: {integrity: sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==} + engines: {node: '>=8'} + /has-property-descriptors@1.0.2: resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} dependencies: @@ -12313,7 +12345,7 @@ packages: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 20.6.2 + '@types/node': 16.18.85 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -12370,7 +12402,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.6.3 - '@types/node': 20.6.2 + '@types/node': 16.18.85 jest-util: 29.7.0 /jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): @@ -12420,7 +12452,7 @@ packages: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.6.2 + '@types/node': 16.18.85 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -12450,7 +12482,7 @@ packages: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.6.2 + '@types/node': 16.18.85 chalk: 4.1.2 cjs-module-lexer: 1.2.3 collect-v8-coverage: 1.0.2 @@ -12523,7 +12555,7 @@ packages: dependencies: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.6.2 + '@types/node': 16.18.85 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -15847,6 +15879,10 @@ packages: - typescript dev: true + /repeat-string@1.6.1: + resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} + engines: {node: '>=0.10'} + /require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} diff --git a/test/create-payload-app/int.spec.ts b/test/create-payload-app/int.spec.ts index b4ea0a105..4a5672864 100644 --- a/test/create-payload-app/int.spec.ts +++ b/test/create-payload-app/int.spec.ts @@ -1,48 +1,85 @@ +import type { CompilerOptions } from 'typescript' + +import * as CommentJson from 'comment-json' import fs from 'fs' import path from 'path' import shelljs from 'shelljs' +import { promisify } from 'util' import { initNext } from '../../packages/create-payload-app/src/lib/init-next' +const readFile = promisify(fs.readFile) + +const nextCreateCommands: Partial> = { + srcDir: + 'pnpm create next-app@latest . --typescript --eslint --no-tailwind --app --import-alias="@/*" --src-dir', + noSrcDir: + 'pnpm create next-app@latest . --typescript --eslint --no-tailwind --app --import-alias="@/*" --no-src-dir', +} describe('create-payload-app', () => { - describe('--init-next', () => { - const nextDir = path.resolve(__dirname, 'test-app') + beforeAll(() => { + // Runs copyfiles copy app/(payload) -> dist/app/(payload) + shelljs.exec('pnpm build:create-payload-app') + }) - beforeAll(() => { - if (fs.existsSync(nextDir)) { - fs.rmdirSync(nextDir, { recursive: true }) + describe('Next.js app template files', () => { + it('should exist in dist', () => { + const distPath = path.resolve( + __dirname, + '../../packages/create-payload-app/dist/app/(payload)', + ) + expect(fs.existsSync(distPath)).toBe(true) + }) + }) + + describe.each(Object.keys(nextCreateCommands))(`--init-next with %s`, (nextCmdKey) => { + const projectDir = path.resolve(__dirname, 'test-app') + + beforeEach(() => { + if (fs.existsSync(projectDir)) { + fs.rmdirSync(projectDir, { recursive: true }) } // Create dir for Next.js project - if (!fs.existsSync(nextDir)) { - fs.mkdirSync(nextDir) + if (!fs.existsSync(projectDir)) { + fs.mkdirSync(projectDir) } // Create a new Next.js project with default options - shelljs.exec( - 'pnpm create next-app@latest . --typescript --eslint --no-tailwind --app --import-alias="@/*" --src-dir', - { cwd: nextDir }, - ) + shelljs.exec(nextCreateCommands[nextCmdKey], { cwd: projectDir }) }) - afterAll(() => { - if (fs.existsSync(nextDir)) { - fs.rmdirSync(nextDir, { recursive: true }) + afterEach(() => { + if (fs.existsSync(projectDir)) { + fs.rmdirSync(projectDir, { recursive: true }) } }) it('should install payload app in Next.js project', async () => { - expect(fs.existsSync(nextDir)).toBe(true) + expect(fs.existsSync(projectDir)).toBe(true) const result = await initNext({ '--debug': true, - nextDir, - useDistFiles: true, // create-payload-app must be built + projectDir, + useDistFiles: true, // create-payload-app/dist/app/(payload) }) expect(result.success).toBe(true) - const payloadFilesPath = path.resolve(nextDir, 'src/app/(payload)') + + const payloadFilesPath = path.resolve(result.userAppDir, '(payload)') expect(fs.existsSync(payloadFilesPath)).toBe(true) + + const payloadConfig = path.resolve(projectDir, 'payload.config.ts') + expect(fs.existsSync(payloadConfig)).toBe(true) + + const tsConfigPath = path.resolve(projectDir, 'tsconfig.json') + const userTsConfigContent = await readFile(tsConfigPath, { encoding: 'utf8' }) + const userTsConfig = CommentJson.parse(userTsConfigContent) as { + compilerOptions?: CompilerOptions + } + expect(userTsConfig.compilerOptions.paths?.['@payload-config']).toEqual([ + './payload.config.ts', + ]) }) }) })