Skip to content

Commit

Permalink
Merge pull request #4533 from adonisjs/feat/set-env
Browse files Browse the repository at this point in the history
feat: add env:add command
  • Loading branch information
thetutlage committed Apr 30, 2024
2 parents b1e4756 + d80d7bf commit c8251b5
Show file tree
Hide file tree
Showing 7 changed files with 264 additions and 5 deletions.
120 changes: 120 additions & 0 deletions commands/env/add.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* @adonisjs/core
*
* (c) AdonisJS
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import { CommandOptions } from '../../types/ace.js'
import stringHelpers from '../../src/helpers/string.js'
import { args, BaseCommand, flags } from '../../modules/ace/main.js'

const ALLOWED_TYPES = ['string', 'boolean', 'number', 'enum'] as const
type AllowedTypes = (typeof ALLOWED_TYPES)[number]

/**
* The env:add command is used to add a new environment variable to the
* `.env`, `.env.example` and `start/env.ts` files.
*/
export default class EnvAdd extends BaseCommand {
static commandName = 'env:add'
static description = 'Add a new environment variable'
static options: CommandOptions = {
allowUnknownFlags: true,
}

@args.string({
description: 'Variable name. Will be converted to screaming snake case',
required: false,
})
declare name: string

@args.string({ description: 'Variable value', required: false })
declare value: string

@flags.string({ description: 'Type of the variable' })
declare type: AllowedTypes

@flags.array({
description: 'Allowed values for the enum type in a comma-separated list',
default: [''],
required: false,
})
declare enumValues: string[]

/**
* Validate the type flag passed by the user
*/
#isTypeFlagValid() {
return ALLOWED_TYPES.includes(this.type)
}

