fix(cpa): generate .env when using the --example flag (#12572)
When cloning a new project from the examples dir via create-payload-app, the corresponding `.env` file is not being generated. This is because the `--example` flag does not prompt for database credentials, which ultimately skips this step. For example: ```bash npx create-payload-app --example live-preview ``` The result will include the provided `.env.example`, but lacks a `.env`. We were previously writing to the `.env.example` file, which is unexpected. We should only be writing to the `.env` file itself. To do this, we only write the `.env.example` to memory as needed, instead of the file system. This PR also simplifies the logic needed to set default vars, and improves the testing coverage overall.
This commit is contained in:
@@ -10,10 +10,10 @@ import type { CliArgs, DbType, ProjectExample, ProjectTemplate } from '../types.
|
||||
import { createProject } from './create-project.js'
|
||||
import { dbReplacements } from './replacements.js'
|
||||
import { getValidTemplates } from './templates.js'
|
||||
import { manageEnvFiles } from './manage-env-files.js'
|
||||
|
||||
describe('createProject', () => {
|
||||
let projectDir: string
|
||||
|
||||
beforeAll(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log = jest.fn()
|
||||
@@ -179,75 +179,5 @@ describe('createProject', () => {
|
||||
expect(content).toContain(dbReplacement.configReplacement().join('\n'))
|
||||
})
|
||||
})
|
||||
describe('managing env files', () => {
|
||||
it('updates .env files without overwriting existing data', async () => {
|
||||
const envFilePath = path.join(projectDir, '.env')
|
||||
const envExampleFilePath = path.join(projectDir, '.env.example')
|
||||
|
||||
fse.ensureDirSync(projectDir)
|
||||
fse.ensureFileSync(envFilePath)
|
||||
fse.ensureFileSync(envExampleFilePath)
|
||||
|
||||
const initialEnvContent = `CUSTOM_VAR=custom-value\nDATABASE_URI=old-connection\n`
|
||||
const initialEnvExampleContent = `CUSTOM_VAR=custom-value\nDATABASE_URI=old-connection\nPAYLOAD_SECRET=YOUR_SECRET_HERE\n`
|
||||
|
||||
fse.writeFileSync(envFilePath, initialEnvContent)
|
||||
fse.writeFileSync(envExampleFilePath, initialEnvExampleContent)
|
||||
|
||||
await manageEnvFiles({
|
||||
cliArgs: {
|
||||
'--debug': true,
|
||||
} as CliArgs,
|
||||
databaseType: 'mongodb',
|
||||
databaseUri: 'mongodb://localhost:27017/test',
|
||||
payloadSecret: 'test-secret',
|
||||
projectDir,
|
||||
template: undefined,
|
||||
})
|
||||
|
||||
const updatedEnvContent = fse.readFileSync(envFilePath, 'utf-8')
|
||||
|
||||
expect(updatedEnvContent).toContain('CUSTOM_VAR=custom-value')
|
||||
expect(updatedEnvContent).toContain('DATABASE_URI=mongodb://localhost:27017/test')
|
||||
expect(updatedEnvContent).toContain('PAYLOAD_SECRET=test-secret')
|
||||
|
||||
const updatedEnvExampleContent = fse.readFileSync(envExampleFilePath, 'utf-8')
|
||||
|
||||
expect(updatedEnvExampleContent).toContain('CUSTOM_VAR=custom-value')
|
||||
expect(updatedEnvContent).toContain('DATABASE_URI=mongodb://localhost:27017/test')
|
||||
expect(updatedEnvContent).toContain('PAYLOAD_SECRET=test-secret')
|
||||
})
|
||||
|
||||
it('creates .env and .env.example if they do not exist', async () => {
|
||||
const envFilePath = path.join(projectDir, '.env')
|
||||
const envExampleFilePath = path.join(projectDir, '.env.example')
|
||||
|
||||
fse.ensureDirSync(projectDir)
|
||||
|
||||
if (fse.existsSync(envFilePath)) fse.removeSync(envFilePath)
|
||||
if (fse.existsSync(envExampleFilePath)) fse.removeSync(envExampleFilePath)
|
||||
|
||||
await manageEnvFiles({
|
||||
cliArgs: {
|
||||
'--debug': true,
|
||||
} as CliArgs,
|
||||
databaseUri: '',
|
||||
payloadSecret: '',
|
||||
projectDir,
|
||||
template: undefined,
|
||||
})
|
||||
|
||||
expect(fse.existsSync(envFilePath)).toBe(true)
|
||||
expect(fse.existsSync(envExampleFilePath)).toBe(true)
|
||||
|
||||
const updatedEnvContent = fse.readFileSync(envFilePath, 'utf-8')
|
||||
expect(updatedEnvContent).toContain('DATABASE_URI=your-connection-string-here')
|
||||
expect(updatedEnvContent).toContain('PAYLOAD_SECRET=YOUR_SECRET_HERE')
|
||||
|
||||
const updatedEnvExampleContent = fse.readFileSync(envExampleFilePath, 'utf-8')
|
||||
expect(updatedEnvExampleContent).toContain('DATABASE_URI=your-connection-string-here')
|
||||
expect(updatedEnvExampleContent).toContain('PAYLOAD_SECRET=YOUR_SECRET_HERE')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -144,17 +144,14 @@ export async function createProject(
|
||||
}
|
||||
}
|
||||
|
||||
// Call manageEnvFiles before initializing Git
|
||||
if (dbDetails) {
|
||||
await manageEnvFiles({
|
||||
cliArgs,
|
||||
databaseType: dbDetails.type,
|
||||
databaseUri: dbDetails.dbUri,
|
||||
payloadSecret: generateSecret(),
|
||||
projectDir,
|
||||
template: 'template' in args ? args.template : undefined,
|
||||
})
|
||||
}
|
||||
await manageEnvFiles({
|
||||
cliArgs,
|
||||
databaseType: dbDetails?.type,
|
||||
databaseUri: dbDetails?.dbUri,
|
||||
payloadSecret: generateSecret(),
|
||||
projectDir,
|
||||
template: 'template' in args ? args.template : undefined,
|
||||
})
|
||||
|
||||
// Remove yarn.lock file. This is only desired in Payload Cloud.
|
||||
const lockPath = path.resolve(projectDir, 'pnpm-lock.yaml')
|
||||
|
||||
165
packages/create-payload-app/src/lib/manage-env-files.spec.ts
Normal file
165
packages/create-payload-app/src/lib/manage-env-files.spec.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { jest } from '@jest/globals'
|
||||
import fs from 'fs'
|
||||
import fse from 'fs-extra'
|
||||
import * as os from 'node:os'
|
||||
import path from 'path'
|
||||
|
||||
import type { CliArgs } from '../types.js'
|
||||
|
||||
import { manageEnvFiles } from './manage-env-files.js'
|
||||
|
||||
describe('createProject', () => {
|
||||
let projectDir: string
|
||||
let envFilePath = ''
|
||||
let envExampleFilePath = ''
|
||||
|
||||
beforeAll(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log = jest.fn()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
const tempDirectory = fs.realpathSync(os.tmpdir())
|
||||
projectDir = `${tempDirectory}/${Math.random().toString(36).substring(7)}`
|
||||
|
||||
envFilePath = path.join(projectDir, '.env')
|
||||
envExampleFilePath = path.join(projectDir, '.env.example')
|
||||
|
||||
if (fse.existsSync(envFilePath)) {
|
||||
fse.removeSync(envFilePath)
|
||||
}
|
||||
|
||||
fse.ensureDirSync(projectDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (fse.existsSync(projectDir)) {
|
||||
fse.rmSync(projectDir, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('generates .env using defaults (not from .env.example)', async () => {
|
||||
// ensure no .env.example exists so that the default values are used
|
||||
// the `manageEnvFiles` function will look for .env.example in the file system
|
||||
if (fse.existsSync(envExampleFilePath)) {
|
||||
fse.removeSync(envExampleFilePath)
|
||||
}
|
||||
|
||||
await manageEnvFiles({
|
||||
cliArgs: {
|
||||
'--debug': true,
|
||||
} as CliArgs,
|
||||
databaseUri: '', // omitting this will ensure the default vars are used
|
||||
payloadSecret: '', // omitting this will ensure the default vars are used
|
||||
projectDir,
|
||||
template: undefined,
|
||||
})
|
||||
|
||||
expect(fse.existsSync(envFilePath)).toBe(true)
|
||||
|
||||
const updatedEnvContent = fse.readFileSync(envFilePath, 'utf-8')
|
||||
|
||||
expect(updatedEnvContent).toBe(
|
||||
`# Added by Payload\nPAYLOAD_SECRET=YOUR_SECRET_HERE\nDATABASE_URI=your-connection-string-here`,
|
||||
)
|
||||
})
|
||||
|
||||
it('generates .env from .env.example', async () => {
|
||||
// create or override the .env.example file with a connection string that will NOT be overridden
|
||||
fse.ensureFileSync(envExampleFilePath)
|
||||
fse.writeFileSync(
|
||||
envExampleFilePath,
|
||||
`DATABASE_URI=example-connection-string\nCUSTOM_VAR=custom-value\n`,
|
||||
)
|
||||
|
||||
await manageEnvFiles({
|
||||
cliArgs: {
|
||||
'--debug': true,
|
||||
} as CliArgs,
|
||||
databaseUri: '', // omitting this will ensure the `.env.example` vars are used
|
||||
payloadSecret: '', // omitting this will ensure the `.env.example` vars are used
|
||||
projectDir,
|
||||
template: undefined,
|
||||
})
|
||||
|
||||
expect(fse.existsSync(envFilePath)).toBe(true)
|
||||
|
||||
const updatedEnvContent = fse.readFileSync(envFilePath, 'utf-8')
|
||||
|
||||
expect(updatedEnvContent).toBe(
|
||||
`DATABASE_URI=example-connection-string\nCUSTOM_VAR=custom-value\nPAYLOAD_SECRET=YOUR_SECRET_HERE\n# Added by Payload`,
|
||||
)
|
||||
})
|
||||
|
||||
it('updates existing .env without overriding vars', async () => {
|
||||
// create an existing .env file with some custom variables that should NOT be overridden
|
||||
fse.ensureFileSync(envFilePath)
|
||||
fse.writeFileSync(
|
||||
envFilePath,
|
||||
`CUSTOM_VAR=custom-value\nDATABASE_URI=example-connection-string\n`,
|
||||
)
|
||||
|
||||
// create an .env.example file to ensure that its contents DO NOT override existing .env vars
|
||||
fse.ensureFileSync(envExampleFilePath)
|
||||
fse.writeFileSync(
|
||||
envExampleFilePath,
|
||||
`CUSTOM_VAR=custom-value-2\nDATABASE_URI=example-connection-string-2\n`,
|
||||
)
|
||||
|
||||
await manageEnvFiles({
|
||||
cliArgs: {
|
||||
'--debug': true,
|
||||
} as CliArgs,
|
||||
databaseUri: '', // omitting this will ensure the `.env` vars are kept
|
||||
payloadSecret: '', // omitting this will ensure the `.env` vars are kept
|
||||
projectDir,
|
||||
template: undefined,
|
||||
})
|
||||
|
||||
expect(fse.existsSync(envFilePath)).toBe(true)
|
||||
|
||||
const updatedEnvContent = fse.readFileSync(envFilePath, 'utf-8')
|
||||
|
||||
expect(updatedEnvContent).toBe(
|
||||
`# Added by Payload\nPAYLOAD_SECRET=YOUR_SECRET_HERE\nDATABASE_URI=example-connection-string\nCUSTOM_VAR=custom-value`,
|
||||
)
|
||||
})
|
||||
|
||||
it('sanitizes .env based on selected database type', async () => {
|
||||
await manageEnvFiles({
|
||||
cliArgs: {
|
||||
'--debug': true,
|
||||
} as CliArgs,
|
||||
databaseType: 'mongodb', // this mimics the CLI selection and will be used as the DATABASE_URI
|
||||
databaseUri: 'mongodb://localhost:27017/test', // this mimics the CLI selection and will be used as the DATABASE_URI
|
||||
payloadSecret: 'test-secret', // this mimics the CLI selection and will be used as the PAYLOAD_SECRET
|
||||
projectDir,
|
||||
template: undefined,
|
||||
})
|
||||
|
||||
const updatedEnvContent = fse.readFileSync(envFilePath, 'utf-8')
|
||||
|
||||
expect(updatedEnvContent).toBe(
|
||||
`# Added by Payload\nPAYLOAD_SECRET=test-secret\nDATABASE_URI=mongodb://localhost:27017/test`,
|
||||
)
|
||||
|
||||
// delete the generated .env file and do it again, but this time, omit the databaseUri to ensure the default is generated
|
||||
fse.removeSync(envFilePath)
|
||||
|
||||
await manageEnvFiles({
|
||||
cliArgs: {
|
||||
'--debug': true,
|
||||
} as CliArgs,
|
||||
databaseType: 'mongodb', // this mimics the CLI selection and will be used as the DATABASE_URI
|
||||
databaseUri: '', // omit this to ensure the default is generated based on the selected database type
|
||||
payloadSecret: 'test-secret',
|
||||
projectDir,
|
||||
template: undefined,
|
||||
})
|
||||
|
||||
const updatedEnvContentWithDefault = fse.readFileSync(envFilePath, 'utf-8')
|
||||
expect(updatedEnvContentWithDefault).toBe(
|
||||
`# Added by Payload\nPAYLOAD_SECRET=test-secret\nDATABASE_URI=mongodb://127.0.0.1/your-database-name`,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -6,21 +6,42 @@ import type { CliArgs, DbType, ProjectTemplate } from '../types.js'
|
||||
import { debug, error } from '../utils/log.js'
|
||||
import { dbChoiceRecord } from './select-db.js'
|
||||
|
||||
const updateEnvExampleVariables = (
|
||||
contents: string,
|
||||
databaseType: DbType | undefined,
|
||||
payloadSecret?: string,
|
||||
databaseUri?: string,
|
||||
): string => {
|
||||
const sanitizeEnv = ({
|
||||
contents,
|
||||
databaseType,
|
||||
databaseUri,
|
||||
payloadSecret,
|
||||
}: {
|
||||
contents: string
|
||||
databaseType: DbType | undefined
|
||||
databaseUri?: string
|
||||
payloadSecret?: string
|
||||
}): string => {
|
||||
const seenKeys = new Set<string>()
|
||||
const updatedEnv = contents
|
||||
|
||||
// add defaults
|
||||
let withDefaults = contents
|
||||
|
||||
if (
|
||||
!contents.includes('DATABASE_URI') &&
|
||||
!contents.includes('POSTGRES_URL') &&
|
||||
!contents.includes('MONGODB_URI')
|
||||
) {
|
||||
withDefaults += '\nDATABASE_URI=your-connection-string-here'
|
||||
}
|
||||
|
||||
if (!contents.includes('PAYLOAD_SECRET')) {
|
||||
withDefaults += '\nPAYLOAD_SECRET=YOUR_SECRET_HERE'
|
||||
}
|
||||
|
||||
let updatedEnv = withDefaults
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
if (line.startsWith('#') || !line.includes('=')) {
|
||||
return line
|
||||
}
|
||||
|
||||
const [key] = line.split('=')
|
||||
const [key, value] = line.split('=')
|
||||
|
||||
if (!key) {
|
||||
return
|
||||
@@ -28,6 +49,7 @@ const updateEnvExampleVariables = (
|
||||
|
||||
if (key === 'DATABASE_URI' || key === 'POSTGRES_URL' || key === 'MONGODB_URI') {
|
||||
const dbChoice = databaseType ? dbChoiceRecord[databaseType] : null
|
||||
|
||||
if (dbChoice) {
|
||||
const placeholderUri = databaseUri
|
||||
? databaseUri
|
||||
@@ -36,6 +58,8 @@ const updateEnvExampleVariables = (
|
||||
databaseType === 'vercel-postgres'
|
||||
? `POSTGRES_URL=${placeholderUri}`
|
||||
: `DATABASE_URI=${placeholderUri}`
|
||||
} else {
|
||||
line = `${key}=${value}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +80,10 @@ const updateEnvExampleVariables = (
|
||||
.reverse()
|
||||
.join('\n')
|
||||
|
||||
if (!updatedEnv.includes('# Added by Payload')) {
|
||||
updatedEnv = `# Added by Payload\n${updatedEnv}`
|
||||
}
|
||||
|
||||
return updatedEnv
|
||||
}
|
||||
|
||||
@@ -63,7 +91,7 @@ const updateEnvExampleVariables = (
|
||||
export async function manageEnvFiles(args: {
|
||||
cliArgs: CliArgs
|
||||
databaseType?: DbType
|
||||
databaseUri: string
|
||||
databaseUri?: string
|
||||
payloadSecret: string
|
||||
projectDir: string
|
||||
template?: ProjectTemplate
|
||||
@@ -77,70 +105,63 @@ export async function manageEnvFiles(args: {
|
||||
return
|
||||
}
|
||||
|
||||
const envExamplePath = path.join(projectDir, '.env.example')
|
||||
const pathToEnvExample = path.join(projectDir, '.env.example')
|
||||
const envPath = path.join(projectDir, '.env')
|
||||
const emptyEnvContent = `# Added by Payload\nDATABASE_URI=your-connection-string-here\nPAYLOAD_SECRET=YOUR_SECRET_HERE\n`
|
||||
try {
|
||||
let updatedExampleContents: string
|
||||
|
||||
let exampleEnv: null | string = ''
|
||||
|
||||
try {
|
||||
if (template?.type === 'plugin') {
|
||||
if (debugFlag) {
|
||||
debug(`plugin template detected - no .env added .env.example added`)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!fs.existsSync(envExamplePath)) {
|
||||
updatedExampleContents = updateEnvExampleVariables(
|
||||
emptyEnvContent,
|
||||
databaseType,
|
||||
payloadSecret,
|
||||
databaseUri,
|
||||
)
|
||||
// If there's a .env.example file, use it to create or update the .env file
|
||||
if (fs.existsSync(pathToEnvExample)) {
|
||||
const envExampleContents = await fs.readFile(pathToEnvExample, 'utf8')
|
||||
|
||||
await fs.writeFile(envExamplePath, updatedExampleContents)
|
||||
if (debugFlag) {
|
||||
debug(`.env.example file successfully created`)
|
||||
}
|
||||
} else {
|
||||
const envExampleContents = await fs.readFile(envExamplePath, 'utf8')
|
||||
const mergedEnvs = envExampleContents + '\n' + emptyEnvContent
|
||||
updatedExampleContents = updateEnvExampleVariables(
|
||||
mergedEnvs,
|
||||
exampleEnv = sanitizeEnv({
|
||||
contents: envExampleContents,
|
||||
databaseType,
|
||||
payloadSecret,
|
||||
databaseUri,
|
||||
)
|
||||
payloadSecret,
|
||||
})
|
||||
|
||||
await fs.writeFile(envExamplePath, updatedExampleContents)
|
||||
if (debugFlag) {
|
||||
debug(`.env.example file successfully updated`)
|
||||
debug(`.env.example file successfully read`)
|
||||
}
|
||||
}
|
||||
|
||||
// If there's no .env file, create it using the .env.example content (if it exists)
|
||||
if (!fs.existsSync(envPath)) {
|
||||
const envContent = updateEnvExampleVariables(
|
||||
emptyEnvContent,
|
||||
const envContent = sanitizeEnv({
|
||||
contents: exampleEnv,
|
||||
databaseType,
|
||||
payloadSecret,
|
||||
databaseUri,
|
||||
)
|
||||
payloadSecret,
|
||||
})
|
||||
|
||||
await fs.writeFile(envPath, envContent)
|
||||
|
||||
if (debugFlag) {
|
||||
debug(`.env file successfully created`)
|
||||
}
|
||||
} else {
|
||||
// If the .env file already exists, sanitize it as-is
|
||||
const envContents = await fs.readFile(envPath, 'utf8')
|
||||
const mergedEnvs = envContents + '\n' + emptyEnvContent
|
||||
const updatedEnvContents = updateEnvExampleVariables(
|
||||
mergedEnvs,
|
||||
|
||||
const updatedEnvContents = sanitizeEnv({
|
||||
contents: envContents,
|
||||
databaseType,
|
||||
payloadSecret,
|
||||
databaseUri,
|
||||
)
|
||||
payloadSecret,
|
||||
})
|
||||
|
||||
await fs.writeFile(envPath, updatedEnvContents)
|
||||
|
||||
if (debugFlag) {
|
||||
debug(`.env file successfully updated`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user