diff --git a/generators/heroku/__snapshots__/heroku.spec.mts.snap b/generators/heroku/__snapshots__/heroku.spec.mts.snap index 407698558301..da6a3bdcc3ea 100644 --- a/generators/heroku/__snapshots__/heroku.spec.mts.snap +++ b/generators/heroku/__snapshots__/heroku.spec.mts.snap @@ -7,6 +7,7 @@ exports[`generator - Heroku microservice application with JAR deployment should "generator-jhipster": { "applicationType": "microservice", "baseName": "jhipster", + "entities": [], "herokuAppName": "jhipster-test", "herokuDeployType": "jar", "herokuJavaVersion": "17" @@ -160,12 +161,68 @@ server: } `; +exports[`generator - Heroku monolith application in the EU calls should match snapshot 1`] = ` +[ + [ + "spawnCommand", + "heroku --version", + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawnCommand", + "heroku whoami", + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawnCommand", + "heroku plugins", + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawn", + "heroku", + [ + "create", + "jhipster-test", + "--region", + "eu", + ], + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawn", + "heroku", + [ + "addons:create", + "heroku-postgresql", + "--as", + "DATABASE", + "--app", + "jhipster-test", + ], + ], +] +`; + exports[`generator - Heroku monolith application in the EU should match files snapshot 1`] = ` { ".yo-rc.json": { "contents": "{ "generator-jhipster": { "baseName": "jhipster", + "entities": [], "herokuAppName": "jhipster-test", "herokuDeployType": "jar", "herokuJavaVersion": "11" @@ -319,12 +376,66 @@ server: } `; +exports[`generator - Heroku monolith application in the US calls should match snapshot 1`] = ` +[ + [ + "spawnCommand", + "heroku --version", + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawnCommand", + "heroku whoami", + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawnCommand", + "heroku plugins", + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawn", + "heroku", + [ + "create", + "jhipster-test", + ], + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawn", + "heroku", + [ + "addons:create", + "heroku-postgresql", + "--as", + "DATABASE", + "--app", + "jhipster-test", + ], + ], +] +`; + exports[`generator - Heroku monolith application in the US should match files snapshot 1`] = ` { ".yo-rc.json": { "contents": "{ "generator-jhipster": { "baseName": "jhipster", + "entities": [], "herokuAppName": "jhipster-test", "herokuDeployType": "jar", "herokuJavaVersion": "11" @@ -478,12 +589,121 @@ server: } `; +exports[`generator - Heroku monolith application with Git deployment calls should match snapshot 1`] = ` +[ + [ + "spawnCommand", + "heroku --version", + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawnCommand", + "heroku whoami", + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawnCommand", + "heroku plugins", + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawn", + "heroku", + [ + "create", + "jhipster-test", + ], + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawn", + "heroku", + [ + "addons:create", + "heroku-postgresql", + "--as", + "DATABASE", + "--app", + "jhipster-test", + ], + ], + [ + "spawn", + "heroku", + [ + "config:get", + "MAVEN_CUSTOM_OPTS", + "--app", + "jhipster-test", + ], + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawn", + "heroku", + [ + "config:set", + "MAVEN_CUSTOM_OPTS=-Pprod,heroku -DskipTests", + "--app", + "jhipster-test", + ], + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawn", + "heroku", + [ + "buildpacks", + "--app", + "jhipster-test", + ], + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawn", + "heroku", + [ + "buildpacks:add", + "heroku/java", + "--app", + "jhipster-test", + ], + { + "reject": false, + "stdio": "pipe", + }, + ], +] +`; + exports[`generator - Heroku monolith application with Git deployment should match files snapshot 1`] = ` { ".yo-rc.json": { "contents": "{ "generator-jhipster": { "baseName": "jhipster", + "entities": [], "herokuAppName": "jhipster-test", "herokuDeployType": "git", "herokuJavaVersion": "11" @@ -637,12 +857,68 @@ server: } `; +exports[`generator - Heroku monolith application with PostgreSQL calls should match snapshot 1`] = ` +[ + [ + "spawnCommand", + "heroku --version", + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawnCommand", + "heroku whoami", + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawnCommand", + "heroku plugins", + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawn", + "heroku", + [ + "create", + "jhipster-test", + "--region", + "eu", + ], + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawn", + "heroku", + [ + "addons:create", + "heroku-postgresql", + "--as", + "DATABASE", + "--app", + "jhipster-test", + ], + ], +] +`; + exports[`generator - Heroku monolith application with PostgreSQL should match files snapshot 1`] = ` { ".yo-rc.json": { "contents": "{ "generator-jhipster": { "baseName": "jhipster", + "entities": [], "herokuAppName": "jhipster-test", "herokuDeployType": "jar", "herokuJavaVersion": "11" @@ -796,13 +1072,91 @@ server: } `; +exports[`generator - Heroku monolith application with an unavailable app name calls should match snapshot 1`] = ` +[ + [ + "spawnCommand", + "heroku --version", + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawnCommand", + "heroku whoami", + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawnCommand", + "heroku plugins", + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawn", + "heroku", + [ + "create", + "jhipster-test", + ], + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawn", + "heroku", + [ + "create", + ], + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawn", + "heroku", + [ + "git:remote", + "--app", + "random-app-name", + ], + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawn", + "heroku", + [ + "addons:create", + "heroku-postgresql", + "--as", + "DATABASE", + "--app", + "random-app-name", + ], + ], +] +`; + exports[`generator - Heroku monolith application with an unavailable app name should match files snapshot 1`] = ` { ".yo-rc.json": { "contents": "{ "generator-jhipster": { "baseName": "jhipster", - "herokuAppName": "jhipster-new-name", + "entities": [], + "herokuAppName": "random-app-name", "herokuDeployType": "jar", "herokuJavaVersion": "11" } @@ -923,7 +1277,7 @@ exports[`generator - Heroku monolith application with an unavailable app name sh eureka: instance: - hostname: jhipster-new-name.herokuapp.com + hostname: random-app-name.herokuapp.com non-secure-port: 80 prefer-ip-address: false @@ -955,12 +1309,78 @@ server: } `; +exports[`generator - Heroku monolith application with elasticsearch calls should match snapshot 1`] = ` +[ + [ + "spawnCommand", + "heroku --version", + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawnCommand", + "heroku whoami", + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawnCommand", + "heroku plugins", + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawn", + "heroku", + [ + "create", + "jhipster-test", + ], + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawn", + "heroku", + [ + "addons:create", + "bonsai:sandbox-6", + "--as", + "BONSAI", + "--app", + "jhipster-test", + ], + ], + [ + "spawn", + "heroku", + [ + "addons:create", + "heroku-postgresql", + "--as", + "DATABASE", + "--app", + "jhipster-test", + ], + ], +] +`; + exports[`generator - Heroku monolith application with elasticsearch should match files snapshot 1`] = ` { ".yo-rc.json": { "contents": "{ "generator-jhipster": { "baseName": "jhipster", + "entities": [], "herokuAppName": "jhipster-test", "herokuDeployType": "jar", "herokuJavaVersion": "11", @@ -1117,12 +1537,55 @@ server: } `; +exports[`generator - Heroku monolith application with existing app calls should match snapshot 1`] = ` +[ + [ + "spawnCommand", + "heroku --version", + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawnCommand", + "heroku whoami", + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawn", + "heroku", + [ + "apps:info", + "--json", + "jhipster-existing", + ], + { + "reject": false, + "stdio": "pipe", + }, + ], + [ + "spawnCommand", + "heroku plugins", + { + "reject": false, + "stdio": "pipe", + }, + ], +] +`; + exports[`generator - Heroku monolith application with existing app should match files snapshot 1`] = ` { ".yo-rc.json": { "contents": "{ "generator-jhipster": { "baseName": "jhipster", + "entities": [], "herokuAppName": "jhipster-existing", "herokuDeployType": "git", "herokuJavaVersion": "17" diff --git a/generators/heroku/generator.mjs b/generators/heroku/generator.mjs index 3e472fc72f0f..168bf3c8e7e2 100644 --- a/generators/heroku/generator.mjs +++ b/generators/heroku/generator.mjs @@ -17,48 +17,31 @@ * limitations under the License. */ /* eslint-disable consistent-return */ -import crypto from 'crypto'; -import fs from 'fs'; -import ChildProcess from 'child_process'; -import util from 'util'; -import * as _ from 'lodash-es'; +import { kebabCase } from 'lodash-es'; import chalk from 'chalk'; import { glob } from 'glob'; -import runAsync from 'run-async'; -import BaseGenerator from '../base/index.mjs'; +import BaseGenerator from '../base-application/index.mjs'; import statistics from '../statistics.mjs'; -import { CLIENT_MAIN_SRC_DIR, JAVA_COMPATIBLE_VERSIONS, JAVA_VERSION, SERVER_MAIN_RES_DIR } from '../generator-constants.mjs'; -import { GENERATOR_HEROKU } from '../generator-list.mjs'; -import { - authenticationTypes, - buildToolTypes, - cacheTypes, - databaseTypes, - searchEngineTypes, - serviceDiscoveryTypes, -} from '../../jdl/jhipster/index.mjs'; +import { JAVA_COMPATIBLE_VERSIONS, JAVA_VERSION, SERVER_MAIN_RES_DIR } from '../generator-constants.mjs'; +import { GENERATOR_BOOTSTRAP_APPLICATION, GENERATOR_HEROKU } from '../generator-list.mjs'; import { mavenProfileContent } from './templates.mjs'; import { createPomStorage } from '../maven/support/pom-store.mjs'; import { addGradlePluginCallback, applyFromGradleCallback } from '../gradle/internal/needles.mjs'; -import { getFrontendAppName } from '../base/support/index.mjs'; -import { loadAppConfig, loadDerivedAppConfig } from '../app/support/index.mjs'; -import { loadDerivedPlatformConfig, loadDerivedServerConfig, loadPlatformConfig, loadServerConfig } from '../server/support/index.mjs'; -import { loadLanguagesConfig } from '../languages/support/index.mjs'; - -const cacheProviderOptions = cacheTypes; -const { MEMCACHED, REDIS } = cacheTypes; -const { OAUTH2 } = authenticationTypes; -const { GRADLE, MAVEN } = buildToolTypes; -const { ELASTICSEARCH } = searchEngineTypes; -const { MARIADB, MYSQL, POSTGRESQL } = databaseTypes; -const { EUREKA } = serviceDiscoveryTypes; - -const NO_CACHE_PROVIDER = cacheProviderOptions.NO; -const execCmd = util.promisify(ChildProcess.exec); export default class HerokuGenerator extends BaseGenerator { + hasHerokuCli; + + herokuAppName; + herokuDeployType; + herokuJavaVersion; + herokuRegion; + herokuAppExists; + herokuSkipDeploy; + herokuSkipBuild; + dynoSize; + constructor(args, options, features) { super(args, options, features); @@ -78,7 +61,6 @@ export default class HerokuGenerator extends BaseGenerator { return; } - this.randomPassword = crypto.randomBytes(20).toString('hex'); this.herokuSkipBuild = this.options.skipBuild; this.herokuSkipDeploy = this.options.skipDeploy || this.options.skipBuild; } @@ -87,37 +69,43 @@ export default class HerokuGenerator extends BaseGenerator { if (!this.fromBlueprint) { await this.composeWithBlueprints(GENERATOR_HEROKU); } + if (!this.delegateToBlueprint) { + await this.dependsOnJHipster(GENERATOR_BOOTSTRAP_APPLICATION); + } } get initializing() { - return { - loadCommonConfig() { - loadAppConfig({ config: this.jhipsterConfigWithDefaults, application: this, useVersionPlaceholders: this.useVersionPlaceholders }); - loadServerConfig({ config: this.jhipsterConfigWithDefaults, application: this }); - loadLanguagesConfig({ application: this, config: this.jhipsterConfigWithDefaults }); - loadPlatformConfig({ config: this.jhipsterConfigWithDefaults, application: this }); - - loadDerivedAppConfig({ application: this }); - loadDerivedPlatformConfig({ application: this }); - loadDerivedServerConfig({ application: this }); + return this.asInitializingTaskGroup({ + async checkInstallation() { + const { exitCode } = await this.spawnHerokuCommand('--version', { verboseInfo: false }); + this.hasHerokuCli = exitCode === 0; + if (!this.hasHerokuCli) { + const error = + "You don't have the Heroku CLI installed. See https://devcenter.heroku.com/articles/heroku-cli#install-the-heroku-cli to learn how to install it."; + if (this.skipChecks) { + this.log.warn(error); + this.log.warn('Generation will continue with limited support'); + } else { + throw new Error(`${error} To ignore this error run 'jhipster heroku --skip-checks'`); + } + } + }, + async herokuLogin() { + if (!this.hasHerokuCli) return; + + const { exitCode } = await this.spawnHerokuCommand('whoami', { verboseInfo: false }); + if (exitCode !== 0) { + this.log.log(chalk.bold('Log in to Heroku to continue.')); + await this.spawnHerokuCommand('login', { reject: true, stdio: 'inherit' }); + } }, initializing() { this.log.log(chalk.bold('Heroku configuration is starting')); - const configuration = this.config; - this.env.options.appPath = configuration.get('appPath') || CLIENT_MAIN_SRC_DIR; - this.cacheProvider = this.cacheProvider || NO_CACHE_PROVIDER; - this.enableHibernateCache = this.enableHibernateCache && ![NO_CACHE_PROVIDER, MEMCACHED].includes(this.cacheProvider); - this.frontendAppName = getFrontendAppName({ baseName: this.jhipsterConfig.baseName }); - this.herokuAppName = configuration.get('herokuAppName'); - this.dynoSize = 'Free'; - this.herokuDeployType = configuration.get('herokuDeployType'); - this.herokuJavaVersion = configuration.get('herokuJavaVersion'); - this.useOkta = configuration.get('useOkta'); - this.oktaAdminLogin = configuration.get('oktaAdminLogin'); - this.oktaAdminPassword = configuration.get('oktaAdminPassword'); + this.dynoSize = 'Basic'; + this.herokuAppExists = Boolean(this.jhipsterConfig.herokuAppName); }, - }; + }); } get [BaseGenerator.INITIALIZING]() { @@ -125,453 +113,284 @@ export default class HerokuGenerator extends BaseGenerator { } get prompting() { - return { - askForApp: runAsync(function () { - const done = this.async(); - - if (this.herokuAppName) { - ChildProcess.exec(`heroku apps:info --json ${this.herokuAppName}`, (err, stdout) => { - if (err) { - this.abort = true; - this.log.error(`Could not find application: ${chalk.cyan(this.herokuAppName)}`); - this.log.error('Run the generator again to create a new application.'); - this.herokuAppName = null; - } else { - const json = JSON.parse(stdout); - this.herokuAppName = json.app.name; - if (json.dynos.length > 0) { - this.dynoSize = json.dynos[0].size; - } - this.log.verboseInfo(`Deploying as existing application: ${chalk.bold(this.herokuAppName)}`); - this.herokuAppExists = true; - this.config.set({ - herokuAppName: this.herokuAppName, - herokuDeployType: this.herokuDeployType, - }); - } - done(); + return this.asPromptingTaskGroup({ + async askForApp() { + if (this.hasHerokuCli && this.herokuAppExists) { + const { stdout, exitCode } = await this.spawnHeroku(['apps:info', '--json', this.jhipsterConfig.herokuAppName], { + verboseInfo: false, }); + if (exitCode !== 0) { + this.log.error(`Could not find application: ${chalk.cyan(this.jhipsterConfig.herokuAppName)}`); + this.herokuAppName = null; + throw new Error('Run the generator again to create a new application.'); + } else { + const json = JSON.parse(stdout); + this.herokuAppName = json.app.name; + if (json.dynos.length > 0) { + this.dynoSize = json.dynos[0].size; + } + this.log.verboseInfo(`Deploying as existing application: ${chalk.bold(this.herokuAppName)}`); + this.config.set({ + herokuAppName: this.herokuAppName, + }); + } } else { - const prompts = [ - { - type: 'input', - name: 'herokuAppName', - message: 'Name to deploy as:', - default: this.baseName, - }, + await this.prompt( + [ + { + type: 'input', + name: 'herokuAppName', + message: 'Name to deploy as:', + default: this.baseName, + }, + ], + this.config, + ); + + const answers = await this.prompt([ { type: 'list', name: 'herokuRegion', - message: 'On which region do you want to deploy ?', + message: 'On which region do you want to deploy?', choices: ['us', 'eu'], default: 0, }, - ]; - - this.prompt(prompts).then(props => { - this.herokuAppName = _.kebabCase(props.herokuAppName); - this.herokuRegion = props.herokuRegion; - this.herokuAppExists = false; - done(); - }); + ]); + this.herokuRegion = answers.herokuRegion; } - }), - - askForHerokuDeployType() { - if (this.abort) return null; - if (this.herokuDeployType) return null; - const prompts = [ - { - type: 'list', - name: 'herokuDeployType', - message: 'Which type of deployment do you want ?', - choices: [ - { - value: 'git', - name: 'Git (compile on Heroku)', - }, - { - value: 'jar', - name: 'JAR (compile locally)', - }, - ], - default: 0, - }, - ]; - - return this.prompt(prompts).then(props => { - this.herokuDeployType = props.herokuDeployType; - }); }, - - askForHerokuJavaVersion() { - if (this.abort) return null; - if (this.herokuJavaVersion) return null; - const prompts = [ - { - type: 'list', - name: 'herokuJavaVersion', - message: 'Which Java version would you like to use to build and run your app ?', - choices: JAVA_COMPATIBLE_VERSIONS.map(version => ({ value: version })), - default: JAVA_VERSION, - }, - ]; - - return this.prompt(prompts).then(props => { - this.herokuJavaVersion = props.herokuJavaVersion; - }); - }, - askForOkta() { - if (this.abort) return null; - if (this.authenticationType !== OAUTH2) return null; - if (this.useOkta) return null; - const prompts = [ - { - type: 'list', - name: 'useOkta', - message: - 'You are using OAuth 2.0. Do you want to use Okta? When you choose Okta, the automated configuration of users and groups requires cURL and jq.', - choices: [ - { - value: true, - name: 'Yes, provision the Okta add-on', - }, - { - value: false, - name: 'No, I want to configure my identity provider manually', - }, - ], - default: 1, - }, - { - when: answers => answers.useOkta, - type: 'input', - name: 'oktaAdminLogin', - message: 'Login (valid email) for the JHipster Admin user:', - validate: input => { - if (!input) { - return 'You must enter a login for the JHipster admin'; - } - return true; + async askForHerokuDeployType() { + await this.prompt( + [ + { + type: 'list', + name: 'herokuDeployType', + message: 'Which type of deployment do you want?', + choices: [ + { value: 'git', name: 'Git (compile on Heroku)' }, + { value: 'jar', name: 'JAR (compile locally)' }, + ], + default: 0, }, - }, - ]; + ], + this.config, + ); + }, - return this.prompt(prompts).then(props => { - this.useOkta = props.useOkta; - if (this.useOkta) { - this.oktaAdminLogin = props.oktaAdminLogin; - this.oktaAdminPassword = this.randomPassword; - } - }); + async askForHerokuJavaVersion() { + await this.prompt( + [ + { + type: 'list', + name: 'herokuJavaVersion', + message: 'Which Java version would you like to use to build and run your app?', + choices: JAVA_COMPATIBLE_VERSIONS.map(version => ({ value: version })), + default: JAVA_VERSION, + }, + ], + this.config, + ); }, - }; + }); } get [BaseGenerator.PROMPTING]() { return this.delegateTasksToBlueprint(() => this.prompting); } - get configuring() { - return { - checkInstallation: runAsync(function () { - if (this.abort) return; - const done = this.async(); - - ChildProcess.exec('heroku --version', err => { - if (err) { - this.log.error("You don't have the Heroku CLI installed. Download it from https://cli.heroku.com/"); - this.abort = true; - } - done(); - }); - }), - + get loading() { + return this.asConfiguringTaskGroup({ saveConfig() { - this.config.set({ - herokuAppName: this.herokuAppName, - herokuDeployType: this.herokuDeployType, - herokuJavaVersion: this.herokuJavaVersion, - useOkta: this.useOkta, - oktaAdminLogin: this.oktaAdminLogin, - }); + this.herokuAppName = kebabCase(this.jhipsterConfig.herokuAppName); + this.herokuJavaVersion = this.jhipsterConfig.herokuJavaVersion; + this.herokuDeployType = this.jhipsterConfig.herokuDeployType; }, - }; + }); } - get [BaseGenerator.CONFIGURING]() { - return this.delegateTasksToBlueprint(() => this.configuring); + get [BaseGenerator.LOADING]() { + return this.delegateTasksToBlueprint(() => this.loading); } get default() { - return { + return this.asDefaultTaskGroup({ insight() { statistics.sendSubGenEvent('generator', GENERATOR_HEROKU); }, - gitInit: runAsync(function () { - if (this.abort) return; - const done = this.async(); + async gitInit() { + if (!this.herokuDeployType === 'git') return; - try { - fs.lstatSync('.git'); + const git = this.createGit(); + if (await git.checkIsRepo()) { this.log.log(chalk.bold('\nUsing existing Git repository')); - done(); - } catch (e) { - // An exception is thrown if the folder doesn't exist + } else { this.log.log(chalk.bold('\nInitializing Git repository')); - const child = ChildProcess.exec('git init', () => { - done(); - }); - child.stdout.on('data', data => { - this.log.verboseInfo(data.toString()); - }); + await git.init(); } - }), + }, + + async installHerokuDeployPlugin() { + if (!this.hasHerokuCli) return; - installHerokuDeployPlugin: runAsync(function () { - if (this.abort) return; - const done = this.async(); const cliPlugin = 'heroku-cli-deploy'; - ChildProcess.exec('heroku plugins', (err, stdout) => { - if (_.includes(stdout, cliPlugin)) { + const { stdout, stderr, exitCode } = await this.spawnHerokuCommand('plugins', { stdio: 'pipe' }); + if (exitCode !== 0) { + if (stdout.includes(cliPlugin)) { this.log.log('\nHeroku CLI deployment plugin already installed'); - done(); } else { this.log.log(chalk.bold('\nInstalling Heroku CLI deployment plugin')); - const child = ChildProcess.exec(`heroku plugins:install ${cliPlugin}`, err => { - if (err) { - this.abort = true; - this.log.error(err); - } - - done(); - }); - - child.stdout.on('data', data => { - this.log.verboseInfo(data.toString()); - }); - } - }); - }), - - herokuCreate: runAsync(function () { - if (this.abort || this.herokuAppExists) return; - const done = this.async(); - - const regionParams = this.herokuRegion !== 'us' ? ` --region ${this.herokuRegion}` : ''; - - this.log.log(chalk.bold('\nCreating Heroku application and setting up node environment')); - const child = ChildProcess.exec(`heroku create ${this.herokuAppName}${regionParams}`, { timeout: 6000 }, (err, stdout, stderr) => { - if (err) { - if (stderr.includes('is already taken')) { - const prompts = [ - { - type: 'list', - name: 'herokuForceName', - message: `The Heroku application "${chalk.cyan(this.herokuAppName)}" already exists! Use it anyways?`, - choices: [ - { - value: 'Yes', - name: 'Yes, I have access to it', - }, - { - value: 'No', - name: 'No, generate a random name', - }, - ], - default: 0, - }, - ]; - - this.log.verboseInfo(''); - this.prompt(prompts).then(props => { - if (props.herokuForceName === 'Yes') { - ChildProcess.exec(`heroku git:remote --app ${this.herokuAppName}`, (err, stdout) => { - if (err) { - this.abort = true; - this.log.error(err); - } else { - this.log.verboseInfo(stdout.trim()); - this.config.set({ - herokuAppName: this.herokuAppName, - herokuDeployType: this.herokuDeployType, - }); - } - done(); - }); - } else { - ChildProcess.exec(`heroku create ${regionParams}`, (err, stdout) => { - if (err) { - this.abort = true; - this.log.error(err); - } else { - // Extract from "Created random-app-name-1234... done" - this.herokuAppName = stdout.substring(stdout.indexOf('https://') + 8, stdout.indexOf('.herokuapp')); - this.log.verboseInfo(stdout.trim()); - - // ensure that the git remote is the same as the appName - ChildProcess.exec(`heroku git:remote --app ${this.herokuAppName}`, err => { - if (err) { - this.abort = true; - this.log.error(err); - } else { - this.config.set({ - herokuAppName: this.herokuAppName, - herokuDeployType: this.herokuDeployType, - }); - } - done(); - }); - } - }); - } - }); - } else { - this.abort = true; - this.herokuAppName = null; - if (stderr.includes('Invalid credentials')) { - this.log.error("Error: Not authenticated. Run 'heroku login' to login to your heroku account and try again."); - } else { - this.log.error(err); - } + const { exitCode } = await this.spawnHerokuCommand(`plugins:install ${cliPlugin}`); + if (exitCode !== 0) { + throw new Error(stderr); } - } else { - done(); - } - }); - - child.stdout.on('data', data => { - const output = data.toString(); - if (data.search('Heroku credentials') >= 0) { - this.abort = true; - this.log.error("Error: Not authenticated. Run 'heroku login' to login to your heroku account and try again."); - done(); - } else { - this.log.verboseInfo(output.trim()); } - }); - }), - - herokuAddonsCreate: runAsync(function () { - if (this.abort) return; - const done = this.async(); - - const addonCreateCallback = (addon, err) => { - if (err) { - const verifyAccountUrl = 'https://heroku.com/verify'; - if (_.includes(err, verifyAccountUrl)) { - this.abort = true; - this.log.error(`Account must be verified to use addons. Please go to: ${verifyAccountUrl}`); - this.log.error(err); + } + }, + + async herokuCreate() { + if (!this.hasHerokuCli || this.herokuAppExists) return; + + const regionParams = this.herokuRegion !== 'us' ? ['--region', this.herokuRegion] : []; + + this.log.log(chalk.bold('\nCreating Heroku application and setting up Node environment')); + const { stdout, stderr, exitCode } = await this.spawnHeroku(['create', this.herokuAppName, ...regionParams]); + + if (stdout.includes('Heroku credentials')) { + throw new Error("Error: Not authenticated. Run 'heroku login' to log in to your Heroku account and try again."); + } + + if (exitCode !== 0) { + if (stderr.includes('is already taken')) { + const prompts = [ + { + type: 'list', + name: 'herokuForceName', + message: `The Heroku application "${chalk.cyan(this.herokuAppName)}" already exists! Use it anyways?`, + choices: [ + { + value: 'Yes', + name: 'Yes, I have access to it', + }, + { + value: 'No', + name: 'No, generate a random name', + }, + ], + default: 0, + }, + ]; + + this.log.log(''); + const props = await this.prompt(prompts); + if (props.herokuForceName === 'Yes') { + await this.spawnHeroku(['git:remote', '--app', this.herokuAppName], { reject: true }); } else { - this.log.verboseInfo(`No new ${addon} addon created`); + const { stdout } = await this.spawnHeroku(['create', ...regionParams]); + // Extract from "Created random-app-name-1234... done" + this.herokuAppName = stdout.substring(stdout.lastIndexOf('/') + 1, stdout.indexOf('.git')); + // ensure that the git remote is the same as the appName + await this.spawnHeroku(['git:remote', '--app', this.herokuAppName]); + this.jhipsterConfig.herokuAppName = this.herokuAppName; } + } else if (stderr.includes('Invalid credentials')) { + this.log.error("Error: Not authenticated. Run 'heroku login' to log in to your Heroku account and try again."); } else { - this.log.verboseInfo(`Created ${addon} addon`); + throw new Error(stderr); } - }; + } + }, + + async herokuAddonsCreate({ application }) { + if (!this.hasHerokuCli || this.herokuAppExists) return; this.log.log(chalk.bold('\nProvisioning addons')); - if (this.searchEngine === ELASTICSEARCH) { + if (application.searchEngineElasticsearch) { this.log.log(chalk.bold('\nProvisioning bonsai elasticsearch addon')); - ChildProcess.exec(`heroku addons:create bonsai:sandbox-6 --as BONSAI --app ${this.herokuAppName}`, (err, stdout, stderr) => { - addonCreateCallback.bind('Elasticsearch', err, stdout, stderr); - }); - } - - if (this.useOkta) { - this.log.log(chalk.bold('\nProvisioning okta addon')); - ChildProcess.exec(`heroku addons:create okta --app ${this.herokuAppName}`, (err, stdout, stderr) => { - addonCreateCallback('Okta', err, stdout, stderr); - }); + const { stdout, stderr } = await this.spawn('heroku', [ + 'addons:create', + 'bonsai:sandbox-6', + '--as', + 'BONSAI', + '--app', + this.herokuAppName, + ]); + this.checkAddOnReturn({ addOn: 'Elasticsearch', stdout, stderr }); } let dbAddOn; - if (this.prodDatabaseType === POSTGRESQL) { - dbAddOn = 'heroku-postgresql --as DATABASE'; - } else if (this.prodDatabaseType === MYSQL) { - dbAddOn = 'jawsdb:kitefin --as DATABASE'; - } else if (this.prodDatabaseType === MARIADB) { - dbAddOn = 'jawsdb-maria:kitefin --as DATABASE'; + if (application.prodDatabaseTypePostgresql) { + dbAddOn = 'heroku-postgresql'; + } else if (application.prodDatabaseTypeMysql) { + dbAddOn = 'jawsdb:kitefin'; + } else if (application.prodDatabaseTypeMariadb) { + dbAddOn = 'jawsdb-maria:kitefin'; } if (dbAddOn) { this.log.log(chalk.bold(`\nProvisioning database addon ${dbAddOn}`)); - ChildProcess.exec(`heroku addons:create ${dbAddOn} --app ${this.herokuAppName}`, (err, stdout, stderr) => { - addonCreateCallback('Database', err, stdout, stderr); - }); + const { stdout, stderr } = await this.spawn('heroku', [ + 'addons:create', + dbAddOn, + '--as', + 'DATABASE', + '--app', + this.herokuAppName, + ]); + this.checkAddOnReturn({ addOn: 'Database', stdout, stderr }); } else { this.log.log(chalk.bold(`\nNo suitable database addon for database ${this.prodDatabaseType} available.`)); } let cacheAddOn; - if (this.cacheProvider === MEMCACHED) { - cacheAddOn = 'memcachier:dev --as MEMCACHIER'; - } else if (this.cacheProvider === REDIS) { - cacheAddOn = 'heroku-redis:hobby-dev --as REDIS'; + if (application.cacheProviderMemcached) { + cacheAddOn = ['memcachier:dev', '--as', 'MEMCACHIER']; + } else if (application.cacheProviderRedis) { + cacheAddOn = ['heroku-redis:hobby-dev', '--as', 'REDIS']; } if (cacheAddOn) { - this.log.log(chalk.bold(`\nProvisioning cache addon ${cacheAddOn}`)); - ChildProcess.exec(`heroku addons:create ${cacheAddOn} --app ${this.herokuAppName}`, (err, stdout, stderr) => { - addonCreateCallback('Cache', err, stdout, stderr); - }); - } else { - this.log.log(chalk.bold(`\nNo suitable cache addon for cacheprovider ${this.cacheProvider} available.`)); - } + this.log.log(chalk.bold(`\nProvisioning cache addon '${cacheAddOn}'`)); - done(); - }), + const { stdout, stderr } = await this.spawn('heroku', ['addons:create', ...cacheAddOn, '--app', this.herokuAppName]); + this.checkAddOnReturn({ addOn: 'Cache', stdout, stderr }); + } + }, - configureJHipsterRegistry() { - if (this.abort || this.herokuAppExists) return undefined; + async configureJHipsterRegistry({ application }) { + if (!this.hasHerokuCli || this.herokuAppExists || !application.serviceDiscoveryEureka) return undefined; - if (this.serviceDiscoveryType === EUREKA) { - const prompts = [ - { - type: 'input', - name: 'herokuJHipsterRegistryApp', - message: 'What is the name of your JHipster Registry Heroku application?', - default: 'jhipster-registry', - }, - { - type: 'input', - name: 'herokuJHipsterRegistryUsername', - message: 'What is your JHipster Registry username?', - default: 'admin', - }, - { - type: 'input', - name: 'herokuJHipsterRegistryPassword', - message: 'What is your JHipster Registry password?', - default: 'password', - }, - ]; - - this.log.verboseInfo(''); - return this.prompt(prompts).then(props => { - // Encode username/password to avoid errors caused by spaces - props.herokuJHipsterRegistryUsername = encodeURIComponent(props.herokuJHipsterRegistryUsername); - props.herokuJHipsterRegistryPassword = encodeURIComponent(props.herokuJHipsterRegistryPassword); - const herokuJHipsterRegistry = `https://${props.herokuJHipsterRegistryUsername}:${props.herokuJHipsterRegistryPassword}@${props.herokuJHipsterRegistryApp}.herokuapp.com`; - const configSetCmd = `heroku config:set JHIPSTER_REGISTRY_URL=${herokuJHipsterRegistry} --app ${this.herokuAppName}`; - const child = ChildProcess.exec(configSetCmd, err => { - if (err) { - this.abort = true; - this.log.error(err); - } - }); - - child.stdout.on('data', data => { - this.log.verboseInfo(data.toString()); - }); - }); - } - return undefined; + this.log.log(''); + const answers = await this.prompt([ + { + type: 'input', + name: 'herokuJHipsterRegistryApp', + message: 'What is the name of your JHipster Registry Heroku application?', + default: 'jhipster-registry', + }, + { + type: 'input', + name: 'herokuJHipsterRegistryUsername', + message: 'What is your JHipster Registry username?', + default: 'admin', + }, + { + type: 'input', + name: 'herokuJHipsterRegistryPassword', + message: 'What is your JHipster Registry password?', + default: 'password', + }, + ]); + + // Encode username/password to avoid errors caused by spaces + const herokuJHipsterRegistryUsername = encodeURIComponent(answers.herokuJHipsterRegistryUsername); + const herokuJHipsterRegistryPassword = encodeURIComponent(answers.herokuJHipsterRegistryPassword); + const herokuJHipsterRegistry = `https://${herokuJHipsterRegistryUsername}:${herokuJHipsterRegistryPassword}@${answers.herokuJHipsterRegistryApp}.herokuapp.com`; + const configSetCmd = ['config:set', 'JHIPSTER_REGISTRY_URL', herokuJHipsterRegistry, '--app', this.herokuAppName]; + await this.spawnHeroku(configSetCmd, { stdio: 'pipe' }); }, - }; + }); } get [BaseGenerator.DEFAULT]() { @@ -580,44 +399,36 @@ export default class HerokuGenerator extends BaseGenerator { get writing() { return this.asWritingTaskGroup({ - copyHerokuFiles() { - if (this.abort) return; - + copyHerokuFiles({ application }) { this.log.log(chalk.bold('\nCreating Heroku deployment files')); + const context = { + ...application, + herokuAppName: this.herokuAppName, + dynoSize: this.dynoSize, + herokuJavaVersion: this.herokuJavaVersion, + herokuDeployType: this.herokuDeployType, + }; - this.writeFile('bootstrap-heroku.yml.ejs', `${SERVER_MAIN_RES_DIR}/config/bootstrap-heroku.yml`); - this.writeFile('application-heroku.yml.ejs', `${SERVER_MAIN_RES_DIR}/config/application-heroku.yml`); - this.writeFile('Procfile.ejs', 'Procfile'); - this.writeFile('system.properties.ejs', 'system.properties'); - if (this.buildTool === GRADLE) { - this.writeFile('heroku.gradle.ejs', 'gradle/heroku.gradle'); - } - if (this.useOkta) { - this.writeFile('provision-okta-addon.sh.ejs', 'provision-okta-addon.sh'); - fs.appendFile('.gitignore', 'provision-okta-addon.sh', 'utf8', err => { - if (err) { - this.log.warn(`${chalk.yellow.bold('WARNING!')} Failed to add 'provision-okta-addon.sh' to .gitignore.'`); - } - }); + this.writeFile('bootstrap-heroku.yml.ejs', `${SERVER_MAIN_RES_DIR}/config/bootstrap-heroku.yml`, context); + this.writeFile('application-heroku.yml.ejs', `${SERVER_MAIN_RES_DIR}/config/application-heroku.yml`, context); + this.writeFile('Procfile.ejs', 'Procfile', context); + this.writeFile('system.properties.ejs', 'system.properties', context); + if (application.buildToolGradle) { + this.writeFile('heroku.gradle.ejs', 'gradle/heroku.gradle', context); } }, - addHerokuBuildPlugin() { - if (this.abort) return; - if (this.buildTool !== GRADLE) return; + addHerokuBuildPlugin({ application }) { + if (!application.buildToolGradle) return; // TODO addGradlePluginCallback is an internal api, switch to source api when converted to BaseApplicationGenerator - this.editFile( - 'build.gradle', - addGradlePluginCallback({ groupId: 'gradle.plugin.com.heroku.sdk', artifactId: 'heroku-gradle', version: '1.0.4' }), - ); + this.editFile('build.gradle', addGradlePluginCallback({ id: 'com.heroku.sdk.heroku-gradle', version: '1.0.4' })); // TODO applyFromGradleCallback is an internal api, switch to source api when converted to BaseApplicationGenerator this.editFile('build.gradle', applyFromGradleCallback({ script: 'gradle/heroku.gradle' })); }, - addHerokuMavenProfile() { - if (this.abort) return; - if (this.buildTool === MAVEN) { - this.addMavenProfile('heroku', mavenProfileContent(this)); + addHerokuMavenProfile({ application }) { + if (application.buildToolMaven) { + this.addMavenProfile('heroku', mavenProfileContent(application)); } }, }); @@ -629,23 +440,7 @@ export default class HerokuGenerator extends BaseGenerator { get end() { return this.asEndTaskGroup({ - makeScriptExecutable() { - if (this.abort) return; - if (this.useOkta) { - try { - fs.chmodSync('provision-okta-addon.sh', '755'); - } catch (err) { - this.log.warn( - `${chalk.yellow.bold( - 'WARNING!', - )}Failed to make 'provision-okta-addon.sh' executable, you may need to run 'chmod +x provison-okta-addon.sh'`, - ); - } - } - }, async productionBuild() { - if (this.abort) return; - if (this.herokuSkipBuild || this.herokuDeployType === 'git') { this.log.log(chalk.bold('\nSkipping build')); return; @@ -654,13 +449,11 @@ export default class HerokuGenerator extends BaseGenerator { this.log.log(chalk.bold('\nBuilding application')); // Use npm script so blueprints just need to override it. - await this.spawnCommand('npm run java:jar:prod', { stdio: 'inherit' }); + await this.printChildOutput(this.spawnCommand('npm run java:jar:prod', { stdio: 'pipe' })); }, - async productionDeploy() { - if (this.abort) return; - - if (this.herokuSkipDeploy) { + async productionDeploy({ application }) { + if (this.herokuSkipDeploy || !this.hasHerokuCli) { this.log.log(chalk.bold('\nSkipping deployment')); return; } @@ -668,152 +461,48 @@ export default class HerokuGenerator extends BaseGenerator { if (this.herokuDeployType === 'git') { try { this.log.log(chalk.bold('\nUpdating Git repository')); - const gitAddCmd = 'git add .'; - this.log.log(chalk.cyan(gitAddCmd)); - - const gitAdd = execCmd(gitAddCmd); - gitAdd.child.stdout.on('data', data => { - this.log.verboseInfo(data); - }); - - gitAdd.child.stderr.on('data', data => { - this.log.verboseInfo(data); - }); - await gitAdd; - - const gitCommitCmd = 'git commit -m "Deploy to Heroku" --allow-empty'; - this.log.log(chalk.cyan(gitCommitCmd)); - - const gitCommit = execCmd(gitCommitCmd); - gitCommit.child.stdout.on('data', data => { - this.log.verboseInfo(data); - }); - - gitCommit.child.stderr.on('data', data => { - this.log.verboseInfo(data); - }); - await gitCommit; + const git = this.createGit().outputHandler((_command, stdout, stderr) => this.printChildOutput({ stdout, stderr })); + await git.add('.').commit('Deploy to Heroku', { '--allow-empty': null }); let buildpack = 'heroku/java'; - let configVars = 'MAVEN_CUSTOM_OPTS="-Pprod,heroku -DskipTests" '; - if (this.buildTool === GRADLE) { + let configName = 'MAVEN_CUSTOM_OPTS'; + let configValues = '-Pprod,heroku -DskipTests'; + if (application.buildToolGradle) { buildpack = 'heroku/gradle'; - configVars = 'GRADLE_TASK="stage -Pprod -PnodeInstall" '; + configName = 'GRADLE_TASK'; + configValues = 'stage -Pprod -PnodeInstall'; } this.log.log(chalk.bold('\nConfiguring Heroku')); - await execCmd(`heroku config:set ${configVars}--app ${this.herokuAppName}`); - const { stdout: data } = await execCmd(`heroku buildpacks:add ${buildpack} --app ${this.herokuAppName}`); - if (data) { - this.logger.info(data); - // remote: ! The following add-ons were automatically provisioned: . These add-ons may incur additional cost, - // which is prorated to the second. Run `heroku addons` for more info. - if (data.includes('Run `heroku addons` for more info.')) { - await execCmd('heroku addons'); - } - - this.log(''); - const prompts = [ - { - type: 'list', - name: 'userDeployDecision', - message: 'Continue to deploy?', - choices: [ - { - value: 'Yes', - name: 'Yes, I confirm', - }, - { - value: 'No', - name: 'No, abort (Recommended)', - }, - ], - default: 0, - }, - ]; - - this.log(''); - const props = await this.prompt(prompts); - if (props.userDeployDecision === 'Yes') { - this.log.info(chalk.bold('Continuing deployment...')); - } else { - this.log(this.logger); - this.log.info(chalk.bold('You aborted deployment!')); - this.abort = true; - this.herokuAppName = null; - return; - } - this.log(''); + const { stdout: configData } = await this.spawnHeroku(['config:get', configName, '--app', this.herokuAppName]); + if (!configData) { + await this.spawnHeroku(['config:set', `${configName}=${configValues}`, '--app', this.herokuAppName]); } - this.log.log(chalk.bold('\nDeploying application')); - - const herokuPush = execCmd('git push heroku HEAD:main', { maxBuffer: 1024 * 10000 }); + const { stdout: buildpackData } = await this.spawnHeroku(['buildpacks', '--app', this.herokuAppName]); + if (!buildpackData.includes(buildpack)) { + await this.spawnHeroku(['buildpacks:add', buildpack, '--app', this.herokuAppName]); + } - herokuPush.child.stdout.on('data', data => { - this.log.verboseInfo(data); - }); + this.log.log(chalk.bold('\nDeploying application...')); - herokuPush.child.stderr.on('data', data => { - this.log.verboseInfo(data); - }); - - await herokuPush; + await git.push('heroku', 'HEAD:main'); this.log.log(chalk.green(`\nYour app should now be live. To view it run\n\t${chalk.bold('heroku open')}`)); this.log.log(chalk.yellow(`And you can view the logs with this command\n\t${chalk.bold('heroku logs --tail')}`)); this.log.log(chalk.yellow(`After application modification, redeploy it with\n\t${chalk.bold('jhipster heroku')}`)); - - if (this.useOkta) { - let curlAvailable = false; - let jqAvailable = false; - try { - await execCmd('curl --help'); - curlAvailable = true; - } catch (err) { - this.log.log( - chalk.red('cURL is not available but required. See https://curl.haxx.se/download.html for installation guidance.'), - ); - this.log.log(chalk.yellow('After you have installed curl execute ./provision-okta-addon.sh manually.')); - } - try { - await execCmd('jq --help'); - jqAvailable = true; - } catch (err) { - this.log.log( - chalk.red('jq is not available but required. See https://stedolan.github.io/jq/download/ for installation guidance.'), - ); - this.log.log(chalk.yellow('After you have installed jq execute ./provision-okta-addon.sh manually.')); - } - if (curlAvailable && jqAvailable) { - this.log.log(chalk.green('Running ./provision-okta-addon.sh to create all required roles and users for JHipster.')); - try { - await execCmd('./provision-okta-addon.sh'); - this.log.log(chalk.bold('\nOkta configured successfully!')); - this.log.log(chalk.green(`\nUse ${chalk.bold(`${this.oktaAdminLogin}/${this.oktaAdminPassword}`)} to login.\n`)); - } catch (err) { - this.log.log( - chalk.red( - 'Failed to execute ./provision-okta-addon.sh. Make sure to setup okta according to https://www.jhipster.tech/heroku/.', - ), - ); - } - } - } } catch (err) { this.log.error(err); } } else { this.log.log(chalk.bold('\nDeploying application')); let jarFileWildcard = 'target/*.jar'; - if (this.buildTool === GRADLE) { + if (application.buildToolGradle) { jarFileWildcard = 'build/libs/*.jar'; } const files = glob.sync(jarFileWildcard, {}); const jarFile = files[0]; - const herokuDeployCommand = `heroku deploy:jar ${jarFile} --app ${this.herokuAppName}`; - const herokuSetBuildpackCommand = 'heroku buildpacks:set heroku/jvm'; this.log.log( chalk.bold( @@ -821,56 +510,11 @@ export default class HerokuGenerator extends BaseGenerator { ), ); try { - await execCmd(herokuSetBuildpackCommand); - const herokuDeploy = execCmd(herokuDeployCommand); - herokuDeploy.child.stdout.on('data', data => { - this.log.verboseInfo(data); - }); - - herokuDeploy.child.stderr.on('data', data => { - this.log.verboseInfo(data); - }); - await herokuDeploy; + await this.spawnHeroku(['deploy:jar', jarFile, '--app', this.herokuAppName], { stdio: 'pipe' }); + await this.spawnHerokuCommand('buildpacks:set heroku/jvm', { stdio: 'pipe' }); this.log.log(chalk.green(`\nYour app should now be live. To view it run\n\t${chalk.bold('heroku open')}`)); this.log.log(chalk.yellow(`And you can view the logs with this command\n\t${chalk.bold('heroku logs --tail')}`)); this.log.log(chalk.yellow(`After application modification, redeploy it with\n\t${chalk.bold('jhipster heroku')}`)); - - if (this.useOkta) { - let curlAvailable = false; - let jqAvailable = false; - try { - await execCmd('curl --help'); - curlAvailable = true; - } catch (err) { - this.log.log( - chalk.red('cURL is not available but required. See https://curl.haxx.se/download.html for installation guidance.'), - ); - this.log.log(chalk.yellow('After you have installed curl execute ./provision-okta-addon.sh manually.')); - } - try { - await execCmd('jq --help'); - jqAvailable = true; - } catch (err) { - this.log.log( - chalk.red('jq is not available but required. See https://stedolan.github.io/jq/download/ for installation guidance.'), - ); - this.log.log(chalk.yellow('After you have installed jq execute ./provision-okta-addon.sh manually.')); - } - if (curlAvailable && jqAvailable) { - this.log.log(chalk.green('Running ./provision-okta-addon.sh to create all required roles and users for JHipster.')); - try { - await execCmd('./provision-okta-addon.sh'); - this.log.log(chalk.bold('\nOkta configured successfully!')); - this.log.log(chalk.green(`\nUse ${chalk.bold(`${this.oktaAdminLogin}/${this.oktaAdminPassword}`)} to login.`)); - } catch (err) { - this.log.log( - chalk.red( - 'Failed to execute ./provision-okta-addon.sh. Make sure to set up Okta according to https://www.jhipster.tech/heroku/.', - ), - ); - } - } - } } catch (err) { this.log.error(err); } @@ -894,4 +538,66 @@ export default class HerokuGenerator extends BaseGenerator { addMavenProfile(profileId, other) { createPomStorage(this).addProfile({ id: profileId, content: other }); } + + /** + * @param {string} command + * @param {import('execa').Options} opt + * @returns {ReturnType} + */ + spawnHerokuCommand(command, opt) { + opt = { stdio: 'pipe', reject: false, ...opt }; + const { verboseInfo, ...spawnOptions } = opt; + const child = this.spawnCommand(`heroku ${command}`, spawnOptions); + if (opt.stdio !== 'pipe' || verboseInfo === false) { + return child; + } + return this.printChildOutput(child); + } + + /** + * @param {string[]} args + * @param {import('execa').Options} opt + * @returns {ReturnType} + */ + spawnHeroku(args, opt) { + opt = { stdio: 'pipe', reject: false, ...opt }; + const { verboseInfo, ...spawnOptions } = opt; + const child = this.spawn('heroku', args, spawnOptions); + if (spawnOptions.stdio !== 'pipe' || verboseInfo === false) { + return child; + } + return this.printChildOutput(child); + } + + /** + * @template {{stdout: any; stderr: any}} T + * @param {T} child + * @param {(chunk: any) => void} child + * @returns {T} + */ + printChildOutput(child, log = data => this.log.verboseInfo(data)) { + const { stdout, stderr } = child; + stdout.on('data', data => { + data.toString().split(/\r?\n/).filter(Boolean).forEach(log); + }); + stderr.on('data', data => { + data.toString().split(/\r?\n/).filter(Boolean).forEach(log); + }); + return child; + } + + checkAddOnReturn({ addOn, stdout, stderr }) { + if (stdout) { + this.log.ok(`Created ${addOn.valueOf()} add-on`); + this.log.ok(stdout); + } else if (stderr) { + const verifyAccountUrl = 'https://heroku.com/verify'; + if (stderr.includes(verifyAccountUrl)) { + this.log.error(`Account must be verified to use addons. Please go to: ${verifyAccountUrl}`); + throw new Error(stderr); + } else { + this.log.verboseInfo(`No new ${addOn.valueOf()} add-on created`); + } + } + } } diff --git a/generators/heroku/heroku.spec.mts b/generators/heroku/heroku.spec.mts index 72811508643e..a9fe1e688928 100644 --- a/generators/heroku/heroku.spec.mts +++ b/generators/heroku/heroku.spec.mts @@ -1,46 +1,49 @@ -import ChildProcess from 'child_process'; -import sinon from 'sinon'; +import sinon, { SinonStub } from 'sinon'; import { expect } from 'esmocha'; import { SERVER_MAIN_RES_DIR } from '../generator-constants.mjs'; -import { defaultHelpers as helpers } from '../../test/support/index.mjs'; +import { defaultHelpers as helpers, runResult } from '../../test/support/index.mjs'; import { GENERATOR_HEROKU } from '../generator-list.mjs'; const expectedFiles = { monolith: ['Procfile', `${SERVER_MAIN_RES_DIR}/config/bootstrap-heroku.yml`, `${SERVER_MAIN_RES_DIR}/config/application-heroku.yml`], }; +const createSpawnCommandReturn = (resolvedValue?, data?) => + Object.assign( + Promise.resolve({ + exitCode: 0, + stdout: '', + stderr: '', + ...resolvedValue, + }), + { + ...data, + stdout: { on: () => {} }, + stderr: { on: () => {} }, + }, + ); + describe('generator - Heroku', () => { const herokuAppName = 'jhipster-test'; - let stub; + let stub: SinonStub; beforeEach(() => { - stub = sinon.stub(ChildProcess, 'exec'); - stub.withArgs('heroku --version').yields(false); - stub.withArgs('heroku plugins').yields(false, 'heroku-cli-deploy'); - stub.withArgs('git init').yields([false, '', '']); + stub = sinon.stub(); + // Add catch all + stub.withArgs('spawnCommand').returns(createSpawnCommandReturn()); + stub.withArgs('spawn').returns(createSpawnCommandReturn()); + + stub.withArgs('spawnCommand', 'heroku plugins').returns(createSpawnCommandReturn({ stdout: 'heroku-cli-deploy', stderr: '' })); }); afterEach(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (ChildProcess.exec as any).restore(); + stub.resetHistory(); }); describe('microservice application', () => { describe('with JAR deployment', () => { - let runResult; beforeEach(async () => { - stub.withArgs(`heroku create ${herokuAppName}`).yields(false, '', ''); - stub.withArgs(`heroku addons:create jawsdb:kitefin --as DATABASE --app ${herokuAppName}`).yields(false, '', ''); - stub - .withArgs(`heroku config:set JHIPSTER_REGISTRY_URL=https://admin:changeme@sushi.herokuapp.com --app ${herokuAppName}`) - .yields(false, '', '') - .returns({ - stdout: { - // eslint-disable-next-line @typescript-eslint/no-empty-function - on: () => {}, - }, - }); - runResult = await helpers + await helpers .createJHipster(GENERATOR_HEROKU) .withJHipsterConfig({ applicationType: 'microservice' }) .withOptions({ skipBuild: true }) @@ -52,8 +55,8 @@ describe('generator - Heroku', () => { herokuJHipsterRegistryUsername: 'admin', herokuJHipsterRegistryPassword: 'changeme', herokuJavaVersion: '17', - useOkta: false, }) + .withSpawnMock(stub) .run(); }); it('should match files snapshot', function () { @@ -67,22 +70,16 @@ describe('generator - Heroku', () => { describe('monolith application', () => { describe('with an unavailable app name', () => { - const autogeneratedAppName = 'jhipster-new-name'; - let runResult; + const autogeneratedAppName = 'random-app-name'; beforeEach(async () => { stub - .withArgs(`heroku create ${herokuAppName}`) - .yields(true, '', `Name ${herokuAppName} is already taken`) - .returns({ - stdout: { - // eslint-disable-next-line @typescript-eslint/no-empty-function - on: () => {}, - }, - }); - stub.withArgs('heroku create ').yields(false, `https://${autogeneratedAppName}.herokuapp.com`); - stub.withArgs(`heroku git:remote --app ${autogeneratedAppName}`).yields(false, `https://${autogeneratedAppName}.herokuapp.com`); - stub.withArgs(`heroku addons:create jawsdb:kitefin --as DATABASE --app ${autogeneratedAppName}`).yields(false, '', ''); - runResult = await helpers + .withArgs('spawn', 'heroku', sinon.match(['create', herokuAppName])) + .returns(createSpawnCommandReturn({ exitCode: 1, stderr: `Name ${herokuAppName} is already taken` })); + stub + .withArgs('spawn', 'heroku', sinon.match(['create'])) + .returns(createSpawnCommandReturn({ stdout: `https://git.heroku.com/${autogeneratedAppName}.git` })); + + await helpers .createJHipster(GENERATOR_HEROKU) .withJHipsterConfig() .withOptions({ skipBuild: true }) @@ -92,8 +89,8 @@ describe('generator - Heroku', () => { herokuDeployType: 'jar', herokuForceName: 'No', herokuJavaVersion: '11', - useOkta: false, }) + .withSpawnMock(stub) .run(); }); it('should match files snapshot', function () { @@ -101,21 +98,16 @@ describe('generator - Heroku', () => { }); it('creates expected monolith files', () => { runResult.assertFile(expectedFiles.monolith); - runResult.assertFileContent('.yo-rc.json', `"herokuAppName": "${autogeneratedAppName}"`); + runResult.assertJsonFileContent('.yo-rc.json', { 'generator-jhipster': { herokuAppName: autogeneratedAppName } }); + }); + it('calls should match snapshot', () => { + expect(runResult.getSpawnArgsUsingDefaultImplementation()).toMatchSnapshot(); }); }); describe('with Git deployment', () => { - let runResult; beforeEach(async () => { - stub.withArgs(`heroku create ${herokuAppName}`).yields(false, '', ''); - stub.withArgs(`heroku addons:create jawsdb:kitefin --as DATABASE --app ${herokuAppName}`).yields(false, '', ''); - stub.withArgs('git add .').yields(false, '', ''); - stub.withArgs('git commit -m "Deploy to Heroku" --allow-empty').yields(false, '', ''); - stub.withArgs(`heroku config:set MAVEN_CUSTOM_OPTS="-Pprod,heroku -DskipTests" --app ${herokuAppName}`).yields(false, '', ''); - stub.withArgs(`heroku buildpacks:add heroku/java --app ${herokuAppName}`).yields(false, '', ''); - stub.withArgs('git push heroku HEAD:master').yields(false, '', ''); - runResult = await helpers + await helpers .createJHipster(GENERATOR_HEROKU) .withJHipsterConfig() .withAnswers({ @@ -123,8 +115,8 @@ describe('generator - Heroku', () => { herokuRegion: 'us', herokuDeployType: 'git', herokuJavaVersion: '11', - useOkta: false, }) + .withSpawnMock(stub) .run(); }); it('should match files snapshot', function () { @@ -134,14 +126,14 @@ describe('generator - Heroku', () => { runResult.assertFile(expectedFiles.monolith); runResult.assertFileContent('.yo-rc.json', '"herokuDeployType": "git"'); }); + it('calls should match snapshot', () => { + expect(runResult.getSpawnArgsUsingDefaultImplementation()).toMatchSnapshot(); + }); }); describe('in the US', () => { - let runResult; beforeEach(async () => { - stub.withArgs(`heroku create ${herokuAppName}`).yields(false, '', ''); - stub.withArgs(`heroku addons:create jawsdb:kitefin --as DATABASE --app ${herokuAppName}`).yields(false, '', ''); - runResult = await helpers + await helpers .createJHipster(GENERATOR_HEROKU) .withJHipsterConfig() .withOptions({ skipBuild: true }) @@ -150,8 +142,8 @@ describe('generator - Heroku', () => { herokuRegion: 'us', herokuDeployType: 'jar', herokuJavaVersion: '11', - useOkta: false, }) + .withSpawnMock(stub) .run(); }); it('should match files snapshot', function () { @@ -163,14 +155,14 @@ describe('generator - Heroku', () => { runResult.assertFileContent(`${SERVER_MAIN_RES_DIR}/config/application-heroku.yml`, 'datasource:'); runResult.assertNoFileContent(`${SERVER_MAIN_RES_DIR}/config/application-heroku.yml`, 'mongodb:'); }); + it('calls should match snapshot', () => { + expect(runResult.getSpawnArgsUsingDefaultImplementation()).toMatchSnapshot(); + }); }); describe('in the EU', () => { - let runResult; beforeEach(async () => { - stub.withArgs(`heroku create ${herokuAppName} --region eu`).yields(false, '', ''); - stub.withArgs(`heroku addons:create jawsdb:kitefin --as DATABASE --app ${herokuAppName}`).yields(false, '', ''); - runResult = await helpers + await helpers .createJHipster(GENERATOR_HEROKU) .withJHipsterConfig() .withOptions({ skipBuild: true }) @@ -179,8 +171,8 @@ describe('generator - Heroku', () => { herokuRegion: 'eu', herokuDeployType: 'jar', herokuJavaVersion: '11', - useOkta: false, }) + .withSpawnMock(stub) .run(); }); it('should match files snapshot', function () { @@ -189,14 +181,14 @@ describe('generator - Heroku', () => { it('creates expected monolith files', () => { runResult.assertFile(expectedFiles.monolith); }); + it('calls should match snapshot', () => { + expect(runResult.getSpawnArgsUsingDefaultImplementation()).toMatchSnapshot(); + }); }); describe('with PostgreSQL', () => { - let runResult; beforeEach(async () => { - stub.withArgs(`heroku create ${herokuAppName} --region eu`).yields(false, '', ''); - stub.withArgs(`heroku addons:create heroku-postgresql --as DATABASE --app ${herokuAppName}`).yields(false, '', ''); - runResult = await helpers + await helpers .createJHipster(GENERATOR_HEROKU) .withJHipsterConfig() .withOptions({ skipBuild: true }) @@ -205,8 +197,8 @@ describe('generator - Heroku', () => { herokuRegion: 'eu', herokuDeployType: 'jar', herokuJavaVersion: '11', - useOkta: false, }) + .withSpawnMock(stub) .run(); }); it('should match files snapshot', function () { @@ -217,20 +209,22 @@ describe('generator - Heroku', () => { runResult.assertFileContent(`${SERVER_MAIN_RES_DIR}/config/application-heroku.yml`, 'datasource:'); runResult.assertNoFileContent(`${SERVER_MAIN_RES_DIR}/config/application-heroku.yml`, 'mongodb:'); }); + it('calls should match snapshot', () => { + expect(runResult.getSpawnArgsUsingDefaultImplementation()).toMatchSnapshot(); + }); }); describe('with existing app', () => { const existingHerokuAppName = 'jhipster-existing'; - let runResult; beforeEach(async () => { stub - .withArgs(`heroku apps:info --json ${existingHerokuAppName}`) - .yields(false, `{"app":{"name":"${existingHerokuAppName}"}, "dynos":[]}`); - stub.withArgs(`heroku addons:create jawsdb:kitefin --as DATABASE --app ${existingHerokuAppName}`).yields(false, '', ''); - runResult = await helpers + .withArgs('spawn', 'heroku', sinon.match(['apps:info', '--json', existingHerokuAppName])) + .returns(createSpawnCommandReturn({ stdout: `{"app":{"name":"${existingHerokuAppName}"}, "dynos":[]}` })); + await helpers .createJHipster(GENERATOR_HEROKU) .withJHipsterConfig({ herokuAppName: 'jhipster-existing', herokuDeployType: 'git' }) .withOptions({ skipBuild: true }) + .withSpawnMock(stub) .run(); }); it('should match files snapshot', function () { @@ -240,16 +234,14 @@ describe('generator - Heroku', () => { runResult.assertFile(expectedFiles.monolith); runResult.assertFileContent('.yo-rc.json', `"herokuAppName": "${existingHerokuAppName}"`); }); + it('calls should match snapshot', () => { + expect(runResult.getSpawnArgsUsingDefaultImplementation()).toMatchSnapshot(); + }); }); describe('with elasticsearch', () => { - let runResult; beforeEach(async () => { - stub.withArgs(`heroku create ${herokuAppName}`).yields(false, '', ''); - stub.withArgs(`heroku addons:create jawsdb:kitefin --as DATABASE --app ${herokuAppName}`).yields(false, '', ''); - stub.withArgs(`heroku addons:create bonsai --as BONSAI --app ${herokuAppName}`).yields(false, '', ''); - - runResult = await helpers + await helpers .createJHipster(GENERATOR_HEROKU) .withJHipsterConfig({ searchEngine: 'elasticsearch' }) .withOptions({ skipBuild: true }) @@ -258,8 +250,8 @@ describe('generator - Heroku', () => { herokuRegion: 'us', herokuDeployType: 'jar', herokuJavaVersion: '11', - useOkta: false, }) + .withSpawnMock(stub) .run(); }); it('should match files snapshot', function () { @@ -271,6 +263,9 @@ describe('generator - Heroku', () => { runResult.assertFileContent(`${SERVER_MAIN_RES_DIR}/config/application-heroku.yml`, 'datasource:'); runResult.assertNoFileContent(`${SERVER_MAIN_RES_DIR}/config/application-heroku.yml`, 'mongodb:'); }); + it('calls should match snapshot', () => { + expect(runResult.getSpawnArgsUsingDefaultImplementation()).toMatchSnapshot(); + }); }); }); }); diff --git a/generators/heroku/templates/Procfile.ejs b/generators/heroku/templates/Procfile.ejs index 24ae0b46e23b..113dafba817d 100644 --- a/generators/heroku/templates/Procfile.ejs +++ b/generators/heroku/templates/Procfile.ejs @@ -16,4 +16,4 @@ See the License for the specific language governing permissions and limitations under the License. -%> -web: java $JAVA_OPTS <% if (applicationTypeGateway || dynoSize === 'Free') { %>-Xmx256m<% } %> -jar <% if (buildToolMaven) { %>target<% } %><% if (buildToolGradle) { %>build/libs<% } %>/*.jar --spring.profiles.active=prod,heroku<% if (databaseTypeMongodb) { %> --spring.data.mongodb.database=$(echo "$MONGODB_URI" | sed "s/^.*:[0-9]*\///g")<% } %> +web: java $JAVA_OPTS <% if (applicationTypeGateway || dynoSize === 'Basic') { %>-Xmx256m<% } %> -jar <% if (buildToolMaven) { %>target<% } %><% if (buildToolGradle) { %>build/libs<% } %>/*.jar --spring.profiles.active=prod,heroku<% if (databaseTypeMongodb) { %> --spring.data.mongodb.database=$(echo "$MONGODB_URI" | sed "s/^.*:[0-9]*\///g")<% } %> diff --git a/generators/heroku/templates/application-heroku.yml.ejs b/generators/heroku/templates/application-heroku.yml.ejs index 9389d89bbf07..aab47e9acf3f 100644 --- a/generators/heroku/templates/application-heroku.yml.ejs +++ b/generators/heroku/templates/application-heroku.yml.ejs @@ -78,17 +78,5 @@ spring: elasticsearch: uris: ${BONSAI_URL} <%_ } _%> -<%_ if (useOkta) { _%> - security: - oauth2: - client: - provider: - oidc: - issuer-uri: ${OKTA_OAUTH2_ISSUER} - registration: - oidc: - client-id: ${OKTA_OAUTH2_CLIENT_ID_WEB} - client-secret: ${OKTA_OAUTH2_CLIENT_SECRET_WEB} -<%_ } _%> server: port: ${PORT:8080} diff --git a/generators/heroku/templates/provision-okta-addon.sh.ejs b/generators/heroku/templates/provision-okta-addon.sh.ejs deleted file mode 100644 index afc6fe9d8375..000000000000 --- a/generators/heroku/templates/provision-okta-addon.sh.ejs +++ /dev/null @@ -1,217 +0,0 @@ -#! /usr/bin/env bash -# abort on nonzero exitstatus -set -o errexit -# abort on unbound variable -set -o nounset -# don't hide errors within pipes -set -o pipefail - -get_app_url() { - heroku info -j | jq '.app.web_url' -} - -get_apps() { - curl -s --location --request GET "${1}/api/v1/apps" \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "Authorization: SSWS ${2}" >apps.json - - jq '.[0]' apps.json >app1.json - jq '.[1]' apps.json >app2.json -} - -add_redirect_url() { - REDIRECT_URL="${1//\"/}login/oauth2/code/oidc" - jq '.settings.oauthClient.redirect_uris += ["'"${REDIRECT_URL}"'"]' app1.json >app1-mod.json - jq '.settings.oauthClient.redirect_uris += ["'"${REDIRECT_URL}"'"]' app2.json >app2-mod.json - - jq '.settings.oauthClient.post_logout_redirect_uris += ["'"${1//\"/}"'"]' app1-mod.json >app1.json - jq '.settings.oauthClient.post_logout_redirect_uris += ["'"${1//\"/}"'"]' app2-mod.json >app2.json -} - -update_apps() { - data=$(jq '.' app1.json) - id=$(jq '.id' app1.json) - - curl -s --location --request PUT "${1}/api/v1/apps/${id//\"/}" \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "Authorization: SSWS ${2}" \ - --data-raw "${data}" - - data=$(jq '.' app2.json) - id=$(jq '.id' app2.json) - - curl -s --location --request PUT "${1}/api/v1/apps/${id//\"/}" \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "Authorization: SSWS ${2}" \ - --data-raw "${data}" -} - -add_groups() { - curl -s --location --request POST "${1}/api/v1/groups" \ - --header "Content-Type: application/json" \ - --header "Authorization: SSWS ${2}" \ - --data-raw '{"profile": - { - "name": "ROLE_ADMIN", - "description": "JHipster Admin Role" - } - }' - - curl -s --location --request POST "${1}/api/v1/groups" \ - --header "Content-Type: application/json" \ - --header "Authorization: SSWS ${2}" \ - --data-raw '{"profile": - { - "name": "ROLE_USER", - "description": "JHipster User Role" - } - }' -} - -add_admin_to_group() { - ADMIN_EMAIL=$(heroku config:get OKTA_ADMIN_EMAIL) - - GROUP_ID=$(curl -s --location --request GET "${1}/api/v1/groups?q=ROLE_ADMIN" \ - --header "Authorization: SSWS ${2}" | jq '.[0].id') - - USER_ID=$(curl -s --location --request GET "${1}/api/v1/users?q=${ADMIN_EMAIL}" \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "Authorization: SSWS ${2}" | jq '.[0].id') - - curl -s --location --request PUT "${1}/api/v1/groups/${GROUP_ID//\"/}/users/${USER_ID//\"/}" \ - --header "Authorization: SSWS ${2}" \ - --data-raw '' -} - -add_groups_claim() { - curl -s --location --request POST "${1}/api/v1/authorizationServers/default/claims" \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "Authorization: SSWS ${2}" \ - --data-raw '{ - "name": "groups", - "status": "ACTIVE", - "claimType": "IDENTITY", - "valueType": "GROUPS", - "value": ".*", - "conditions": { - "scopes": [] - }, - "system": false, - "alwaysIncludeInToken": true, - "group_filter_type": "REGEX" - }' -} - -add_admin_user() { - - GROUP_ID=$(curl -s --location --request GET "${1}/api/v1/groups?q=ROLE_ADMIN" \ - --header "Authorization: SSWS ${2}" | jq '.[0].id') - - # Create user with provided initial password - USER_ID=$(curl -s --location --request POST "${1}/api/v1/users?activate=true" \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "Authorization: SSWS ${2}" \ - --data-raw ' - { - "profile": { - "firstName": "JHipster", - "lastName": "Admin", - "email": "<%= oktaAdminLogin %>", - "login": "<%= oktaAdminLogin %>" - }, - "credentials": { - "password" : { "value": "<%= oktaAdminPassword %>" } - } - }' | jq '.id') - - curl -s --location --request PUT "${1}/api/v1/groups/${GROUP_ID//\"/}/users/${USER_ID//\"/}" \ - --header "Authorization: SSWS ${2}" \ - --data-raw '' - - # directly expire the password such that the user is forced to select a new password - curl -s --location --request POST "${1}/api/v1/users/${USER_ID//\"/}/lifecycle/expire_password" \ - --header "Content-Type: application/json" \ - --header "Accept: application/json" \ - --header "Authorization: SSWS ${2}" \ - --data-raw '' -} - -already_done() { - LENGTH=$(curl -s --location --request GET "${1}/api/v1/users?q=<%= oktaAdminLogin %>" \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "Authorization: SSWS ${2}" | jq '. | length') - - if [ $LENGTH -gt 0 ] - then - echo "true" - else - echo "false" - fi -} - -check_required_dependencies() { - if hash curl 2>/dev/null; - then - echo -e "\U2714 cURL is available." - else - echo "\U1F6D1cURL is not available but required. See https://curl.haxx.se/download.html for installation guidance." - return 0; - fi - - if hash jq 2>/dev/null; - then - echo -e "\U2611 jq is available.️" - else - echo -e "\U1F6D1jq is not available but required. See https://stedolan.github.io/jq/download/ for installation guidance." - return 0; - fi -} - -main() { - check_required_dependencies - - OKTA_URL=$(heroku config:get OKTA_CLIENT_ORGURL) - OKTA_TOKEN=$(heroku config:get OKTA_CLIENT_TOKEN) - - APP_URL=$(get_app_url) - - DONE=$(already_done ${OKTA_URL} ${OKTA_TOKEN}) - - if [ ${DONE} == "true" ] - then - echo "User already created, doing nothing." - return 0 - fi - - # First add the correct redirect url to each application - get_apps "${OKTA_URL}" "${OKTA_TOKEN}" - add_redirect_url "${APP_URL}" - update_apps "${OKTA_URL}" "${OKTA_TOKEN}" - - # Create ROLE_ADMIN and ROLE_USER groups - add_groups "${OKTA_URL}" "${OKTA_TOKEN}" - - # Add the automatically provisioned HEROKU ADMIN to the ROLE_ADMIN group - add_admin_to_group "${OKTA_URL}" "${OKTA_TOKEN}" - - # Add the groups claim, see https://www.jhipster.tech/security/#okta - add_groups_claim "${OKTA_URL}" "${OKTA_TOKEN}" - - add_admin_user "${OKTA_URL}" "${OKTA_TOKEN}" - - # Delete all temporary files created during this script - rm apps.json - rm app1.json - rm app2.json - rm app1-mod.json - rm app2-mod.json -} - -main