netlify-cli
Version:
Netlify command line tool
610 lines • 29.6 kB
JavaScript
import { existsSync } from 'fs';
import { join, relative, resolve } from 'path';
import process from 'process';
import { format } from 'util';
import { DefaultLogger, Project } from '@netlify/build-info';
import { NodeFS, NoopLogger } from '@netlify/build-info/node';
import { resolveConfig } from '@netlify/config';
import { isCI } from 'ci-info';
import { Command, Option } from 'commander';
import debug from 'debug';
import { findUp } from 'find-up';
import inquirer from 'inquirer';
import inquirerAutocompletePrompt from 'inquirer-autocomplete-prompt';
import merge from 'lodash/merge.js';
import { NetlifyAPI } from 'netlify';
import { getAgent } from '../lib/http-agent.js';
import { NETLIFY_CYAN, USER_AGENT, chalk, logAndThrowError, exit, getToken, log, version, normalizeConfig, padLeft, pollForToken, sortOptions, warn, logError, } from '../utils/command-helpers.js';
import { getFrameworksAPIPaths } from '../utils/frameworks-api.js';
import getGlobalConfigStore from '../utils/get-global-config-store.js';
import { getSiteByName } from '../utils/get-site.js';
import openBrowser from '../utils/open-browser.js';
import CLIState from '../utils/cli-state.js';
import { identify, reportError, track } from '../utils/telemetry/index.js';
// load the autocomplete plugin
inquirer.registerPrompt('autocomplete', inquirerAutocompletePrompt);
/** Netlify CLI client id. Lives in bot@netlify.com */
// TODO: setup client for multiple environments
const CLIENT_ID = 'd6f37de6614df7ae58664cfca524744d73807a377f5ee71f1a254f78412e3750';
const NANO_SECS_TO_MSECS = 1e6;
/** The fallback width for the help terminal */
const FALLBACK_HELP_CMD_WIDTH = 80;
const HELP_$ = NETLIFY_CYAN('$');
/** indent on commands or description on the help page */
const HELP_INDENT_WIDTH = 2;
/** separator width between term and description */
const HELP_SEPARATOR_WIDTH = 5;
/**
* A list of commands where we don't have to perform the workspace selection at.
* Those commands work with the system or are not writing any config files that need to be
* workspace aware.
*/
const COMMANDS_WITHOUT_WORKSPACE_OPTIONS = new Set(['api', 'recipes', 'completion', 'status', 'switch', 'login']);
/**
* A list of commands where we need to fetch featureflags for config resolution
*/
const COMMANDS_WITH_FEATURE_FLAGS = new Set(['build', 'dev', 'deploy']);
/** Formats a help list correctly with the correct indent */
const formatHelpList = (textArray) => textArray.join('\n').replace(/^/gm, ' '.repeat(HELP_INDENT_WIDTH));
/** Get the duration between a start time and the current time */
const getDuration = (startTime) => {
const durationNs = process.hrtime.bigint() - startTime;
return Math.round(Number(durationNs / BigInt(NANO_SECS_TO_MSECS)));
};
/**
* Retrieves a workspace package based of the filter flag that is provided.
* If the filter flag does not match a workspace package or is not defined then it will prompt with an autocomplete to select a package
*/
async function selectWorkspace(project, filter) {
// don't show prompt for workspace selection if there is only one package
if (project.workspace?.packages && project.workspace.packages.length === 1) {
return project.workspace.packages[0].path;
}
const selected = project.workspace?.packages.find((pkg) => {
if (project.relativeBaseDirectory &&
project.relativeBaseDirectory.length !== 0 &&
pkg.path.startsWith(project.relativeBaseDirectory)) {
return true;
}
return (pkg.name && pkg.name === filter) || pkg.path === filter;
});
if (!selected) {
log();
log(chalk.cyan(`We've detected multiple sites inside your repository`));
if (isCI) {
throw new Error(`Sites detected: ${(project.workspace?.packages || [])
.map((pkg) => pkg.name || pkg.path)
.join(', ')}. Configure the site you want to work with and try again. Refer to https://ntl.fyi/configure-site for more information.`);
}
const { result } = await inquirer.prompt({
name: 'result',
// @ts-expect-error(serhalp) -- I think this is because `inquirer-autocomplete-prompt` extends known
// `type`s but TS doesn't know about it
type: 'autocomplete',
message: 'Select the site you want to work with',
source: (_unused, input = '') => (project.workspace?.packages || [])
.filter((pkg) => pkg.path.includes(input))
.map((pkg) => ({
name: `${pkg.name ? `${chalk.bold(pkg.name)} ` : ''}${pkg.path} ${chalk.dim(`--filter ${pkg.name || pkg.path}`)}`,
value: pkg.path,
})),
});
return result;
}
return selected.path;
}
async function getRepositoryRoot(cwd) {
const res = await findUp('.git', { cwd, type: 'directory' });
if (res) {
return join(res, '..');
}
}
/** Base command class that provides tracking and config initialization */
export default class BaseCommand extends Command {
/** The netlify object inside each command with the state */
netlify;
// TODO(serhalp) We set `startTime` here and then overwrite it in a `preAction` hook. This is
// just asking for latent bugs. Remove this one?
analytics = { startTime: process.hrtime.bigint() };
project;
/**
* The working directory that is used for reading the `netlify.toml` file and storing the state.
* In a monorepo context this must not be the process working directory and can be an absolute path to the
* Package/Site that should be worked in.
*/
// here we actually want to disable the lint rule as its value is set
// eslint-disable-next-line no-restricted-properties
workingDir = process.cwd();
/**
* The workspace root if inside a mono repository.
* Must not be the repository root!
*/
jsWorkspaceRoot;
/** The current workspace package we should execute the commands in */
workspacePackage;
featureFlags = {};
siteId;
accountId;
/**
* IMPORTANT this function will be called for each command!
* Don't do anything expensive in there.
*/
createCommand(name) {
const base = new BaseCommand(name)
// .addOption(new Option('--force', 'Force command to run. Bypasses prompts for certain destructive commands.'))
.addOption(new Option('--silent', 'Silence CLI output').hideHelp(true))
.addOption(new Option('--cwd <cwd>').hideHelp(true))
.addOption(new Option('--auth <token>', 'Netlify auth token - can be used to run this command without logging in'))
.addOption(new Option('--http-proxy [address]', 'Proxy server address to route requests through.')
.default(process.env.HTTP_PROXY || process.env.HTTPS_PROXY)
.hideHelp(true))
.addOption(new Option('--http-proxy-certificate-filename [file]', 'Certificate file to use when connecting using a proxy server')
.default(process.env.NETLIFY_PROXY_CERTIFICATE_FILENAME)
.hideHelp(true))
.option('--debug', 'Print debugging information');
// only add the `--filter` option to commands that are workspace aware
if (!COMMANDS_WITHOUT_WORKSPACE_OPTIONS.has(name)) {
base.option('--filter <app>', 'For monorepos, specify the name of the application to run the command in');
}
return base.hook('preAction', async (_parentCommand, actionCommand) => {
if (actionCommand.opts()?.debug) {
process.env.DEBUG = '*';
}
debug(`${name}:preAction`)('start');
this.analytics.startTime = process.hrtime.bigint();
await this.init(actionCommand);
debug(`${name}:preAction`)('end');
});
}
#noBaseOptions = false;
/** don't show help options on command overview (mostly used on top commands like `addons` where options only apply on children) */
noHelpOptions() {
this.#noBaseOptions = true;
return this;
}
/** The examples list for the command (used inside doc generation and help page) */
examples = [];
/** Set examples for the command */
addExamples(examples) {
this.examples = examples;
return this;
}
/** Overrides the help output of commander with custom styling */
createHelp() {
const help = super.createHelp();
help.commandUsage = (command) => {
const term = this.name() === 'netlify'
? `${HELP_$} ${command.name()} [COMMAND]`
: `${HELP_$} ${command.parent?.name()} ${command.name()} ${command.usage()}`;
return padLeft(term, HELP_INDENT_WIDTH);
};
const getCommands = (command) => {
const parentCommand = this.name() === 'netlify' ? command : command.parent;
return (parentCommand?.commands
.filter((cmd) => {
if (cmd._hidden)
return false;
// the root command
if (this.name() === 'netlify') {
// don't include subcommands on the main page
return !cmd.name().includes(':');
}
return cmd.name().startsWith(`${command.name()}:`);
})
.sort((a, b) => a.name().localeCompare(b.name())) || []);
};
help.longestSubcommandTermLength = (command) => getCommands(command).reduce((max, cmd) => Math.max(max, cmd.name().length), 0);
/** override the longestOptionTermLength to react on hide options flag */
help.longestOptionTermLength = (command, helper) =>
// @ts-expect-error TS(2551) FIXME: Property 'noBaseOptions' does not exist on type 'C... Remove this comment to see the full error message
(command.noBaseOptions === false &&
helper.visibleOptions(command).reduce((max, option) => Math.max(max, helper.optionTerm(option).length), 0)) ||
0;
help.formatHelp = (command, helper) => {
const parentCommand = this.name() === 'netlify' ? command : command.parent;
const termWidth = helper.padWidth(command, helper);
const helpWidth = helper.helpWidth || FALLBACK_HELP_CMD_WIDTH;
// formats a term correctly
const formatItem = (term, description, isCommand = false) => {
const bang = isCommand ? `${HELP_$} ` : '';
if (description) {
const pad = termWidth + HELP_SEPARATOR_WIDTH;
const fullText = `${bang}${term.padEnd(pad - (isCommand ? 2 : 0))}${chalk.grey(description)}`;
return helper.wrap(fullText, helpWidth - HELP_INDENT_WIDTH, pad);
}
return `${bang}${term}`;
};
let output = [];
// Description
const [topDescription, ...commandDescription] = (helper.commandDescription(command) || '').split('\n');
if (topDescription.length !== 0) {
output = [...output, topDescription, ''];
}
// on the parent help command the version should be displayed
if (this.name() === 'netlify') {
output = [...output, chalk.bold('VERSION'), formatHelpList([formatItem(USER_AGENT)]), ''];
}
// Usage
output = [...output, chalk.bold('USAGE'), helper.commandUsage(command), ''];
// Arguments
const argumentList = helper
.visibleArguments(command)
.map((argument) => formatItem(helper.argumentTerm(argument), helper.argumentDescription(argument)));
if (argumentList.length !== 0) {
output = [...output, chalk.bold('ARGUMENTS'), formatHelpList(argumentList), ''];
}
if (command.#noBaseOptions === false) {
// Options
const optionList = helper
.visibleOptions(command)
.sort(sortOptions)
.map((option) => formatItem(helper.optionTerm(option), helper.optionDescription(option)));
if (optionList.length !== 0) {
output = [...output, chalk.bold('OPTIONS'), formatHelpList(optionList), ''];
}
}
// Description
if (commandDescription.length !== 0) {
output = [...output, chalk.bold('DESCRIPTION'), formatHelpList(commandDescription), ''];
}
// Aliases
// @ts-expect-error TS(2551) FIXME: Property '_aliases' does not exist on type 'Comman... Remove this comment to see the full error message
if (command._aliases.length !== 0) {
// @ts-expect-error TS(2551) FIXME: Property '_aliases' does not exist on type 'Comman... Remove this comment to see the full error message
const aliases = command._aliases.map((alias) => formatItem(`${parentCommand.name()} ${alias}`, null, true));
output = [...output, chalk.bold('ALIASES'), formatHelpList(aliases), ''];
}
if (command.examples.length !== 0) {
output = [
...output,
chalk.bold('EXAMPLES'),
formatHelpList(command.examples.map((example) => `${HELP_$} ${example}`)),
'',
];
}
const commandList = getCommands(command).map((cmd) => formatItem(cmd.name(), helper.subcommandDescription(cmd).split('\n')[0], true));
if (commandList.length !== 0) {
output = [...output, chalk.bold('COMMANDS'), formatHelpList(commandList), ''];
}
return [...output, ''].join('\n');
};
return help;
}
/** Will be called on the end of an action to track the metrics */
async onEnd(error_) {
const { payload = {}, startTime } = this.analytics;
const duration = getDuration(startTime);
const status = error_ === undefined ? 'success' : 'error';
const command = Array.isArray(this.args) ? this.args[0] : this.name();
debug(`${this.name()}:onEnd`)(`Command: ${command}. Status: ${status}. Duration: ${duration}ms`);
try {
await track('command', {
...payload,
command,
duration,
status,
});
}
catch (err) {
debug(`${this.name()}:onEnd`)(`Command: ${command}. Telemetry tracking failed: ${err instanceof Error ? err.message : err?.toString()}`);
}
if (error_ !== undefined) {
logError(error_ instanceof Error ? error_ : format(error_));
exit(1);
}
}
async authenticate(tokenFromFlag) {
const [token] = await getToken(tokenFromFlag);
if (token) {
return token;
}
return this.expensivelyAuthenticate();
}
async expensivelyAuthenticate() {
const webUI = process.env.NETLIFY_WEB_UI || 'https://app.netlify.com';
log(`Logging into your Netlify account...`);
// Create ticket for auth
const ticket = await this.netlify.api.createTicket({
clientId: CLIENT_ID,
});
// Open browser for authentication
const authLink = `${webUI}/authorize?response_type=ticket&ticket=${ticket.id}`;
log(`Opening ${authLink}`);
await openBrowser({ url: authLink });
const accessToken = await pollForToken({
api: this.netlify.api,
ticket,
});
const { email, full_name: name, id: userId } = await this.netlify.api.getCurrentUser();
const userData = merge(this.netlify.globalConfig.get(`users.${userId}`), {
id: userId,
name,
email,
auth: {
token: accessToken,
github: {
user: undefined,
token: undefined,
},
},
});
// Set current userId
this.netlify.globalConfig.set('userId', userId);
// Set user data
this.netlify.globalConfig.set(`users.${userId}`, userData);
await identify({
name,
email,
userId,
});
await track('user_login', {
email,
});
// Log success
log();
log(chalk.greenBright('You are now logged into your Netlify account!'));
log();
log(`Run ${chalk.cyanBright('netlify status')} for account details`);
log();
log(`To see all available commands run: ${chalk.cyanBright('netlify help')}`);
log();
return accessToken;
}
/** Adds some data to the analytics payload */
setAnalyticsPayload(payload) {
this.analytics = {
...this.analytics,
payload: { ...this.analytics.payload, ...payload },
};
}
/**
* Initializes the options and parses the configuration needs to be called on start of a command function
*/
async init(actionCommand) {
debug(`${actionCommand.name()}:init`)('start');
const flags = actionCommand.opts();
// here we actually want to use the process.cwd as we are setting the workingDir
// eslint-disable-next-line no-restricted-properties
this.workingDir = flags.cwd ? resolve(flags.cwd) : process.cwd();
// ==================================================
// Create a Project and run the Heuristics to detect
// if we are running inside a monorepo or not.
// ==================================================
// retrieve the repository root
const rootDir = await getRepositoryRoot();
// Get framework, add to analytics payload for every command, if a framework is set
const fs = new NodeFS();
// disable logging inside the project and FS if not in debug mode
fs.logger = actionCommand.opts()?.debug ? new DefaultLogger('debug') : new NoopLogger();
this.project = new Project(fs, this.workingDir, rootDir)
.setEnvironment(process.env)
.setNodeVersion(process.version)
.setReportFn((err, reportConfig) => {
reportError(err, {
severity: reportConfig?.severity || 'error',
metadata: reportConfig?.metadata,
});
});
const frameworks = await this.project.detectFrameworks();
let packageConfig = flags.config ? resolve(flags.config) : undefined;
// check if we have detected multiple projects inside which one we have to perform our operations.
// only ask to select one if on the workspace root and no --cwd was provided
if (!flags.cwd &&
!COMMANDS_WITHOUT_WORKSPACE_OPTIONS.has(actionCommand.name()) &&
this.project.workspace?.packages.length &&
this.project.workspace.isRoot) {
this.workspacePackage = await selectWorkspace(this.project, actionCommand.opts().filter);
this.workingDir = join(this.project.jsWorkspaceRoot, this.workspacePackage);
}
if (this.project.workspace?.packages.length && !this.project.workspace.isRoot) {
// set the package path even though we are not in the workspace root
// as the build command will set the process working directory to the workspace root
this.workspacePackage = this.project.relativeBaseDirectory;
}
this.jsWorkspaceRoot = this.project.jsWorkspaceRoot;
// detect if a toml exists in this package.
const tomlFile = join(this.workingDir, 'netlify.toml');
if (!packageConfig && existsSync(tomlFile)) {
packageConfig = tomlFile;
}
// ==================================================
// Retrieve Site id and build state from the state.json
// ==================================================
const state = new CLIState(this.workingDir);
const [token] = await getToken(flags.auth);
const apiUrlOpts = {
userAgent: USER_AGENT,
};
if (process.env.NETLIFY_API_URL) {
const apiUrl = new URL(process.env.NETLIFY_API_URL);
apiUrlOpts.scheme = apiUrl.protocol.slice(0, -1);
apiUrlOpts.host = apiUrl.host;
apiUrlOpts.pathPrefix =
process.env.NETLIFY_API_URL === `${apiUrl.protocol}//${apiUrl.host}` ? '/api/v1' : apiUrl.pathname;
}
const agent = await getAgent({
httpProxy: flags.httpProxy,
certificateFile: flags.httpProxyCertificateFilename,
});
const apiOpts = { ...apiUrlOpts, agent };
const api = new NetlifyAPI(token ?? '', apiOpts);
actionCommand.siteId = flags.siteId || (typeof flags.site === 'string' && flags.site) || state.get('siteId');
const needsFeatureFlagsToResolveConfig = COMMANDS_WITH_FEATURE_FLAGS.has(actionCommand.name());
if (api.accessToken && !flags.offline && needsFeatureFlagsToResolveConfig && actionCommand.siteId) {
try {
// FIXME(serhalp): Remove `any` and fix errors. API types exist now.
const site = await api.getSite({ siteId: actionCommand.siteId, feature_flags: 'cli' });
actionCommand.featureFlags = site.feature_flags;
actionCommand.accountId = site.account_id;
}
catch {
// if the site is not found, that could mean that the user passed a site name, not an ID
}
}
// ==================================================
// Start retrieving the configuration through the
// configuration file and the API
// ==================================================
const cachedConfig = await actionCommand.getConfig({
cwd: flags.cwd ? this.workingDir : this.jsWorkspaceRoot || this.workingDir,
repositoryRoot: rootDir,
packagePath: this.workspacePackage,
// The config flag needs to be resolved from the actual process working directory
configFilePath: packageConfig,
token,
...apiUrlOpts,
});
const { accounts = [], buildDir, config, configPath, repositoryRoot, siteInfo } = cachedConfig;
let { env } = cachedConfig;
if (flags.offlineEnv) {
env = {};
}
env.NETLIFY_CLI_VERSION = { sources: ['internal'], value: version };
const normalizedConfig = normalizeConfig(config);
// If a user passes a site name as an option instead of a site ID to options.site, the siteInfo object
// will only have the property siteInfo.id. Checking for one of the other properties ensures that we can do
// a re-call of the api.getSite() that is done in @netlify/config so we have the proper site object in all
// commands.
// options.site as a site name (and not just site id) was introduced for the deploy command, so users could
// deploy by name along with by id
let siteData = siteInfo;
if (!siteData.url && flags.site) {
const result = await getSiteByName(api, flags.site);
if (result == null) {
return logAndThrowError(`Site with name "${flags.site}" not found`);
}
siteData = result;
}
const globalConfig = await getGlobalConfigStore();
// ==================================================
// Perform analytics reporting
// ==================================================
const frameworkIDs = frameworks?.map((framework) => framework.id);
if (frameworkIDs?.length !== 0) {
this.setAnalyticsPayload({ frameworks: frameworkIDs });
}
this.setAnalyticsPayload({
monorepo: Boolean(this.project.workspace),
packageManager: this.project.packageManager?.name,
buildSystem: this.project.buildSystems.map(({ id }) => id),
});
// set the project and the netlify api object on the command,
// to be accessible inside each command.
actionCommand.project = this.project;
actionCommand.workingDir = this.workingDir;
actionCommand.workspacePackage = this.workspacePackage;
actionCommand.jsWorkspaceRoot = this.jsWorkspaceRoot;
// Either an existing configuration file from `@netlify/config` or a file path
// that should be used for creating it.
const configFilePath = configPath || join(this.workingDir, 'netlify.toml');
actionCommand.netlify = {
accounts,
// api methods
api,
apiOpts,
// The absolute repository root (detected through @netlify/config)
repositoryRoot,
configFilePath,
relConfigFilePath: relative(repositoryRoot, configFilePath),
// current site context
site: {
root: buildDir,
configPath,
get id() {
return state.get('siteId');
},
set id(id) {
state.set('siteId', id);
},
},
// Site information retrieved using the API (api.getSite())
siteInfo: siteData,
// Configuration from netlify.[toml/yml]
config: normalizedConfig,
// Used to avoid calling @netlify/config again
cachedConfig: {
...cachedConfig,
env,
},
// global cli config
// TODO(serhalp): Rename to `globalConfigStore`
globalConfig,
// state of current site dir
// TODO(serhalp): Rename to `cliState`
state,
frameworksAPIPaths: getFrameworksAPIPaths(buildDir, this.workspacePackage),
};
debug(`${this.name()}:init`)('end');
}
/** Find and resolve the Netlify configuration */
async getConfig(opts) {
const { configFilePath, cwd, host, offline, packagePath, pathPrefix, repositoryRoot, scheme, token } = opts;
// the flags that are passed to the command like `--debug` or `--offline`
const flags = this.opts();
try {
// FIXME(serhalp): Type this in `netlify/build`! This is blocking a ton of proper types across the CLI.
return await resolveConfig({
accountId: this.accountId,
config: configFilePath,
packagePath: packagePath,
repositoryRoot: repositoryRoot,
cwd: cwd,
context: flags.context || process.env.CONTEXT || this.getDefaultContext(),
debug: flags.debug,
siteId: this.siteId,
token: token,
mode: 'cli',
host: host,
pathPrefix: pathPrefix,
scheme: scheme,
offline: offline ?? flags.offline,
siteFeatureFlagPrefix: 'cli',
featureFlags: this.featureFlags,
});
}
catch (error_) {
// @ts-expect-error TS(2571) FIXME: Object is of type 'unknown'.
const isUserError = error_.customErrorInfo !== undefined && error_.customErrorInfo.type === 'resolveConfig';
// If we're failing due to an error thrown by us, it might be because the token we're using is invalid.
// To account for that, we try to retrieve the config again, this time without a token, to avoid making
// any API calls.
//
// @todo Replace this with a mechanism for calling `resolveConfig` with more granularity (i.e. having
// the option to say that we don't need API data.)
if (isUserError && !offline && token) {
if (flags.debug) {
logError(error_);
warn('Failed to resolve config, falling back to offline resolution');
}
// recursive call with trying to resolve offline
return this.getConfig({ ...opts, offline: true });
}
// @ts-expect-error TS(2571) FIXME: Object is of type 'unknown'.
const message = isUserError ? error_.message : error_.stack;
return logAndThrowError(message);
}
}
/**
* get a path inside the `.netlify` project folder resolving with the workspace package
*/
getPathInProject(...paths) {
return join(this.workspacePackage || '', '.netlify', ...paths);
}
/**
* Returns the context that should be used in case one hasn't been explicitly
* set. The default context is `dev` most of the time, but some commands may
* wish to override that.
*/
getDefaultContext() {
return this.name() === 'serve' ? 'production' : 'dev';
}
/**
* Retrieve feature flags for this site
*/
getFeatureFlag(flagName) {
// @ts-expect-error(serhalp) -- FIXME(serhalp): This probably isn't what we intend.
// We should return `false` feature flags as `false` and not `null`. Carefully fix.
return this.netlify.siteInfo.feature_flags?.[flagName] || null;
}
}
//# sourceMappingURL=base-command.js.map