UNPKG

@shopify/cli-kit

Version:

A set of utilities, interfaces, and models that are common across all the platform features

269 lines (267 loc) • 12 kB
import { errorHandler, registerCleanBugsnagErrorsFromWithinPlugins } from './error-handler.js'; import { loadEnvironment, environmentFilePath } from './environments.js'; import { isDevelopment } from './context/local.js'; import { addPublicMetadata } from './metadata.js'; import { AbortError } from './error.js'; import { renderInfo, renderWarning } from './ui.js'; import { outputContent, outputResult, outputToken } from './output.js'; import { terminalSupportsPrompting } from './system.js'; import { hashString } from './crypto.js'; import { isTruthy } from './context/utilities.js'; import { showNotificationsIfNeeded } from './notifications-system.js'; import { setCurrentCommandId } from './global-context.js'; import { underscore } from '../common/string.js'; import { Command, Errors } from '@oclif/core'; class BaseCommand extends Command { // Replace markdown links to plain text like: "link label" (url) static descriptionWithoutMarkdown() { // eslint-disable-next-line @typescript-eslint/no-explicit-any return (this.descriptionWithMarkdown ?? '').replace(/(\[)(.*?)(])(\()(.*?)(\))/gm, '"$2" ($5)'); } static analyticsNameOverride() { return undefined; } static analyticsStopCommand() { return undefined; } async catch(error) { error.skipOclifErrorHandling = true; await errorHandler(error, this.config); return Errors.handle(error); } async init() { this.exitWithTimestampWhenEnvVariablePresent(); setCurrentCommandId(this.id ?? ''); if (!isDevelopment()) { // This function runs just prior to `run` await registerCleanBugsnagErrorsFromWithinPlugins(this.config); } await removeDuplicatedPlugins(this.config); this.showNpmFlagWarning(); await showNotificationsIfNeeded(); return super.init(); } // NPM creates an environment variable for every flag passed to a script. // This function checks for the presence of any of the available CLI flags // and warns the user to use the `--` separator. showNpmFlagWarning() { const commandVariables = this.constructor; const commandFlags = Object.keys(commandVariables.flags || {}); const possibleNpmEnvVars = commandFlags.map((key) => `npm_config_${underscore(key).replace(/^no_/, '')}`); if (possibleNpmEnvVars.some((flag) => process.env[flag] !== undefined)) { renderWarning({ body: [ 'NPM scripts require an extra', { command: '--' }, 'separator to pass the flags. Example:', { command: 'npm run dev -- --reset' }, ], }); } } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types exitWithTimestampWhenEnvVariablePresent() { if (isTruthy(process.env.SHOPIFY_CLI_ENV_STARTUP_PERFORMANCE_RUN)) { outputResult(` SHOPIFY_CLI_TIMESTAMP_START { "timestamp": ${Date.now()} } SHOPIFY_CLI_TIMESTAMP_END `); process.exit(0); } } async parse(options, argv) { let result = await super.parse(options, argv); result = await this.resultWithEnvironment(result, options, argv); await addFromParsedFlags(result.flags); return { ...result, ...{ argv: result.argv } }; } environmentsFilename() { // To be re-implemented if needed return undefined; } failMissingNonTTYFlags(flags, requiredFlags) { if (terminalSupportsPrompting()) return; requiredFlags.forEach((name) => { if (!(name in flags)) { throw new AbortError(outputContent `Flag not specified: ${outputToken.cyan(name)} This flag is required in non-interactive terminal environments, such as a CI environment, or when piping input from another process.`, 'To resolve this, specify the option in the command, or run the command in an interactive environment such as your local terminal.'); } }); } async resultWithEnvironment(originalResult, options, argv) { const flags = originalResult.flags; const environmentsFileName = this.environmentsFilename(); if (!environmentsFileName) return originalResult; const environmentFileExists = await environmentFilePath(environmentsFileName, { from: flags.path }); // Handle both string and array cases for environment flag let environments = []; if (flags.environment) { environments = Array.isArray(flags.environment) ? flags.environment : [flags.environment]; } const environmentSpecified = environments.length > 0; // Noop if no environment file exists and none was specified if (!environmentFileExists && !environmentSpecified) return originalResult; // Noop if multiple environments were specified (let commands handle this) if (environmentSpecified && environments.length > 1) return originalResult; const { environment, isDefaultEnvironment } = await this.loadEnvironmentForCommand(flags.path, environmentsFileName, environments[0]); if (!environment) return originalResult; if (isDefaultEnvironment && !commandSupportsFlag(options?.flags, 'environment')) return originalResult; // Parse using noDefaultsOptions to derive a list of flags specified as // command-line arguments. const noDefaultsResult = await super.parse(noDefaultsOptions(options), argv); // Add the environment's settings to argv and pass them to `super.parse`. This // invokes oclif's validation system without breaking the oclif black box. // Replace the original result with this one. const result = await super.parse(options, [ // Need to specify argv default because we're merging with argsFromEnvironment. ...(argv ?? this.argv), ...argsFromEnvironment(environment, options, noDefaultsResult), ...(isDefaultEnvironment ? ['--environment', 'default'] : []), ]); // Report successful application of the environment. reportEnvironmentApplication(noDefaultsResult.flags, result.flags, isDefaultEnvironment ? 'default' : environments[0], environment); return result; } /** * Tries to load an environment to forward to the command. If no environment * is specified it will try to load a default environment. */ async loadEnvironmentForCommand(path, environmentsFileName, specifiedEnvironment) { if (specifiedEnvironment) { const environment = await loadEnvironment(specifiedEnvironment, environmentsFileName, { from: path }); return { environment, isDefaultEnvironment: false }; } const environment = await loadEnvironment('default', environmentsFileName, { from: path, silent: true }); return { environment, isDefaultEnvironment: true }; } } // eslint-disable-next-line @typescript-eslint/ban-types BaseCommand.baseFlags = {}; export async function addFromParsedFlags(flags) { await addPublicMetadata(() => ({ cmd_all_verbose: flags.verbose, cmd_all_path_override: flags.path !== undefined, cmd_all_path_override_hash: flags.path === undefined ? undefined : hashString(flags.path), })); } /** * Any flag which is: * * 1. Present in the final set of flags * 2. Specified in the environment * 3. Not specified by the user as a command line argument * * should be reported. * * It doesn't matter if the environment flag's value was the same as the default; from * the user's perspective, they want to know their environment was applied. */ function reportEnvironmentApplication(noDefaultsFlags, flagsWithEnvironments, environmentName, environment) { const changes = {}; for (const [name, value] of Object.entries(flagsWithEnvironments)) { const userSpecifiedThisFlag = Object.prototype.hasOwnProperty.call(noDefaultsFlags, name); const environmentContainsFlag = Object.prototype.hasOwnProperty.call(environment, name); if (!userSpecifiedThisFlag && environmentContainsFlag) { const valueToReport = name === 'password' ? `********${value.substr(-4)}` : value; changes[name] = valueToReport; } } if (Object.keys(changes).length === 0) return; const items = Object.entries(changes).map(([name, value]) => `${name}: ${value}`); renderInfo({ headline: ['Using applicable flags from', { userInput: environmentName }, 'environment:'], body: [{ list: { items } }], }); } /** * Strips the defaults from configured flags. For example, if flags contains: * * ``` * someFlag: Flags.boolean({ * description: 'some flag', * default: false * }) * ``` * * it becomes: * * ``` * someFlag: Flags.boolean({ * description: 'some flag' * }) * ``` * * If we parse using this configuration, the only specified flags will be those * the user actually passed on the command line. */ export function noDefaultsOptions(options) { if (!options?.flags) return options; return { ...options, flags: Object.fromEntries(Object.entries(options.flags).map(([label, settings]) => { const copiedSettings = { ...settings }; delete copiedSettings.default; return [label, copiedSettings]; })), }; } /** * Converts the environment's settings to arguments as though passed on the command * line, skipping any arguments the user specified on the command line. */ function argsFromEnvironment(environment, options, noDefaultsResult) { const args = []; for (const [label, value] of Object.entries(environment)) { const flagIsRelevantToCommand = commandSupportsFlag(options?.flags, label); const userSpecifiedThisFlag = noDefaultsResult.flags && Object.prototype.hasOwnProperty.call(noDefaultsResult.flags, label); if (flagIsRelevantToCommand && !userSpecifiedThisFlag) { if (typeof value === 'boolean') { if (value) { args.push(`--${label}`); } else { throw new AbortError(outputContent `Environments can only specify true for boolean flags. Attempted to set ${outputToken.yellow(label)} to false.`); } } else if (Array.isArray(value)) { value.forEach((element) => args.push(`--${label}`, `${element}`)); } else { args.push(`--${label}`, `${value}`); } } } return args; } function commandSupportsFlag(flags, flagName) { return Boolean(flags) && Object.prototype.hasOwnProperty.call(flags, flagName); } async function removeDuplicatedPlugins(config) { const plugins = Array.from(config.plugins.values()); const bundlePlugins = ['@shopify/app', '@shopify/plugin-cloudflare']; const pluginsToRemove = plugins.filter((plugin) => bundlePlugins.includes(plugin.name)); if (pluginsToRemove.length > 0) { const commandsToRun = pluginsToRemove.map((plugin) => ` - shopify plugins remove ${plugin.name}`).join('\n'); renderWarning({ headline: `Unsupported plugins detected: ${pluginsToRemove.map((plugin) => plugin.name).join(', ')}`, body: [ 'They are already included in the CLI and installing them as custom plugins can cause conflicts.', `You can fix it by running:\n${commandsToRun}`, ], }); } const filteredPlugins = plugins.filter((plugin) => !bundlePlugins.includes(plugin.name)); config.plugins = new Map(filteredPlugins.map((plugin) => [plugin.name, plugin])); } export default BaseCommand; //# sourceMappingURL=base-command.js.map