diff --git a/commands/env/add.ts b/commands/env/add.ts new file mode 100644 index 00000000..c70d60c2 --- /dev/null +++ b/commands/env/add.ts @@ -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') + } +} diff --git a/modules/ace/codemods.ts b/modules/ace/codemods.ts index 6d1cd6c0..08b83164 100644 --- a/modules/ace/codemods.ts +++ b/modules/ace/codemods.ts @@ -101,13 +101,16 @@ export class Codemods extends EventEmitter { /** * Define one or more environment variables */ - async defineEnvVariables(environmentVariables: Record) { + async defineEnvVariables>( + environmentVariables: T, + options?: { omitFromExample?: Array } + ) { 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() diff --git a/package.json b/package.json index c85c0ed8..93c8869a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/ace/codemods.spec.ts b/tests/ace/codemods.spec.ts index 1faade0f..1aae37d9 100644 --- a/tests/ace/codemods.spec.ts +++ b/tests/ace/codemods.spec.ts @@ -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() diff --git a/tests/commands/add.spec.ts b/tests/commands/add.spec.ts index ff4d069b..e6621d34 100644 --- a/tests/commands/add.spec.ts +++ b/tests/commands/add.spec.ts @@ -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 }) => { diff --git a/tests/commands/configure.spec.ts b/tests/commands/configure.spec.ts index 5c2c7e0b..28cb8578 100644 --- a/tests/commands/configure.spec.ts +++ b/tests/commands/configure.spec.ts @@ -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.' ) }) }) diff --git a/tests/commands/env_add.spec.ts b/tests/commands/env_add.spec.ts new file mode 100644 index 00000000..d6a64635 --- /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=') + 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()') + }) +})