async run() {
/**
* Prompt for missing name
*/
if (!this.name) {
this.name = await this.prompt.ask('Enter the variable name', {
validate: (value) => !!value,
format: (value) => stringHelpers.snakeCase(value).toUpperCase(),
})
}

/**
* Prompt for missing value
*/
if (!this.value) {
this.value = await this.prompt.ask('Enter the variable value')
}

/**
* Prompt for missing type
*/
if (!this.type) {
this.type = await this.prompt.choice('Select the variable type', ALLOWED_TYPES)
}

/**
* Prompt for missing enum values if the selected env type is `enum`
*/
if (this.type === 'enum' && !this.enumValues) {
this.enumValues = await this.prompt.ask('Enter the enum values separated by a comma', {
result: (value) => value.split(',').map((one) => one.trim()),
})
}

/**
* Validate inputs
*/
if (!this.#isTypeFlagValid()) {
this.logger.error(`Invalid type "${this.type}". Must be one of ${ALLOWED_TYPES.join(', ')}`)
return
}

/**
* Add the environment variable to the `.env` and `.env.example` files
*/
const codemods = await this.createCodemods()
const transformedName = stringHelpers.snakeCase(this.name).toUpperCase()
await codemods.defineEnvVariables(
{ [transformedName]: this.value },
{ omitFromExample: [transformedName] }
)

/**
* Add the environment variable to the `start/env.ts` file
*/
const validation = {
string: 'Env.schema.string()',
number: 'Env.schema.number()',
boolean: 'Env.schema.boolean()',
enum: `Env.schema.enum(['${this.enumValues.join("','")}'] as const)`,
}[this.type]

await codemods.defineEnvValidations({ variables: { [transformedName]: validation } })

this.logger.success('Environment variable added successfully')
}
}
7 changes: 5 additions & 2 deletions modules/ace/codemods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,16 @@ export class Codemods extends EventEmitter {
/**
* Define one or more environment variables
*/
async defineEnvVariables(environmentVariables: Record<string, number | string | boolean>) {
async defineEnvVariables<T extends Record<string, number | string | boolean>>(
environmentVariables: T,
options?: { omitFromExample?: Array<keyof T> }
) {
const editor = new EnvEditor(this.#app.appRoot)
await editor.load()

Object.keys(environmentVariables).forEach((key) => {
const value = environmentVariables[key]
editor.add(key, value)
editor.add(key, value, options?.omitFromExample?.includes(key))
})

await editor.save()
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@
"@adonisjs/bodyparser": "^10.0.2",
"@adonisjs/config": "^5.0.2",
"@adonisjs/encryption": "^6.0.2",
"@adonisjs/env": "^6.0.1",
"@adonisjs/env": "^6.1.0",
"@adonisjs/events": "^9.0.2",
"@adonisjs/fold": "^10.1.2",
"@adonisjs/hash": "^9.0.3",
Expand Down
20 changes: 20 additions & 0 deletions tests/ace/codemods.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,26 @@ test.group('Codemods | environment variables', (group) => {
await assert.fileContains('.env', 'CORS_ENABLED=true')
})

test('do not insert env value in .env.example if specified', async ({ assert, fs }) => {
const ace = await new AceFactory().make(fs.baseUrl)
await ace.app.init()
ace.ui.switchMode('raw')

/**
* Creating .env file so that we can update it.
*/
await fs.create('.env', '')
await fs.create('.env.example', '')

const codemods = new Codemods(ace.app, ace.ui.logger)
await codemods.defineEnvVariables(
{ SECRET_VALUE: 'secret' },
{ omitFromExample: ['SECRET_VALUE'] }
)
await assert.fileContains('.env', 'SECRET_VALUE=secret')
await assert.fileContains('.env.example', 'SECRET_VALUE=')
})

test('do not define env variables when file does not exists', async ({ assert, fs }) => {
const ace = await new AceFactory().make(fs.baseUrl)
await ace.app.init()
Expand Down
2 changes: 1 addition & 1 deletion tests/commands/add.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ test.group('Install', (group) => {
await command.exec()

command.assertExitCode(1)
command.assertLogMatches(/Command failed with exit code 1/)
command.assertLogMatches(/pnpm install.*inexistent exited/)
})

test('display error if configure fail', async ({ fs }) => {
Expand Down
2 changes: 1 addition & 1 deletion tests/commands/configure.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ test.group('Configure command | run', (group) => {
assert.equal(command.exitCode, 1)
assert.deepInclude(
lastLog.message,
'[ red(error) ] Command failed with exit code 1: npm install -D is-odd@15.0.0'
'[ red(error) ] npm install -D is-odd@15.0.0 exited with a status of 1.'
)
})
})
Expand Down
116 changes: 116 additions & 0 deletions tests/commands/env_add.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* @adonisjs/core
*
* (c) AdonisJS
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import { test } from '@japa/runner'
import EnvAdd from '../../commands/env/add.js'
import { AceFactory } from '../../factories/core/ace.js'

test.group('Env Add command', () => {
test('add new env variable to the different files', async ({ assert, fs }) => {
await fs.createJson('tsconfig.json', {})
await fs.create('.env', '')
await fs.create('.env.example', '')
await fs.create(
'./start/env.ts',
`import { Env } from '@adonisjs/core/env'
export default await Env.create(new URL('../', import.meta.url), {})`
)

const ace = await new AceFactory().make(fs.baseUrl)
await ace.app.init()
ace.ui.switchMode('raw')

const command = await ace.create(EnvAdd, ['variable', 'value', '--type=string'])
await command.exec()

await assert.fileContains('.env', 'VARIABLE=value')
await assert.fileContains('.env.example', 'VARIABLE=')
await assert.fileContains('./start/env.ts', 'VARIABLE: Env.schema.string()')
})

test('convert variable to screaming snake case', async ({ assert, fs }) => {
await fs.createJson('tsconfig.json', {})
await fs.create('.env', '')
await fs.create('.env.example', '')
await fs.create(
'./start/env.ts',
`import { Env } from '@adonisjs/core/env'
export default await Env.create(new URL('../', import.meta.url), {})`
)

const ace = await new AceFactory().make(fs.baseUrl)
await ace.app.init()
ace.ui.switchMode('raw')

const command = await ace.create(EnvAdd, ['stripe_ApiKey', 'value', '--type=string'])
await command.exec()

await assert.fileContains('.env', 'STRIPE_API_KEY=value')
await assert.fileContains('.env.example', 'STRIPE_API_KEY=')
await assert.fileContains('./start/env.ts', 'STRIPE_API_KEY: Env.schema.string()')
})

test('enum type with allowed values', async ({ assert, fs }) => {
await fs.createJson('tsconfig.json', {})
await fs.create('.env', '')
await fs.create('.env.example', '')
await fs.create(
'./start/env.ts',
`import { Env } from '@adonisjs/core/env'
export default await Env.create(new URL('../', import.meta.url), {})`
)

const ace = await new AceFactory().make(fs.baseUrl)
await ace.app.init()
ace.ui.switchMode('raw')

const command = await ace.create(EnvAdd, [
'variable',
'bar',
'--type=enum',
'--enum-values=foo',
'--enum-values=bar',
])
await command.exec()

await assert.fileContains('.env', 'VARIABLE=bar')
await assert.fileContains('.env.example', 'VARIABLE=')
await assert.fileContains(
'./start/env.ts',
"VARIABLE: Env.schema.enum(['foo', 'bar'] as const)"
)
})

test('prompt when nothing is passed to the command', async ({ assert, fs }) => {
await fs.createJson('tsconfig.json', {})
await fs.create('.env', '')
await fs.create('.env.example', '')
await fs.create(
'./start/env.ts',
`import { Env } from '@adonisjs/core/env'
export default await Env.create(new URL('../', import.meta.url), {})`
)

const ace = await new AceFactory().make(fs.baseUrl)
await ace.app.init()
ace.ui.switchMode('raw')

const command = await ace.create(EnvAdd, [])

command.prompt.trap('Enter the variable name').replyWith('my_variable_name')
command.prompt.trap('Enter the variable value').replyWith('my_value')
command.prompt.trap('Select the variable type').replyWith('string')

await command.exec()

await assert.fileContains('.env', 'MY_VARIABLE_NAME=my_value')
await assert.fileContains('.env.example', 'MY_VARIABLE_NAME=')
await assert.fileContains('./start/env.ts', 'MY_VARIABLE_NAME: Env.schema.string()')
})
})

0 comments on commit c8251b5

Please sign in to comment.