UNPKG

@unito/integration-cli

Version:

Integration CLI

339 lines (336 loc) 16.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = require("tslib"); const core_1 = require("@oclif/core"); const inquirer_1 = tslib_1.__importDefault(require("inquirer")); const ngrok_1 = tslib_1.__importDefault(require("@ngrok/ngrok")); const chalk_1 = tslib_1.__importDefault(require("chalk")); const gradient = tslib_1.__importStar(require("gradient-string")); const json_colorizer_1 = tslib_1.__importDefault(require("json-colorizer")); const child_process_1 = tslib_1.__importDefault(require("child_process")); const tmp_1 = tslib_1.__importDefault(require("tmp")); const crypto_1 = tslib_1.__importDefault(require("crypto")); const baseCommand_1 = require("../baseCommand"); const errors_1 = require("../errors"); const integrations_1 = require("../resources/integrations"); const GlobalConfiguration = tslib_1.__importStar(require("../resources/globalConfiguration")); const IntegrationsPlatform = tslib_1.__importStar(require("../services/integrationsPlatform")); const IntegrationConfiguration = tslib_1.__importStar(require("../resources/configuration")); const fileSystem_1 = require("../resources/fileSystem"); const integrationsPlatform_1 = require("../resources/integrationsPlatform"); class Publish extends baseCommand_1.BaseCommand { static description = 'Publish your integration'; static examples = ['<%= config.bin %> <%= command.id %>']; static flags = { environment: core_1.Flags.custom({ description: 'the environment of the platform', options: Object.values(GlobalConfiguration.Environment), default: GlobalConfiguration.Environment.Production, })(), 'registry-only': core_1.Flags.boolean({ description: '(advanced) only update the registry', default: false, hidden: true, exclusive: ['preview', 'live-preview'], }), preview: core_1.Flags.boolean({ description: 'preview the integration - your integration will run and be privately accessible on the target environment', default: false, exclusive: ['registry-only', 'live-preview'], }), 'live-preview': core_1.Flags.boolean({ description: 'live-preview the integration - Useful to iterate quickly during development, your integration runs locally and is privately accessible on the target environment', default: false, exclusive: ['registry-only', 'preview'], }), 'config-path': core_1.Flags.string({ summary: 'relative path to a custom ".unito.json" file', description: `Use a custom configuration file instead of the default '.unito.json' or other environment specific ones. If you want to force the CLI to use a specific configuration file, you can use this flag to specify the relative path from your integration's root folder (with a leading '/'). Usage: <%= config.bin %> <%= command.id %> --config-path=/myCustomConfig.json`, }), force: core_1.Flags.boolean({ description: 'bypass the confirmation prompt and force the command to run - this is useful for CI/CD pipelines', default: false, hidden: true, }), }; async catch(error) { /* istanbul ignore if */ if ((0, errors_1.handleError)(this, error)) { this.exit(-1); } throw error; } async run() { (0, integrations_1.validateIsIntegrationDirectory)(); const { flags } = await this.parse(Publish); // Read the configurations. const globalConfiguration = await GlobalConfiguration.read(this.config.configDir); const environment = flags.environment ?? GlobalConfiguration.Environment.Production; const integrationConfiguration = await IntegrationConfiguration.getConfiguration(environment, flags['config-path']); if (integrationConfiguration.authorizations) { integrationConfiguration.authorizations = integrationConfiguration.authorizations.filter(authorization => !authorization.development); } // Login to the platform. await (0, integrationsPlatform_1.validateAuthenticated)(globalConfiguration, environment); // Package the integration. tmp_1.default.setGracefulCleanup(); if (flags['live-preview']) { await this.livePreviewIntegration(integrationConfiguration); } else if (flags['registry-only']) { let proceed = true; if (!flags.force && environment === GlobalConfiguration.Environment.Production) { ({ proceed } = await inquirer_1.default.prompt({ name: 'proceed', message: '🙈🔫 You are about to update an integration in production directly. This is unusual, you should go through the normal publish and review process, are you sure you want to proceed?', type: 'confirm', })); } if (proceed) { core_1.ux.log(`🙈 Hang on tight, updating Integration ${integrationConfiguration.name} information's in ${environment}!`); await this.updateRegistry(integrationConfiguration); } else { core_1.ux.log('🙊 Abort! You are safe now 🍸 relax'); } } else { // Switch to a temporary directory to avoid modifying local files. this.copyAndMoveToTmpDir(); if (flags.preview) { await this.previewIntegration(integrationConfiguration); } else { await this.publishIntegration(integrationConfiguration); } } } async livePreviewIntegration(integrationConfiguration) { // Get the profile. core_1.ux.action.start('Get profile', undefined, { stdout: true }); const profile = await IntegrationsPlatform.getProfile(); core_1.ux.action.stop(); // Generate a new name. const newName = `${integrationConfiguration.name}-live-preview-${this.hashEmail(profile.email)}`; // Update the secrets. const updatedConfiguration = await this.generateNewSecrets(integrationConfiguration, newName); // Update the name. updatedConfiguration.name = newName; // Update the display name. if (updatedConfiguration.ui?.displayName) { updatedConfiguration.ui.displayName = [ updatedConfiguration.ui.displayName, 'Live Preview', this.hashEmail(profile.email), ].join(' - '); } // Start ngrok & update the base url. try { core_1.ux.action.start('Starting ngrok'); const listener = await ngrok_1.default.forward({ addr: 9200, authtoken_from_env: true }); const url = listener.url(); if (url) { updatedConfiguration.baseUrl = url; core_1.ux.log(`Ngrok started at: ${url}`); } else { throw new Error('Ngrok failed to start: URL is null'); } core_1.ux.action.stop(); } catch (error) { core_1.ux.action.stop(); console.error('Failed to start ngrok:', error); } // Update the registry. await this.updateRegistry(updatedConfiguration); // Launch the process. core_1.ux.log('Launching the integration'); child_process_1.default.spawn('npm', ['run', 'dev'], { stdio: 'inherit', env: { ...process.env, NODE_ENV: 'development', PORT: '9200' }, }); } async updateRegistry(integrationConfiguration) { let integration; try { integration = await IntegrationsPlatform.getIntegrationByName(integrationConfiguration.name); } catch (error) { if (error instanceof IntegrationsPlatform.HttpError && error.status === 403) { core_1.ux.log(chalk_1.default.redBright(`Access Denied! You do not have access to this integration.`)); this.exit(-1); } else { integration = undefined; } } // Create or update the integration. let updated; for (const authorization of integrationConfiguration.authorizations ?? []) { authorization.instructionsImage = await this.getInstructionsImage(authorization.name); authorization.instructionsMarkdown = await this.getInstructionsMarkdown(authorization.name); } if (integration) { core_1.ux.action.start('Integration found. Updating', undefined, { stdout: true }); const configurationPayload = { ...integrationConfiguration, }; if (integration.archivedAt) { core_1.ux.action.start(`Integration previously archived on ${integration.archivedAt}. Un-archiving`, undefined, { stdout: true, }); configurationPayload.archived = false; } updated = await IntegrationsPlatform.updateIntegration(integration.id, configurationPayload); } else { core_1.ux.action.start('Integration not found. Creating', undefined, { stdout: true }); updated = await IntegrationsPlatform.createIntegration(integrationConfiguration); } core_1.ux.action.stop(); // Summary of the operation. core_1.ux.log(''); core_1.ux.log(`The integration was ${integration ? 'updated' : 'created'} with the following information:`); core_1.ux.log(''); core_1.ux.log((0, json_colorizer_1.default)(this.indent(JSON.stringify(updated, null, 2)))); core_1.ux.log(''); core_1.ux.log('The authorization instructions were updated as follow:'); core_1.ux.log(''); for (const authorization of integrationConfiguration.authorizations ?? []) { core_1.ux.log(` ${authorization.name}:`); core_1.ux.log(` image: ${authorization.instructionsImage ? '<set>' : '<unset>'}`); core_1.ux.log(` markdown: ${authorization.instructionsMarkdown ? '<set>' : '<unset>'}`); } core_1.ux.log(''); } copyAndMoveToTmpDir() { // Copy integration in tmp folder. core_1.ux.action.start('Packaging', undefined, { stdout: true }); const archivePath = this.archiveIntegration(); const tmpDir = tmp_1.default.dirSync(); child_process_1.default.execSync(`unzip ${archivePath} -d ${tmpDir.name}`); // Change the working directory. process.chdir(tmpDir.name); child_process_1.default.execSync('git init . --initial-branch=main'); } async previewIntegration(integrationConfiguration) { // Get the profile. core_1.ux.action.start('Get profile', undefined, { stdout: true }); const profile = await IntegrationsPlatform.getProfile(); core_1.ux.action.stop(); // Generate a new name. const newName = `${integrationConfiguration.name}-preview-${this.hashEmail(profile.email)}`; // Update the secrets. const updatedConfiguration = await this.generateNewSecrets(integrationConfiguration, newName); core_1.ux.action.start('Creating preview', undefined, { stdout: true }); // Update the name. updatedConfiguration.name = newName; // Update the display name. if (updatedConfiguration.ui?.displayName) { updatedConfiguration.ui.displayName = [ updatedConfiguration.ui.displayName, 'Preview', this.hashEmail(profile.email), ].join(' - '); } // Write the updated configuration on disk. await IntegrationConfiguration.writeConfiguration(updatedConfiguration); core_1.ux.action.stop(); // Publish the integration. await this.publishIntegration(updatedConfiguration); } async publishIntegration(integrationConfiguration) { // Packaging the integration. core_1.ux.action.start('Packaging', undefined, { stdout: true }); // Write the final configuration on disk. await IntegrationConfiguration.writeConfiguration(integrationConfiguration); const archivePath = this.archiveIntegration(); core_1.ux.action.stop(); // Publish the integration. core_1.ux.action.start('Publishing', undefined, { stdout: true }); await IntegrationsPlatform.publishIntegration(archivePath); core_1.ux.action.stop(); // Summary of the operation. core_1.ux.log(''); core_1.ux.log(['Your integration', gradient.fruit(integrationConfiguration.name), 'is being published.'].join(' ')); core_1.ux.log("Note that if the code of your integration did not change, it won't be republished."); core_1.ux.log(chalk_1.default.blueBright(`To follow the publication, execute ${chalk_1.default.yellowBright('integration-cli activity --follow')}.`)); } indent(paragraph, tabs = 1) { return paragraph .split('\n') .map(line => [' '.repeat(Math.max(0, tabs) * 2), line].join('')) .join('\n'); } archiveIntegration() { const archivePath = tmp_1.default.tmpNameSync({ postfix: `.zip` }); child_process_1.default.execSync(`git ls-files ':!:docs/' ':!:test/' --cached --others --exclude-standard | zip -@ ${archivePath}`, { cwd: process.cwd(), env: { ...process.env }, }); core_1.ux.log(chalk_1.default.yellowBright("Note: the 'docs' and 'test' folders are excluded from the published package.")); return archivePath; } hashEmail(email) { return crypto_1.default.createHash('shake256', { outputLength: 2 }).update(email).digest('hex'); } async generateNewSecrets(integrationConfiguration, newIntegrationName) { core_1.ux.action.start(`Reencrypting with name ${newIntegrationName}`, undefined, { stdout: true }); const updatedConfiguration = JSON.parse(JSON.stringify(integrationConfiguration)); // deep copy. for (const authorization of updatedConfiguration.authorizations ?? []) { /* istanbul ignore if */ if (!authorization.oauth2) { continue; } for (const [fieldName, value] of Object.entries(authorization.oauth2)) { const newlyEncryptedData = await this.reencryptData(integrationConfiguration.name, [authorization.name, fieldName].join(':'), value, newIntegrationName); if (newlyEncryptedData) { authorization.oauth2[fieldName] = newlyEncryptedData; } } } for (const [key, secret] of Object.entries(updatedConfiguration.secrets ?? {})) { const newlyEncryptedData = await this.reencryptData(integrationConfiguration.name, `secrets:${key}`, secret, newIntegrationName); if (newlyEncryptedData) { updatedConfiguration.secrets[key] = newlyEncryptedData; } } core_1.ux.action.stop(); return updatedConfiguration; } async reencryptData(originalIntegrationName, key, encryptedData, newIntegrationName) { if (typeof encryptedData !== 'string') { return; } if (!encryptedData.startsWith('unito-secret-')) { core_1.ux.log(`Skipping ${key}`); return; } core_1.ux.log(`Reencrypting ${key}`); const { encryptedData: reencryptedData } = await IntegrationsPlatform.reencryptData(originalIntegrationName, encryptedData, newIntegrationName); return reencryptedData; } async getInstructionsImage(authName) { const extensions = ['png', 'jpg', 'jpeg']; for (const ext of extensions) { const fileName = [process.cwd(), 'assets', `instructions.${authName}.${ext}`].join('/'); const fileBuffer = await (0, fileSystem_1.getFileBuffer)(fileName, 400 * 1024); // 400 KB size limit if (fileBuffer) { const mimeType = ext === 'png' ? 'image/png' : 'image/jpeg'; return `data:${mimeType};base64,${fileBuffer.toString('base64')}`; } } return null; } async getInstructionsMarkdown(authName) { const fileName = [process.cwd(), 'assets', `instructions.${authName}.md`].join('/'); const fileBuffer = await (0, fileSystem_1.getFileBuffer)(fileName, 64 * 1024); // 64 KB size limit return fileBuffer?.toString('utf8') ?? null; } } exports.default = Publish;