diff --git a/packages/cli-helpers/package.json b/packages/cli-helpers/package.json index 8ebb15ecc338..f48fec629930 100644 --- a/packages/cli-helpers/package.json +++ b/packages/cli-helpers/package.json @@ -24,11 +24,13 @@ "dependencies": { "@babel/core": "^7.22.20", "@babel/runtime-corejs3": "7.23.1", + "@iarna/toml": "2.2.5", "@opentelemetry/api": "1.4.1", "@redwoodjs/project-config": "6.0.7", "@redwoodjs/telemetry": "6.0.7", "chalk": "4.1.2", "core-js": "3.32.2", + "dotenv": "16.3.1", "execa": "5.1.1", "listr2": "6.6.1", "lodash": "4.17.21", diff --git a/packages/cli-helpers/src/lib/__tests__/__snapshots__/project.test.ts.snap b/packages/cli-helpers/src/lib/__tests__/__snapshots__/project.test.ts.snap new file mode 100644 index 000000000000..6da535eaf61e --- /dev/null +++ b/packages/cli-helpers/src/lib/__tests__/__snapshots__/project.test.ts.snap @@ -0,0 +1,149 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`addEnvVar addEnvVar adds environment variables as part of a setup task should add a comment that the existing environment variable value was not changed, but include its new value as a comment 1`] = ` +"EXISTING_VAR=value +# CommentedVar=123 + +# Note: The existing environment variable EXISTING_VAR was not overwritten. Uncomment to use its new value. +# Updated existing variable Comment +# EXISTING_VAR = new_value +" +`; + +exports[`addEnvVar addEnvVar adds environment variables as part of a setup task should add a new environment variable when it does not exist 1`] = ` +"EXISTING_VAR = value +# CommentedVar = 123 + +# New Variable Comment +NEW_VAR = new_value +" +`; + +exports[`addEnvVar addEnvVar adds environment variables as part of a setup task should add a new environment variable when it does not exist when existing envars have no spacing 1`] = ` +"EXISTING_VAR=value +# CommentedVar = 123 + +# New Variable Comment +NEW_VAR = new_value +" +`; + +exports[`addEnvVar addEnvVar adds environment variables as part of a setup task should handle existing environment variables and new value with quoted values by not updating the original value 1`] = ` +"EXISTING_VAR = "value" +# CommentedVar = 123 + +# Note: The existing environment variable EXISTING_VAR was not overwritten. Uncomment to use its new value. +# New Variable Comment +# EXISTING_VAR = new_value +" +`; + +exports[`addEnvVar addEnvVar adds environment variables as part of a setup task should handle existing environment variables with quoted values 1`] = ` +"EXISTING_VAR = "value" +# CommentedVar = 123 +" +`; + +exports[`addEnvVar addEnvVar adds environment variables as part of a setup task should handle existing environment variables with quoted values and no spacing 1`] = ` +"EXISTING_VAR="value" +# CommentedVar=123 +" +`; + +exports[`updateTomlConfig updateTomlConfig configures a new CLI plugin adds package but keeps autoInstall false 1`] = ` +"[web] +title = "Redwood App" +port = 8_910 +apiUrl = "/.redwood/functions" +includeEnvironmentVariables = [ ] + +[api] +port = 8_911 + +[experimental.cli] +autoInstall = false + +[[experimental.cli.plugins]] +package = "@example/test-package-when-autoInstall-false" +enabled = true +" +`; + +exports[`updateTomlConfig updateTomlConfig configures a new CLI plugin adds when experimental cli has some plugins configured 1`] = ` +"[web] +title = "Redwood App" +port = 8_910 +apiUrl = "/.redwood/functions" +includeEnvironmentVariables = [ ] + +[api] +port = 8_911 + +[experimental.cli] +autoInstall = true + + [[experimental.cli.plugins]] + package = "@existing-example/some-package-when-cli-has-some-packages-configured" + +[[experimental.cli.plugins]] +package = "@example/test-package-name" +enabled = true +" +`; + +exports[`updateTomlConfig updateTomlConfig configures a new CLI plugin adds when experimental cli is not configured 1`] = ` +"[web] +title = "Redwood App" +port = 8_910 +apiUrl = "/.redwood/functions" +includeEnvironmentVariables = [ ] + +[api] +port = 8_911 + +[experimental.cli] +autoInstall = true + + [[experimental.cli.plugins]] + package = "@example/test-package-when-cli-not-configured" + enabled = true +" +`; + +exports[`updateTomlConfig updateTomlConfig configures a new CLI plugin adds when experimental cli is setup but has no plugins configured 1`] = ` +"[web] +title = "Redwood App" +port = 8_910 +apiUrl = "/.redwood/functions" +includeEnvironmentVariables = [ ] + +[api] +port = 8_911 + +[experimental.cli] +autoInstall = true + +[[experimental.cli.plugins]] +package = "@example/test-package-when-no-plugins-configured" +enabled = true +" +`; + +exports[`updateTomlConfig updateTomlConfig configures a new CLI plugin does not add duplicate place when experimental cli has that plugin configured 1`] = ` +"[web] +title = "Redwood App" +port = 8_910 +apiUrl = "/.redwood/functions" +includeEnvironmentVariables = [ ] + +[api] +port = 8_911 + +[experimental.cli] +autoInstall = true + + [[experimental.cli.plugins]] + package = "@existing-example/some-package-name-already-exists" + +" +`; diff --git a/packages/cli-helpers/src/lib/__tests__/project.test.ts b/packages/cli-helpers/src/lib/__tests__/project.test.ts new file mode 100644 index 000000000000..3aef810bc9bc --- /dev/null +++ b/packages/cli-helpers/src/lib/__tests__/project.test.ts @@ -0,0 +1,214 @@ +import fs from 'fs' + +import toml from '@iarna/toml' + +import { updateTomlConfig, addEnvVar } from '../project' // Replace with the correct path to your module + +jest.mock('fs') + +const defaultRedwoodToml = { + web: { + title: 'Redwood App', + port: 8910, + apiUrl: '/.redwood/functions', + includeEnvironmentVariables: [], + }, + api: { + port: 8911, + }, +} + +const getRedwoodToml = () => { + return defaultRedwoodToml +} + +jest.mock('@redwoodjs/project-config', () => { + return { + getPaths: () => { + return { + generated: { + base: '.redwood', + }, + base: '', + } + }, + getConfigPath: () => { + return '.redwood.toml' + }, + getConfig: () => { + return getRedwoodToml() + }, + } +}) + +describe('addEnvVar', () => { + let envFileContent = '' + + describe('addEnvVar adds environment variables as part of a setup task', () => { + beforeEach(() => { + jest.spyOn(fs, 'existsSync').mockImplementation(() => { + return true + }) + + jest.spyOn(fs, 'readFileSync').mockImplementation(() => { + return envFileContent + }) + + jest.spyOn(fs, 'writeFileSync').mockImplementation((envPath, envFile) => { + expect(envPath).toContain('.env') + return envFile + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + envFileContent = '' + }) + + it('should add a new environment variable when it does not exist', () => { + envFileContent = 'EXISTING_VAR = value\n# CommentedVar = 123\n' + const file = addEnvVar('NEW_VAR', 'new_value', 'New Variable Comment') + + expect(file).toMatchSnapshot() + }) + + it('should add a new environment variable when it does not exist when existing envars have no spacing', () => { + envFileContent = 'EXISTING_VAR=value\n# CommentedVar = 123\n' + const file = addEnvVar('NEW_VAR', 'new_value', 'New Variable Comment') + + expect(file).toMatchSnapshot() + }) + + it('should add a comment that the existing environment variable value was not changed, but include its new value as a comment', () => { + envFileContent = 'EXISTING_VAR=value\n# CommentedVar=123\n' + const file = addEnvVar( + 'EXISTING_VAR', + 'new_value', + 'Updated existing variable Comment' + ) + + expect(file).toMatchSnapshot() + }) + + it('should handle existing environment variables with quoted values', () => { + envFileContent = `EXISTING_VAR = "value"\n# CommentedVar = 123\n` + const file = addEnvVar('EXISTING_VAR', 'value', 'New Variable Comment') + + expect(file).toMatchSnapshot() + }) + + it('should handle existing environment variables with quoted values and no spacing', () => { + envFileContent = `EXISTING_VAR="value"\n# CommentedVar=123\n` + const file = addEnvVar('EXISTING_VAR', 'value', 'New Variable Comment') + + expect(file).toMatchSnapshot() + }) + + it('should handle existing environment variables and new value with quoted values by not updating the original value', () => { + envFileContent = `EXISTING_VAR = "value"\n# CommentedVar = 123\n` + const file = addEnvVar( + 'EXISTING_VAR', + 'new_value', + 'New Variable Comment' + ) + + expect(file).toMatchSnapshot() + }) + }) +}) + +describe('updateTomlConfig', () => { + describe('updateTomlConfig configures a new CLI plugin', () => { + beforeEach(() => { + jest.spyOn(fs, 'existsSync').mockImplementation(() => { + return true + }) + + jest.spyOn(fs, 'readFileSync').mockImplementation(() => { + return toml.stringify(defaultRedwoodToml) + }) + + jest + .spyOn(fs, 'writeFileSync') + .mockImplementation((tomlPath, tomlFile) => { + expect(tomlPath).toContain('redwood.toml') + return tomlFile + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('adds when experimental cli is not configured', () => { + const file = updateTomlConfig( + '@example/test-package-when-cli-not-configured' + ) + expect(file).toMatchSnapshot() + }) + + it('adds when experimental cli has some plugins configured', () => { + defaultRedwoodToml['experimental'] = { + cli: { + autoInstall: true, + plugins: [ + { + package: + '@existing-example/some-package-when-cli-has-some-packages-configured', + }, + ], + }, + } + + const file = updateTomlConfig('@example/test-package-name') + expect(file).toMatchSnapshot() + }) + + it('adds when experimental cli is setup but has no plugins configured', () => { + defaultRedwoodToml['experimental'] = { + cli: { + autoInstall: true, + }, + } + + const file = updateTomlConfig( + '@example/test-package-when-no-plugins-configured' + ) + + expect(file).toMatchSnapshot() + }) + + it('adds package but keeps autoInstall false', () => { + defaultRedwoodToml['experimental'] = { + cli: { + autoInstall: false, + }, + } + + const file = updateTomlConfig( + '@example/test-package-when-autoInstall-false' + ) + + expect(file).toMatchSnapshot() + }) + + it('does not add duplicate place when experimental cli has that plugin configured', () => { + defaultRedwoodToml['experimental'] = { + cli: { + autoInstall: true, + plugins: [ + { + package: '@existing-example/some-package-name-already-exists', + }, + ], + }, + } + + const file = updateTomlConfig( + '@existing-example/some-package-name-already-exists' + ) + + expect(file).toMatchSnapshot() + }) + }) +}) diff --git a/packages/cli-helpers/src/lib/project.ts b/packages/cli-helpers/src/lib/project.ts index 1e0c6fe618dd..7755671f2aad 100644 --- a/packages/cli-helpers/src/lib/project.ts +++ b/packages/cli-helpers/src/lib/project.ts @@ -1,7 +1,16 @@ import fs from 'fs' import path from 'path' -import { resolveFile, findUp } from '@redwoodjs/project-config' +import type { JsonMap } from '@iarna/toml' +import toml from '@iarna/toml' +import dotenv from 'dotenv' + +import { + findUp, + getConfigPath, + getConfig, + resolveFile, +} from '@redwoodjs/project-config' import { colors } from './colors' import { getPaths } from './paths' @@ -33,21 +42,117 @@ export const getInstalledRedwoodVersion = () => { } } +/** + * Updates the project's redwood.toml file to include the specified packages plugin + * + * Uses toml parsing to determine if the plugin is already included in the file and + * only adds it if it is not. + * + * Writes the updated config to the file system by appending strings, not stringify-ing the toml. + */ +export const updateTomlConfig = (packageName: string) => { + const redwoodTomlPath = getConfigPath() + const originalTomlContent = fs.readFileSync(redwoodTomlPath, 'utf-8') + + let tomlToAppend = {} as JsonMap + + const config = getConfig(redwoodTomlPath) + + const cliSection = config.experimental?.cli + + if (!cliSection) { + tomlToAppend = { + experimental: { + cli: { + autoInstall: true, + plugins: [{ package: packageName, enabled: true }], + }, + }, + } + } else if (cliSection.plugins) { + const packageExists = cliSection.plugins.some( + (plugin) => plugin.package === packageName + ) + + if (!packageExists) { + tomlToAppend = { + experimental: { + cli: { + plugins: [{ package: packageName, enabled: true }], + }, + }, + } + } + } else { + tomlToAppend = { + experimental: { + cli: { + plugins: [{ package: packageName, enabled: true }], + }, + }, + } + } + + const newConfig = originalTomlContent + '\n' + toml.stringify(tomlToAppend) + + return fs.writeFileSync(redwoodTomlPath, newConfig, 'utf-8') +} + +export const updateTomlConfigTask = (packageName: string) => { + return { + title: `Updating redwood.toml to configure ${packageName} ...`, + task: () => { + updateTomlConfig(packageName) + }, + } +} + export const addEnvVarTask = (name: string, value: string, comment: string) => { return { title: `Adding ${name} var to .env...`, task: () => { - const envPath = path.join(getPaths().base, '.env') - const content = [comment && `# ${comment}`, `${name}=${value}`, ''].flat() - let envFile = '' + addEnvVar(name, value, comment) + }, + } +} - if (fs.existsSync(envPath)) { - envFile = fs.readFileSync(envPath).toString() + '\n' - } +export const addEnvVar = (name: string, value: string, comment: string) => { + const envPath = path.join(getPaths().base, '.env') + let envFile = '' + const newEnvironmentVariable = [ + comment && `# ${comment}`, + `${name} = ${value}`, + '', + ] + .flat() + .join('\n') - fs.writeFileSync(envPath, envFile + content.join('\n')) - }, + if (fs.existsSync(envPath)) { + envFile = fs.readFileSync(envPath).toString() + const existingEnvVars = dotenv.parse(envFile) + + if (existingEnvVars[name] && existingEnvVars[name] === value) { + return envFile + } + + if (existingEnvVars[name]) { + const p = [ + `# Note: The existing environment variable ${name} was not overwritten. Uncomment to use its new value.`, + comment && `# ${comment}`, + `# ${name} = ${value}`, + '', + ] + .flat() + .join('\n') + envFile += '\n' + p + } else { + envFile += '\n' + newEnvironmentVariable + } + } else { + envFile = newEnvironmentVariable } + + return fs.writeFileSync(envPath, envFile) } /** diff --git a/yarn.lock b/yarn.lock index 601bcc027e52..d512c826da04 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8392,6 +8392,7 @@ __metadata: "@babel/cli": 7.23.0 "@babel/core": ^7.22.20 "@babel/runtime-corejs3": 7.23.1 + "@iarna/toml": 2.2.5 "@opentelemetry/api": 1.4.1 "@redwoodjs/project-config": 6.0.7 "@redwoodjs/telemetry": 6.0.7 @@ -8400,6 +8401,7 @@ __metadata: "@types/yargs": 17.0.24 chalk: 4.1.2 core-js: 3.32.2 + dotenv: 16.3.1 execa: 5.1.1 jest: 29.7.0 listr2: 6.6.1