From 4508128d524178355e8c318c2b85be49f035a699 Mon Sep 17 00:00:00 2001 From: Julien Ripouteau Date: Sat, 20 Apr 2024 13:43:17 +0200 Subject: [PATCH 1/4] feat: add env:add command --- commands/env/add.ts | 118 +++++++++++++++++++++++++++++++++ tests/commands/env_add.spec.ts | 116 ++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 commands/env/add.ts create mode 100644 tests/commands/env_add.spec.ts 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()') + }) +}) From 5a1a4e9356c2fbc9676b7fc6ebacba56a6bfaefc Mon Sep 17 00:00:00 2001 From: Julien Ripouteau Date: Mon, 29 Apr 2024 12:02:12 +0200 Subject: [PATCH 2/4] feat: insert empty example in the .env.example --- commands/env/add.ts | 8 +++++--- modules/ace/codemods.ts | 7 +++++-- package.json | 2 +- tests/ace/codemods.spec.ts | 17 +++++++++++++++++ tests/commands/env_add.spec.ts | 8 ++++---- 5 files changed, 32 insertions(+), 10 deletions(-) diff --git a/commands/env/add.ts b/commands/env/add.ts index c05553c1..840d7da4 100644 --- a/commands/env/add.ts +++ b/commands/env/add.ts @@ -8,8 +8,8 @@ */ import { CommandOptions } from '../../types/ace.js' -import { args, BaseCommand, flags } from '../../modules/ace/main.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] @@ -98,8 +98,10 @@ export default class EnvAdd extends BaseCommand { */ const codemods = await this.createCodemods() const transformedName = stringHelpers.snakeCase(this.name).toUpperCase() - - await codemods.defineEnvVariables({ [transformedName]: this.value }) + await codemods.defineEnvVariables( + { [transformedName]: this.value }, + { withEmptyExampleValue: true } + ) /** * Add the environment variable to the `start/env.ts` file diff --git a/modules/ace/codemods.ts b/modules/ace/codemods.ts index 6d1cd6c0..f07ea6d9 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: Record, + options?: { withEmptyExampleValue?: boolean } + ) { 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?.withEmptyExampleValue) }) 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..6ed79431 100644 --- a/tests/ace/codemods.spec.ts +++ b/tests/ace/codemods.spec.ts @@ -66,6 +66,23 @@ 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' }, { withEmptyExampleValue: true }) + 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/env_add.spec.ts b/tests/commands/env_add.spec.ts index 23d0d731..d6a64635 100644 --- a/tests/commands/env_add.spec.ts +++ b/tests/commands/env_add.spec.ts @@ -30,7 +30,7 @@ test.group('Env Add command', () => { await command.exec() await assert.fileContains('.env', 'VARIABLE=value') - await assert.fileContains('.env.example', 'VARIABLE=value') + await assert.fileContains('.env.example', 'VARIABLE=') await assert.fileContains('./start/env.ts', 'VARIABLE: Env.schema.string()') }) @@ -52,7 +52,7 @@ test.group('Env Add command', () => { await command.exec() await assert.fileContains('.env', 'STRIPE_API_KEY=value') - await assert.fileContains('.env.example', 'STRIPE_API_KEY=value') + await assert.fileContains('.env.example', 'STRIPE_API_KEY=') await assert.fileContains('./start/env.ts', 'STRIPE_API_KEY: Env.schema.string()') }) @@ -80,7 +80,7 @@ test.group('Env Add command', () => { await command.exec() await assert.fileContains('.env', 'VARIABLE=bar') - await assert.fileContains('.env.example', 'VARIABLE=bar') + await assert.fileContains('.env.example', 'VARIABLE=') await assert.fileContains( './start/env.ts', "VARIABLE: Env.schema.enum(['foo', 'bar'] as const)" @@ -110,7 +110,7 @@ test.group('Env Add command', () => { 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('.env.example', 'MY_VARIABLE_NAME=') await assert.fileContains('./start/env.ts', 'MY_VARIABLE_NAME: Env.schema.string()') }) }) From 9cd71839a0be0b4198f050e8e11c3e4a93c82cd7 Mon Sep 17 00:00:00 2001 From: Julien Ripouteau Date: Mon, 29 Apr 2024 12:12:43 +0200 Subject: [PATCH 3/4] refactor: defineEnvVariables with omitFromExample --- commands/env/add.ts | 2 +- modules/ace/codemods.ts | 8 ++++---- tests/ace/codemods.spec.ts | 5 ++++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/commands/env/add.ts b/commands/env/add.ts index 840d7da4..c70d60c2 100644 --- a/commands/env/add.ts +++ b/commands/env/add.ts @@ -100,7 +100,7 @@ export default class EnvAdd extends BaseCommand { const transformedName = stringHelpers.snakeCase(this.name).toUpperCase() await codemods.defineEnvVariables( { [transformedName]: this.value }, - { withEmptyExampleValue: true } + { omitFromExample: [transformedName] } ) /** diff --git a/modules/ace/codemods.ts b/modules/ace/codemods.ts index f07ea6d9..08b83164 100644 --- a/modules/ace/codemods.ts +++ b/modules/ace/codemods.ts @@ -101,16 +101,16 @@ export class Codemods extends EventEmitter { /** * Define one or more environment variables */ - async defineEnvVariables( - environmentVariables: Record, - options?: { withEmptyExampleValue?: boolean } + 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, options?.withEmptyExampleValue) + editor.add(key, value, options?.omitFromExample?.includes(key)) }) await editor.save() diff --git a/tests/ace/codemods.spec.ts b/tests/ace/codemods.spec.ts index 6ed79431..1aae37d9 100644 --- a/tests/ace/codemods.spec.ts +++ b/tests/ace/codemods.spec.ts @@ -78,7 +78,10 @@ test.group('Codemods | environment variables', (group) => { await fs.create('.env.example', '') const codemods = new Codemods(ace.app, ace.ui.logger) - await codemods.defineEnvVariables({ SECRET_VALUE: 'secret' }, { withEmptyExampleValue: true }) + await codemods.defineEnvVariables( + { SECRET_VALUE: 'secret' }, + { omitFromExample: ['SECRET_VALUE'] } + ) await assert.fileContains('.env', 'SECRET_VALUE=secret') await assert.fileContains('.env.example', 'SECRET_VALUE=') }) From d80d7bfe5cc6bc4d9b3ab9b547f52b7daa88c0dc Mon Sep 17 00:00:00 2001 From: Julien Ripouteau Date: Mon, 29 Apr 2024 12:52:28 +0200 Subject: [PATCH 4/4] fix: fix breaking test --- tests/commands/add.spec.ts | 2 +- tests/commands/configure.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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.' ) }) })