Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add env:add command #4533

Merged
merged 4 commits into from
Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 [email protected]'
'[ red(error) ] npm install -D [email protected] 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()')
})
})
Loading