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:
Jacob Fletcher
2025-05-30 14:26:57 -04:00
committed by GitHub
parent 7c094dc572
commit 836fd86090
4 changed files with 237 additions and 124 deletions

View File

@@ -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')
})
})
})
})

View File

@@ -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')

View 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`,
)
})
})

View File

@@ -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`)
}