@shopify/cli-kit
Version:
A set of utilities, interfaces, and models that are common across all the platform features
251 lines (249 loc) • 11 kB
JavaScript
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);
}
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.
*/
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);
}
export default BaseCommand;
//# sourceMappingURL=base-command.js.map