diff --git a/commands/env/add.ts b/commands/env/add.ts new file mode 100644 index 00000000..c05553c1 --- /dev/null +++ b/commands/env/add.ts @@ -0,0 +1,118 @@ +/* + * @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 { args, BaseCommand, flags } from '../../modules/ace/main.js' +import stringHelpers from '../../src/helpers/string.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 }) + + /** + * 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') + } +} diff --git a/tests/commands/env_add.spec.ts b/tests/commands/env_add.spec.ts new file mode 100644 index 00000000..23d0d731 --- /dev/null +++ b/tests/commands/env_add.spec.ts @@ -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=value') + 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=value') + 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=bar') + 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=my_value') + await assert.fileContains('./start/env.ts', 'MY_VARIABLE_NAME: Env.schema.string()') + }) +})