netlify-cli
Version:
Netlify command line tool
695 lines • 28.6 kB
JavaScript
import { stat } from 'fs/promises';
import { basename, resolve } from 'path';
import { runCoreSteps } from '@netlify/build';
import inquirer from 'inquirer';
import isEmpty from 'lodash/isEmpty.js';
import isObject from 'lodash/isObject.js';
import { parseAllHeaders } from '@netlify/headers-parser';
import { parseAllRedirects } from '@netlify/redirect-parser';
import prettyjson from 'prettyjson';
import { cancelDeploy } from '../../lib/api.js';
import { getRunBuildOptions, runBuild, } from '../../lib/build.js';
import { getBootstrapURL } from '../../lib/edge-functions/bootstrap.js';
import { featureFlags as edgeFunctionsFeatureFlags } from '../../lib/edge-functions/consts.js';
import { normalizeFunctionsConfig } from '../../lib/functions/config.js';
import { BACKGROUND_FUNCTIONS_WARNING } from '../../lib/log.js';
import { startSpinner, stopSpinner } from '../../lib/spinner.js';
import { detectFrameworkSettings, getDefaultConfig } from '../../utils/build-info.js';
import { NETLIFYDEV, NETLIFYDEVERR, NETLIFYDEVLOG, chalk, logAndThrowError, exit, getToken, log, logJson, warn, } from '../../utils/command-helpers.js';
import { DEFAULT_DEPLOY_TIMEOUT } from '../../utils/deploy/constants.js';
import { deploySite } from '../../utils/deploy/deploy-site.js';
import { getEnvelopeEnv } from '../../utils/env/index.js';
import { getFunctionsManifestPath, getInternalFunctionsDir } from '../../utils/functions/index.js';
import openBrowser from '../../utils/open-browser.js';
import { link } from '../link/link.js';
import { sitesCreate } from '../sites/sites-create.js';
const triggerDeploy = async ({ api, options, siteData, siteId, }) => {
try {
const siteBuild = await api.createSiteBuild({ siteId });
if (options.json) {
logJson({
site_id: siteId,
site_name: siteData.name,
deploy_id: `${siteBuild.deploy_id}`,
logs: `https://app.netlify.com/sites/${siteData.name}/deploys/${siteBuild.deploy_id}`,
});
}
else {
log(`${NETLIFYDEV} A new deployment was triggered successfully. Visit https://app.netlify.com/sites/${siteData.name}/deploys/${siteBuild.deploy_id} to see the logs.`);
}
}
catch (error_) {
if (error_.status === 404) {
return logAndThrowError('Site not found. Please rerun "netlify link" and make sure that your site has CI configured.');
}
else {
return logAndThrowError(error_.message);
}
}
};
/** Retrieves the folder containing the static files that need to be deployed */
const getDeployFolder = async ({ command, config, options, site, siteData, }) => {
let deployFolder;
// if the `--dir .` flag is provided we should resolve it to the working directory.
// - in regular sites this is the `process.cwd`
// - in mono repositories this will be the root of the jsWorkspace
if (options.dir) {
deployFolder = command.workspacePackage
? resolve(command.jsWorkspaceRoot || site.root, options.dir)
: resolve(command.workingDir, options.dir);
}
else if (config?.build?.publish) {
deployFolder = resolve(site.root, config.build.publish);
}
else if (siteData?.build_settings?.dir) {
deployFolder = resolve(site.root, siteData.build_settings.dir);
}
if (!deployFolder) {
log('Please provide a publish directory (e.g. "public" or "dist" or "."):');
const { promptPath } = await inquirer.prompt([
{
type: 'input',
name: 'promptPath',
message: 'Publish directory',
default: '.',
filter: (input) => resolve(command.workingDir, input),
},
]);
deployFolder = promptPath;
}
return deployFolder;
};
const validateDeployFolder = async (deployFolder) => {
let stats;
try {
stats = await stat(deployFolder);
}
catch (error_) {
if (error_ && typeof error_ === 'object' && 'code' in error_) {
if (error_.code === 'ENOENT') {
return logAndThrowError(`The deploy directory "${deployFolder}" has not been found. Did you forget to run 'netlify build'?`);
}
// Improve the message of permission errors
if (error_.code === 'EACCES') {
return logAndThrowError('Permission error when trying to access deploy folder');
}
}
throw error_;
}
if (!stats.isDirectory()) {
return logAndThrowError('Deploy target must be a path to a directory');
}
return stats;
};
/** get the functions directory */
const getFunctionsFolder = ({ config, options, site, siteData, workingDir, }) => {
let functionsFolder;
// Support "functions" and "Functions"
const funcConfig = config.functionsDirectory;
if (options.functions) {
functionsFolder = resolve(workingDir, options.functions);
}
else if (funcConfig) {
functionsFolder = resolve(site.root, funcConfig);
}
else if (siteData?.build_settings?.functions_dir) {
functionsFolder = resolve(site.root, siteData.build_settings.functions_dir);
}
return functionsFolder;
};
const validateFunctionsFolder = async (functionsFolder) => {
let stats;
if (functionsFolder) {
// we used to hard error if functions folder is specified but doesn't exist
// but this was too strict for onboarding. we can just log a warning.
try {
stats = await stat(functionsFolder);
}
catch (error_) {
if (error_ && typeof error_ === 'object' && 'code' in error_) {
if (error_.code === 'ENOENT') {
log(`Functions folder "${functionsFolder}" specified but it doesn't exist! Will proceed without deploying functions`);
}
// Improve the message of permission errors
if (error_.code === 'EACCES') {
return logAndThrowError('Permission error when trying to access functions folder');
}
}
}
}
if (stats && !stats.isDirectory()) {
return logAndThrowError('Functions folder must be a path to a directory');
}
return stats;
};
const validateFolders = async ({ deployFolder, functionsFolder, }) => {
const deployFolderStat = await validateDeployFolder(deployFolder);
const functionsFolderStat = await validateFunctionsFolder(functionsFolder);
return { deployFolderStat, functionsFolderStat };
};
/**
* @param {object} config
* @param {string} config.deployFolder
* @param {*} config.site
* @returns
*/
// @ts-expect-error TS(7031) FIXME: Binding element 'deployFolder' implicitly has an '... Remove this comment to see the full error message
const getDeployFilesFilter = ({ deployFolder, site }) => {
// site.root === deployFolder can happen when users run `netlify deploy --dir .`
// in that specific case we don't want to publish the repo node_modules
// when site.root !== deployFolder the behaviour matches our buildbot
const skipNodeModules = site.root === deployFolder;
return (filename) => {
if (filename == null) {
return false;
}
if (filename === deployFolder) {
return true;
}
const base = basename(filename);
const skipFile = (skipNodeModules && base === 'node_modules') ||
(base.startsWith('.') && base !== '.well-known') ||
base.startsWith('__MACOSX') ||
base.includes('/.') ||
// headers and redirects are bundled in the config
base === '_redirects' ||
base === '_headers';
return !skipFile;
};
};
const SEC_TO_MILLISEC = 1e3;
// 100 bytes
const SYNC_FILE_LIMIT = 1e2;
// @ts-expect-error TS(7031) FIXME: Binding element 'api' implicitly has an 'any' type... Remove this comment to see the full error message
const prepareProductionDeploy = async ({ api, siteData }) => {
if (isObject(siteData.published_deploy) && siteData.published_deploy.locked) {
log(`\n${NETLIFYDEVERR} Deployments are "locked" for production context of this site\n`);
const { unlockChoice } = await inquirer.prompt([
{
type: 'confirm',
name: 'unlockChoice',
message: 'Would you like to "unlock" deployments for production context to proceed?',
default: false,
},
]);
if (!unlockChoice)
exit(0);
await api.unlockDeploy({ deploy_id: siteData.published_deploy.id });
log(`\n${NETLIFYDEVLOG} "Auto publishing" has been enabled for production context\n`);
}
log('Deploying to main site URL...');
};
// @ts-expect-error TS(7006) FIXME: Parameter 'actual' implicitly has an 'any' type.
const hasErrorMessage = (actual, expected) => {
if (typeof actual === 'string') {
return actual.includes(expected);
}
return false;
};
// @ts-expect-error TS(7031) FIXME: Binding element 'error_' implicitly has an 'any' t... Remove this comment to see the full error message
const reportDeployError = ({ error_, failAndExit }) => {
switch (true) {
case error_.name === 'JSONHTTPError': {
const message = error_?.json?.message ?? '';
if (hasErrorMessage(message, 'Background Functions not allowed by team plan')) {
return failAndExit(`\n${BACKGROUND_FUNCTIONS_WARNING}`);
}
warn(`JSONHTTPError: ${message} ${error_.status}`);
warn(`\n${JSON.stringify(error_, null, ' ')}\n`);
failAndExit(error_);
return;
}
case error_.name === 'TextHTTPError': {
warn(`TextHTTPError: ${error_.status}`);
warn(`\n${error_}\n`);
failAndExit(error_);
return;
}
case hasErrorMessage(error_.message, 'Invalid filename'): {
warn(error_.message);
failAndExit(error_);
return;
}
default: {
warn(`\n${JSON.stringify(error_, null, ' ')}\n`);
failAndExit(error_);
}
}
};
const deployProgressCb = function () {
const spinnersByType = {};
return (event) => {
switch (event.phase) {
case 'start': {
spinnersByType[event.type] = startSpinner({
text: event.msg,
});
return;
}
case 'progress': {
const spinner = spinnersByType[event.type];
if (spinner) {
spinner.update({ text: event.msg });
}
return;
}
case 'error':
stopSpinner({ error: true, spinner: spinnersByType[event.type], text: event.msg });
delete spinnersByType[event.type];
return;
case 'stop':
default: {
stopSpinner({ spinner: spinnersByType[event.type], text: event.msg });
delete spinnersByType[event.type];
}
}
};
};
const uploadDeployBlobs = async ({ cachedConfig, deployId, options, packagePath, silent, siteId, }) => {
const statusCb = silent ? () => { } : deployProgressCb();
statusCb({
type: 'blobs-uploading',
msg: 'Uploading blobs to deploy store...\n',
phase: 'start',
});
const [token] = await getToken();
const blobsToken = token || undefined;
const { success } = await runCoreSteps(['blobs_upload'], {
...options,
quiet: silent,
// @ts-expect-error(serhalp) -- Untyped in `@netlify/build`
cachedConfig,
packagePath,
deployId,
siteId,
token: blobsToken,
});
if (!success) {
statusCb({
type: 'blobs-uploading',
msg: 'Deploy aborted due to error while uploading blobs to deploy store',
phase: 'error',
});
return logAndThrowError('Error while uploading blobs to deploy store');
}
statusCb({
type: 'blobs-uploading',
msg: 'Finished uploading blobs to deploy store',
phase: 'stop',
});
};
const runDeploy = async ({
// @ts-expect-error TS(7031) FIXME: Binding element 'alias' implicitly has an 'any' ty... Remove this comment to see the full error message
alias,
// @ts-expect-error TS(7031) FIXME: Binding element 'api' implicitly has an 'any' type... Remove this comment to see the full error message
api, command,
// @ts-expect-error TS(7031) FIXME: Binding element 'config' implicitly has an 'any' t... Remove this comment to see the full error message
config,
// @ts-expect-error TS(7031) FIXME: Binding element 'deployFolder' implicitly has an '... Remove this comment to see the full error message
deployFolder,
// @ts-expect-error TS(7031) FIXME: Binding element 'deployTimeout' implicitly has an ... Remove this comment to see the full error message
deployTimeout,
// @ts-expect-error TS(7031) FIXME: Binding element 'deployToProduction' implicitly ha... Remove this comment to see the full error message
deployToProduction,
// @ts-expect-error TS(7031) FIXME: Binding element 'functionsConfig' implicitly has a... Remove this comment to see the full error message
functionsConfig, functionsFolder,
// @ts-expect-error TS(7031) FIXME: Binding element 'options' implicitly has an 'a... Remove this comment to see the full error message
options,
// @ts-expect-error TS(7031) FIXME: Binding element 'packagePath' implicitly has an 'a... Remove this comment to see the full error message
packagePath,
// @ts-expect-error TS(7031) FIXME: Binding element 'silent' implicitly has an 'any' t... Remove this comment to see the full error message
silent,
// @ts-expect-error TS(7031) FIXME: Binding element 'site' implicitly has an 'any' typ... Remove this comment to see the full error message
site,
// @ts-expect-error TS(7031) FIXME: Binding element 'siteData' implicitly has an 'any'... Remove this comment to see the full error message
siteData,
// @ts-expect-error TS(7031) FIXME: Binding element 'siteId' implicitly has an 'any' t... Remove this comment to see the full error message
siteId,
// @ts-expect-error TS(7031) FIXME: Binding element 'skipFunctionsCache' implicitly ha... Remove this comment to see the full error message
skipFunctionsCache,
// @ts-expect-error TS(7031) FIXME: Binding element 'title' implicitly has an 'any' ty... Remove this comment to see the full error message
title, }) => {
let results;
let deployId;
try {
if (deployToProduction) {
await prepareProductionDeploy({ siteData, api });
}
else {
log('Deploying to draft URL...');
}
const draft = !deployToProduction && !alias;
results = await api.createSiteDeploy({ siteId, title, body: { draft, branch: alias } });
deployId = results.id;
const internalFunctionsFolder = await getInternalFunctionsDir({ base: site.root, packagePath, ensureExists: true });
await command.netlify.frameworksAPIPaths.functions.ensureExists();
// The order of the directories matter: zip-it-and-ship-it will prioritize
// functions from the rightmost directories. In this case, we want user
// functions to take precedence over internal functions.
const functionDirectories = [
internalFunctionsFolder,
command.netlify.frameworksAPIPaths.functions.path,
functionsFolder,
].filter((folder) => Boolean(folder));
const manifestPath = skipFunctionsCache ? null : await getFunctionsManifestPath({ base: site.root, packagePath });
const redirectsPath = `${deployFolder}/_redirects`;
const headersPath = `${deployFolder}/_headers`;
const { redirects } = await parseAllRedirects({
configRedirects: config.redirects,
redirectsFiles: [redirectsPath],
minimal: true,
});
config.redirects = redirects;
const { headers } = await parseAllHeaders({
configHeaders: config.headers,
headersFiles: [headersPath],
minimal: true,
});
config.headers = headers;
await uploadDeployBlobs({
deployId,
siteId,
silent,
options,
cachedConfig: command.netlify.cachedConfig,
packagePath: command.workspacePackage,
});
results = await deploySite(command, api, siteId, deployFolder, {
// @ts-expect-error FIXME
config,
fnDir: functionDirectories,
functionsConfig,
statusCb: silent ? () => { } : deployProgressCb(),
deployTimeout,
syncFileLimit: SYNC_FILE_LIMIT,
// pass an existing deployId to update
deployId,
filter: getDeployFilesFilter({ site, deployFolder }),
workingDir: command.workingDir,
manifestPath,
skipFunctionsCache,
siteRoot: site.root,
});
}
catch (error_) {
if (deployId) {
await cancelDeploy({ api, deployId });
}
reportDeployError({ error_, failAndExit: logAndThrowError });
}
const siteUrl = results.deploy.ssl_url || results.deploy.url;
const deployUrl = results.deploy.deploy_ssl_url || results.deploy.deploy_url;
const logsUrl = `${results.deploy.admin_url}/deploys/${results.deploy.id}`;
let functionLogsUrl = `${results.deploy.admin_url}/logs/functions`;
let edgeFunctionLogsUrl = `${results.deploy.admin_url}/logs/edge-functions`;
if (!deployToProduction) {
functionLogsUrl += `?scope=deploy:${deployId}`;
edgeFunctionLogsUrl += `?scope=deployid:${deployId}`;
}
return {
siteId: results.deploy.site_id,
siteName: results.deploy.name,
deployId: results.deployId,
siteUrl,
deployUrl,
logsUrl,
functionLogsUrl,
edgeFunctionLogsUrl,
};
};
const handleBuild = async ({ cachedConfig, currentDir, defaultConfig, deployHandler, options, packagePath, }) => {
if (!options.build) {
return {};
}
const [token] = await getToken();
const resolvedOptions = await getRunBuildOptions({
cachedConfig,
defaultConfig,
packagePath,
token,
options,
currentDir,
deployHandler,
});
const { configMutations, exitCode, newConfig } = await runBuild(resolvedOptions);
if (exitCode !== 0) {
exit(exitCode);
}
return { newConfig, configMutations };
};
/**
*
* @param {*} options Bundling options
* @returns
*/
// @ts-expect-error TS(7006) FIXME: Parameter 'options' implicitly has an 'any' type.
const bundleEdgeFunctions = async (options, command) => {
const argv = process.argv.slice(2);
const statusCb = options.silent || argv.includes('--json') || argv.includes('--silent') ? () => { } : deployProgressCb();
statusCb({
type: 'edge-functions-bundling',
msg: 'Bundling edge functions...\n',
phase: 'start',
});
const { severityCode, success } = await runCoreSteps(['edge_functions_bundling'], {
...options,
packagePath: command.workspacePackage,
buffer: true,
featureFlags: edgeFunctionsFeatureFlags,
edgeFunctionsBootstrapURL: await getBootstrapURL(),
});
if (!success) {
statusCb({
type: 'edge-functions-bundling',
msg: 'Deploy aborted due to error while bundling edge functions',
phase: 'error',
});
exit(severityCode);
}
statusCb({
type: 'edge-functions-bundling',
msg: 'Finished bundling edge functions',
phase: 'stop',
});
};
const printResults = ({ deployToProduction, isIntegrationDeploy, json, results, runBuildCommand, }) => {
const msgData = {
'Build logs': results.logsUrl,
'Function logs': results.functionLogsUrl,
'Edge function Logs': results.edgeFunctionLogsUrl,
};
if (deployToProduction) {
msgData['Unique deploy URL'] = results.deployUrl;
msgData['Website URL'] = results.siteUrl;
}
else {
msgData['Website draft URL'] = results.deployUrl;
}
// Spacer
log();
// Json response for piping commands
if (json) {
const jsonData = {
site_id: results.siteId,
site_name: results.siteName,
deploy_id: results.deployId,
deploy_url: results.deployUrl,
logs: results.logsUrl,
function_logs: results.functionLogsUrl,
edge_function_logs: results.edgeFunctionLogsUrl,
};
if (deployToProduction) {
jsonData.url = results.siteUrl;
}
logJson(jsonData);
exit(0);
}
else {
log(prettyjson.render(msgData));
if (!deployToProduction) {
log();
log('If everything looks good on your draft URL, deploy it to your main site URL with the --prod flag.');
log(chalk.cyanBright.bold(`netlify ${isIntegrationDeploy ? 'integration:' : ''}deploy${runBuildCommand ? ' --build' : ''} --prod`));
log();
}
}
};
const prepAndRunDeploy = async ({
// @ts-expect-error TS(7031) FIXME: Binding element 'api' implicitly has an 'any' type... Remove this comment to see the full error message
api,
// @ts-expect-error TS(7031) FIXME: Binding element 'command' implicitly has an 'any' ... Remove this comment to see the full error message
command,
// @ts-expect-error TS(7031) FIXME: Binding element 'config' implicitly has an 'any' t... Remove this comment to see the full error message
config,
// @ts-expect-error TS(7031) FIXME: Binding element 'deployToProduction' implicitly ha... Remove this comment to see the full error message
deployToProduction,
// @ts-expect-error TS(7031) FIXME: Binding element 'options' implicitly has an 'any' ... Remove this comment to see the full error message
options,
// @ts-expect-error TS(7031) FIXME: Binding element 'site' implicitly has an 'any' typ... Remove this comment to see the full error message
site,
// @ts-expect-error TS(7031) FIXME: Binding element 'siteData' implicitly has an 'any'... Remove this comment to see the full error message
siteData,
// @ts-expect-error TS(7031) FIXME: Binding element 'siteId' implicitly has an 'any' t... Remove this comment to see the full error message
siteId,
// @ts-expect-error TS(7031) FIXME: Binding element 'workingDir' implicitly has an 'an... Remove this comment to see the full error message
workingDir, }) => {
const alias = options.alias || options.branch;
// if a context is passed besides dev, we need to pull env vars from that specific context
if (options.context && options.context !== 'dev') {
command.netlify.cachedConfig.env = await getEnvelopeEnv({
api,
context: options.context,
env: command.netlify.cachedConfig.env,
siteInfo: siteData,
});
}
const deployFolder = await getDeployFolder({ command, options, config, site, siteData });
const functionsFolder = getFunctionsFolder({ workingDir, options, config, site, siteData });
const { configPath } = site;
const edgeFunctionsConfig = command.netlify.config.edge_functions;
// build flag wasn't used and edge functions exist
if (!options.build && edgeFunctionsConfig && edgeFunctionsConfig.length !== 0) {
await bundleEdgeFunctions(options, command);
}
log(prettyjson.render({
'Deploy path': deployFolder,
'Functions path': functionsFolder,
'Configuration path': configPath,
}));
const { functionsFolderStat } = await validateFolders({
deployFolder,
functionsFolder,
});
const siteEnv = await getEnvelopeEnv({
api,
context: options.context,
env: command.netlify.cachedConfig.env,
raw: true,
scope: 'functions',
siteInfo: siteData,
});
const functionsConfig = normalizeFunctionsConfig({
functionsConfig: config.functions,
projectRoot: site.root,
siteEnv,
});
const results = await runDeploy({
// @ts-expect-error FIXME
alias,
api,
command,
config,
deployFolder,
deployTimeout: options.timeout * SEC_TO_MILLISEC || DEFAULT_DEPLOY_TIMEOUT,
deployToProduction,
functionsConfig,
// pass undefined functionsFolder if doesn't exist
functionsFolder: functionsFolderStat && functionsFolder,
options,
packagePath: command.workspacePackage,
silent: options.json || options.silent,
site,
siteData,
siteId,
skipFunctionsCache: options.skipFunctionsCache,
title: options.message,
});
return results;
};
export const deploy = async (options, command) => {
const { workingDir } = command;
const { api, site, siteInfo } = command.netlify;
const alias = options.alias || options.branch;
command.setAnalyticsPayload({ open: options.open, prod: options.prod, json: options.json, alias: Boolean(alias) });
await command.authenticate(options.auth);
let initialSiteData;
let newSiteData;
const hasSiteData = (site.id || options.site) && !isEmpty(siteInfo);
if (hasSiteData) {
initialSiteData = siteInfo;
}
else {
log("This folder isn't linked to a site yet");
const NEW_SITE = '+ Create & configure a new site';
const EXISTING_SITE = 'Link this directory to an existing site';
const initializeOpts = [EXISTING_SITE, NEW_SITE];
const { initChoice } = await inquirer.prompt([
{
type: 'list',
name: 'initChoice',
message: 'What would you like to do?',
choices: initializeOpts,
},
]);
// create site or search for one
switch (initChoice) {
case NEW_SITE:
newSiteData = await sitesCreate({}, command);
site.id = newSiteData.id;
break;
case EXISTING_SITE:
newSiteData = await link({}, command);
site.id = newSiteData.id;
break;
}
}
// This is the best I could come up with to make TS happy with the complexities above.
const siteData = initialSiteData ?? newSiteData;
const siteId = siteData.id;
if (options.trigger) {
return triggerDeploy({ api, options, siteData, siteId });
}
const deployToProduction = options.prod || (options.prodIfUnlocked && !(siteData.published_deploy?.locked ?? false));
let results = {};
if (options.build) {
const settings = await detectFrameworkSettings(command, 'build');
await handleBuild({
packagePath: command.workspacePackage,
cachedConfig: command.netlify.cachedConfig,
defaultConfig: getDefaultConfig(settings),
currentDir: command.workingDir,
options,
deployHandler: async ({ netlifyConfig }) => {
results = await prepAndRunDeploy({
command,
options,
workingDir,
api,
site,
config: netlifyConfig,
siteData,
siteId,
deployToProduction,
});
return { newEnvChanges: { DEPLOY_ID: results.deployId, DEPLOY_URL: results.deployUrl } };
},
});
}
else {
results = await prepAndRunDeploy({
command,
options,
workingDir,
api,
site,
config: command.netlify.config,
siteData,
siteId,
deployToProduction,
});
}
const isIntegrationDeploy = command.name() === 'integration:deploy';
printResults({
runBuildCommand: options.build,
isIntegrationDeploy,
json: options.json,
results,
deployToProduction,
});
if (options.open) {
const urlToOpen = deployToProduction ? results.siteUrl : results.deployUrl;
await openBrowser({ url: urlToOpen });
exit();
}
};
//# sourceMappingURL=deploy.js.map