diff --git a/bin/run.mjs b/bin/run.mjs index b0c7e6094c0..13a7cc9de07 100755 --- a/bin/run.mjs +++ b/bin/run.mjs @@ -4,6 +4,7 @@ import { argv } from 'process' import updateNotifier from 'update-notifier' import { createMainCommand } from '../src/commands/index.mjs' +import { error } from '../src/utils/command-helpers.mjs' import getPackageJson from '../src/utils/get-package-json.mjs' // 12 hours @@ -15,9 +16,9 @@ try { pkg, updateCheckInterval: UPDATE_CHECK_INTERVAL, }).notify() -} catch (error) { - console.log('Error checking for updates:') - console.log(error) +} catch (error_) { + error('Error checking for updates:') + error(error_) } const program = createMainCommand() @@ -25,6 +26,6 @@ const program = createMainCommand() try { await program.parseAsync(argv) program.onEnd() -} catch (error) { - program.onEnd(error) +} catch (error_) { + program.onEnd(error_) } diff --git a/src/commands/base-command.mjs b/src/commands/base-command.mjs index 5cfc06b0e21..dfc2596aea4 100644 --- a/src/commands/base-command.mjs +++ b/src/commands/base-command.mjs @@ -1,13 +1,18 @@ // @ts-check +import { existsSync } from 'fs' +import { join, relative, resolve } from 'path' import process from 'process' import { format } from 'util' -import { Project } from '@netlify/build-info' +import { DefaultLogger, Project } from '@netlify/build-info' // eslint-disable-next-line import/extensions, n/no-missing-import -import { NodeFS } from '@netlify/build-info/node' +import { NodeFS, NoopLogger } from '@netlify/build-info/node' import { resolveConfig } from '@netlify/config' 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' @@ -30,22 +35,31 @@ import getGlobalConfig from '../utils/get-global-config.mjs' import { getSiteByName } from '../utils/get-site.mjs' import openBrowser from '../utils/open-browser.mjs' import StateConfig from '../utils/state-config.mjs' -import { identify, track } from '../utils/telemetry/index.mjs' +import { identify, reportError, track } from '../utils/telemetry/index.mjs' -// Netlify CLI client id. Lives in bot@netlify.com +// 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 +/** 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 +/** indent on commands or description on the help page */ const HELP_INDENT_WIDTH = 2 -// separator width between term and description +/** 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(['recipes', 'completion', 'status', 'switch', 'login', 'lm']) + /** * Formats a help list correctly with the correct indent * @param {string[]} textArray @@ -64,30 +78,83 @@ const getDuration = function (startTime) { } /** - * The netlify object inside each command with the state - * @typedef NetlifyOptions - * @type {object} - * @property {import('netlify').NetlifyAPI} api - * @property {*} repositoryRoot - * @property {object} site - * @property {*} site.root - * @property {*} site.configPath - * @property {*} site.id - * @property {*} siteInfo - * @property {*} config - * @property {*} cachedConfig - * @property {*} globalConfig - * @property {import('../../utils/state-config.mjs').default} state, + * 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 + * @param {Project} project + * @param {string=} filter + * @returns {Promise} */ +async function selectWorkspace(project, filter) { + 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!`)) + + const { result } = await inquirer.prompt({ + name: 'result', + type: 'autocomplete', + message: 'Select a site you want to work with', + source: (/** @type {string} */ _, 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 +} /** Base command class that provides tracking and config initialization */ export default class BaseCommand extends Command { - /** @type {NetlifyOptions} */ + /** + * The netlify object inside each command with the state + * @type {import('./types.js').NetlifyOptions} + */ netlify /** @type {{ startTime: bigint, payload?: any}} */ analytics = { startTime: process.hrtime.bigint() } + /** @type {Project} */ + 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 it's value is set + // eslint-disable-next-line workspace/no-process-cwd + workingDir = process.cwd() + + /** + * The workspace root if inside a mono repository. + * Must not be the repository root! + * @type {string|undefined} + */ + jsWorkspaceRoot + /** + * The current workspace package we should execute the commands in + * @type {string|undefined} + */ + workspacePackage + /** * IMPORTANT this function will be called for each command! * Don't do anything expensive in there. @@ -95,49 +162,61 @@ export default class BaseCommand extends Command { * @returns */ createCommand(name) { - return ( - new BaseCommand(name) - // If --silent or --json flag passed disable logger - .addOption(new Option('--json', 'Output return values as JSON').hideHelp(true)) - .addOption(new Option('--silent', 'Silence CLI output').hideHelp(true)) - .addOption(new Option('--cwd ').hideHelp(true)) - .addOption(new Option('-o, --offline').hideHelp(true)) - .addOption(new Option('--auth ', 'Netlify auth token').hideHelp(true)) - .addOption( - new Option( - '--httpProxy [address]', - 'Old, prefer --http-proxy. Proxy server address to route requests through.', - ) - .default(process.env.HTTP_PROXY || process.env.HTTPS_PROXY) - .hideHelp(true), - ) - .addOption( - new Option( - '--httpProxyCertificateFilename [file]', - 'Old, prefer --http-proxy-certificate-filename. Certificate file to use when connecting using a proxy server.', - ) - .default(process.env.NETLIFY_PROXY_CERTIFICATE_FILENAME) - .hideHelp(true), + const base = new BaseCommand(name) + // If --silent or --json flag passed disable logger + .addOption(new Option('--json', 'Output return values as JSON').hideHelp(true)) + .addOption(new Option('--silent', 'Silence CLI output').hideHelp(true)) + .addOption(new Option('--cwd ').hideHelp(true)) + .addOption(new Option('-o, --offline').hideHelp(true)) + .addOption(new Option('--auth ', 'Netlify auth token').hideHelp(true)) + .addOption( + new Option('--httpProxy [address]', 'Old, prefer --http-proxy. Proxy server address to route requests through.') + .default(process.env.HTTP_PROXY || process.env.HTTPS_PROXY) + .hideHelp(true), + ) + .addOption( + new Option( + '--httpProxyCertificateFilename [file]', + 'Old, prefer --http-proxy-certificate-filename. Certificate file to use when connecting using a proxy server.', ) - .option( + .default(process.env.NETLIFY_PROXY_CERTIFICATE_FILENAME) + .hideHelp(true), + ) + .addOption( + new Option( '--http-proxy-certificate-filename [file]', 'Certificate file to use when connecting using a proxy server', - process.env.NETLIFY_PROXY_CERTIFICATE_FILENAME, ) + .default(process.env.NETLIFY_PROXY_CERTIFICATE_FILENAME) + .hideHelp(true), + ) + .addOption( + new Option('--httpProxy [address]', 'Proxy server address to route requests through.') + .default(process.env.HTTP_PROXY || process.env.HTTPS_PROXY) + .hideHelp(true), + ) + .option('--debug', 'Print debugging information') + + // only add the `--config` or `--filter` option to commands that are workspace aware + if (!COMMANDS_WITHOUT_WORKSPACE_OPTIONS.has(name)) { + base + .option('--config ', 'Custom path to a netlify configuration file') .option( - '--http-proxy [address]', - 'Proxy server address to route requests through.', - process.env.HTTP_PROXY || process.env.HTTPS_PROXY, + '--filter ', + 'Optional name of an application to run the command in.\nThis option is needed for working in Monorepos', ) - .option('--debug', 'Print debugging information') - .hook('preAction', async (_parentCommand, actionCommand) => { - debug(`${name}:preAction`)('start') - this.analytics = { startTime: process.hrtime.bigint() } - // @ts-ignore cannot type actionCommand as BaseCommand - await this.init(actionCommand) - debug(`${name}:preAction`)('end') - }) - ) + } + + return base.hook('preAction', async (_parentCommand, actionCommand) => { + if (actionCommand.opts()?.debug) { + process.env.DEBUG = '*' + } + debug(`${name}:preAction`)('start') + this.analytics = { startTime: process.hrtime.bigint() } + // @ts-ignore cannot type actionCommand as BaseCommand + await this.init(actionCommand) + debug(`${name}:preAction`)('end') + }) } /** @private */ @@ -149,7 +228,7 @@ export default class BaseCommand extends Command { return this } - /** The examples list for the command (used inside doc generation and help page) */ + /** @type {string[]} The examples list for the command (used inside doc generation and help page) */ examples = [] /** @@ -172,23 +251,27 @@ export default class BaseCommand extends Command { const term = this.name() === 'netlify' ? `${HELP_$} ${command.name()} [COMMAND]` - : `${HELP_$} ${command.parent.name()} ${command.name()} ${command.usage()}` + : `${HELP_$} ${command.parent?.name()} ${command.name()} ${command.usage()}` return padLeft(term, HELP_INDENT_WIDTH) } + /** + * @param {BaseCommand} command + */ const getCommands = (command) => { const parentCommand = this.name() === 'netlify' ? command : command.parent - return parentCommand.commands.filter((cmd) => { - // eslint-disable-next-line no-underscore-dangle - 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()}:`) - }) + 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()}:`) + }) || [] + ) } /** @@ -281,9 +364,8 @@ export default class BaseCommand extends Command { } // Aliases - // eslint-disable-next-line no-underscore-dangle + if (command._aliases.length !== 0) { - // eslint-disable-next-line no-underscore-dangle const aliases = command._aliases.map((alias) => formatItem(`${parentCommand.name()} ${alias}`, null, true)) output = [...output, chalk.bold('ALIASES'), formatHelpList(aliases), ''] } @@ -337,6 +419,11 @@ export default class BaseCommand extends Command { } } + /** + * + * @param {string|undefined} tokenFromFlag + * @returns + */ async authenticate(tokenFromFlag) { const [token] = await getToken(tokenFromFlag) if (token) { @@ -406,6 +493,10 @@ export default class BaseCommand extends Command { return accessToken } + /** + * Adds some data to the analytics payload + * @param {Record} payload + */ setAnalyticsPayload(payload) { const newPayload = { ...this.analytics.payload, ...payload } this.analytics = { ...this.analytics, payload: newPayload } @@ -418,12 +509,58 @@ export default class BaseCommand extends Command { */ async init(actionCommand) { debug(`${actionCommand.name()}:init`)('start') - const options = actionCommand.opts() - const cwd = options.cwd || process.cwd() - // Get site id & build state - const state = new StateConfig(cwd) + const flags = actionCommand.opts() + // here we actually want to use the process.cwd as we are setting the workingDir + // eslint-disable-next-line workspace/no-process-cwd + this.workingDir = flags.cwd || process.cwd() + + // ================================================== + // Create a Project and run the Heuristics to detect + // if we are run 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) + // eslint-disable-next-line promise/prefer-await-to-callbacks + .setReportFn((err, reportConfig) => { + reportError(err, { + severity: reportConfig?.severity || 'error', + metadata: reportConfig?.metadata, + }) + }) + const frameworks = await this.project.detectFrameworks() + /** @type { string|undefined} */ + 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 + if ( + !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) + } - const [token] = await getToken(options.auth) + 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 StateConfig(this.workingDir) + const [token] = await getToken(flags.auth) const apiUrlOpts = { userAgent: USER_AGENT, @@ -437,12 +574,24 @@ export default class BaseCommand extends Command { process.env.NETLIFY_API_URL === `${apiUrl.protocol}//${apiUrl.host}` ? '/api/v1' : apiUrl.pathname } - const cachedConfig = await actionCommand.getConfig({ cwd, state, token, ...apiUrlOpts }) + // ================================================== + // Start retrieving the configuration through the + // configuration file and the API + // ================================================== + const cachedConfig = await actionCommand.getConfig({ + cwd: this.jsWorkspaceRoot || this.workingDir, + repositoryRoot: rootDir, + // The config flag needs to be resolved from the actual process working directory + configFilePath: packageConfig, + state, + token, + ...apiUrlOpts, + }) const { buildDir, config, configPath, repositoryRoot, siteInfo } = cachedConfig const normalizedConfig = normalizeConfig(config) const agent = await getAgent({ - httpProxy: options.httpProxy, - certificateFile: options.httpProxyCertificateFilename, + httpProxy: flags.httpProxy, + certificateFile: flags.httpProxyCertificateFilename, }) const apiOpts = { ...apiUrlOpts, agent } const api = new NetlifyAPI(token || '', apiOpts) @@ -454,33 +603,44 @@ export default class BaseCommand extends Command { // 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 && options.site) { - siteData = await getSiteByName(api, options.site) + if (!siteData.url && flags.site) { + siteData = await getSiteByName(api, flags.site) } const globalConfig = await getGlobalConfig() - // Get framework, add to analytics payload for every command, if a framework is set - const fs = new NodeFS() - const project = new Project(fs, buildDir) - const frameworks = await project.detectFrameworks() - + // ================================================== + // Perform analytics reporting + // ================================================== const frameworkIDs = frameworks?.map((framework) => framework.id) - if (frameworkIDs?.length !== 0) { this.setAnalyticsPayload({ frameworks: frameworkIDs }) } - this.setAnalyticsPayload({ - packageManager: project.packageManager?.name, - buildSystem: project.buildSystems.map(({ id }) => id), + 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 = { // api methods api, apiOpts, + // The Absolute Repository root (detected through @netlify/config) repositoryRoot, + configFilePath, + relConfigFilePath: relative(repositoryRoot, configFilePath), // current site context site: { root: buildDir, @@ -508,26 +668,36 @@ export default class BaseCommand extends Command { /** * Find and resolve the Netlify configuration - * @param {*} config - * @returns {ReturnType} + * @param {object} config + * @param {string} config.cwd + * @param {string|null=} config.token + * @param {*} config.state + * @param {boolean=} config.offline + * @param {string=} config.configFilePath An optional path to the netlify configuration file e.g. netlify.toml + * @param {string=} config.repositoryRoot + * @param {string=} config.host + * @param {string=} config.pathPrefix + * @param {string=} config.scheme + * @returns {ReturnType} */ async getConfig(config) { - const options = this.opts() - const { cwd, host, offline = options.offline, pathPrefix, scheme, state, token } = config + // the flags that are passed to the command like `--debug` or `--offline` + const flags = this.opts() try { return await resolveConfig({ - config: options.config, - cwd, - context: options.context || process.env.CONTEXT || this.getDefaultContext(), - debug: this.opts().debug, - siteId: options.siteId || (typeof options.site === 'string' && options.site) || state.get('siteId'), - token, + config: config.configFilePath, + repositoryRoot: config.repositoryRoot, + cwd: config.cwd, + context: flags.context || process.env.CONTEXT || this.getDefaultContext(), + debug: flags.debug, + siteId: flags.siteId || (typeof flags.site === 'string' && flags.site) || config.state.get('siteId'), + token: config.token, mode: 'cli', - host, - pathPrefix, - scheme, - offline, + host: config.host, + pathPrefix: config.pathPrefix, + scheme: config.scheme, + offline: config.offline ?? flags.offline, siteFeatureFlagPrefix: 'cli', }) } catch (error_) { @@ -539,17 +709,17 @@ export default class BaseCommand extends Command { // // @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 (this.opts().debug) { + if (isUserError && !config.offline && config.token) { + if (flags.debug) { error(error_, { exit: false }) warn('Failed to resolve config, falling back to offline resolution') } - return this.getConfig({ cwd, offline: true, state, token }) + // recursive call with trying to resolve offline + return this.getConfig({ ...config, offline: true }) } const message = isUserError ? error_.message : error_.stack - console.error(message) - exit(1) + error(message, { exit: true }) } } @@ -558,13 +728,22 @@ export default class BaseCommand extends Command { * set. The default context is `dev` most of the time, but some commands may * wish to override that. * - * @returns {string} + * @returns {'production' | 'dev'} */ getDefaultContext() { - if (this.name() === 'serve') { - return 'production' - } + return this.name() === 'serve' ? 'production' : 'dev' + } +} - return 'dev' +/** + * Retrieves the repository root through a git command. + * Returns undefined if not a git project. + * @param {string} [cwd] The optional current working directory + * @returns {Promise} + */ +async function getRepositoryRoot(cwd) { + const res = await findUp('.git', { cwd, type: 'directory' }) + if (res) { + return join(res, '..') } } diff --git a/src/commands/build/build.mjs b/src/commands/build/build.mjs index ed4a6a44fe0..cbbbe904b50 100644 --- a/src/commands/build/build.mjs +++ b/src/commands/build/build.mjs @@ -2,6 +2,7 @@ import process from 'process' import { getBuildOptions, runBuild } from '../../lib/build.mjs' +import { detectFrameworkSettings } from '../../utils/build-info.mjs' import { error, exit, getToken } from '../../utils/command-helpers.mjs' import { getEnvelopeEnv, normalizeContext } from '../../utils/env/index.mjs' @@ -33,11 +34,18 @@ const injectEnv = async function (command, { api, buildOptions, context, siteInf * @param {import('../base-command.mjs').default} command */ const build = async (options, command) => { + const { cachedConfig, siteInfo } = command.netlify command.setAnalyticsPayload({ dry: options.dry }) // Retrieve Netlify Build options const [token] = await getToken() + const settings = await detectFrameworkSettings(command, 'build') + + // override the build command with the detection result if no command is specified through the config + if (!cachedConfig.config.build.command) { + cachedConfig.config.build.command = settings?.buildCommand + cachedConfig.config.build.commandOrigin = 'heuristics' + } - const { cachedConfig, siteInfo } = command.netlify const buildOptions = await getBuildOptions({ cachedConfig, token, diff --git a/src/commands/deploy/deploy.mjs b/src/commands/deploy/deploy.mjs index c7a08439a17..4dbcad085e2 100644 --- a/src/commands/deploy/deploy.mjs +++ b/src/commands/deploy/deploy.mjs @@ -1,7 +1,7 @@ // @ts-check import { stat } from 'fs/promises' import { basename, resolve } from 'path' -import { cwd, env } from 'process' +import { env } from 'process' import { runCoreSteps } from '@netlify/build' import { restoreConfig, updateConfig } from '@netlify/config' @@ -64,16 +64,18 @@ const triggerDeploy = async ({ api, options, siteData, siteId }) => { /** * g * @param {object} config + * @param {string} config.workingDir The process working directory * @param {object} config.config * @param {import('commander').OptionValues} config.options * @param {object} config.site * @param {object} config.siteData * @returns {Promise} */ -const getDeployFolder = async ({ config, options, site, siteData }) => { +const getDeployFolder = async ({ config, options, site, siteData, workingDir }) => { + console.log() let deployFolder if (options.dir) { - deployFolder = resolve(cwd(), options.dir) + deployFolder = resolve(workingDir, options.dir) } else if (config?.build?.publish) { deployFolder = resolve(site.root, config.build.publish) } else if (siteData?.build_settings?.dir) { @@ -82,14 +84,14 @@ const getDeployFolder = async ({ config, options, site, siteData }) => { if (!deployFolder) { log('Please provide a publish directory (e.g. "public" or "dist" or "."):') - log(cwd()) + log(workingDir) const { promptPath } = await inquirer.prompt([ { type: 'input', name: 'promptPath', message: 'Publish directory', default: '.', - filter: (input) => resolve(cwd(), input), + filter: (input) => resolve(workingDir, input), }, ]) deployFolder = promptPath @@ -128,14 +130,15 @@ const validateDeployFolder = async ({ deployFolder }) => { * @param {import('commander').OptionValues} config.options * @param {object} config.site * @param {object} config.siteData + * @param {string} config.workingDir // The process working directory * @returns {string} */ -const getFunctionsFolder = ({ config, options, site, siteData }) => { +const getFunctionsFolder = ({ config, options, site, siteData, workingDir }) => { let functionsFolder // Support "functions" and "Functions" const funcConfig = config.functionsDirectory if (options.functions) { - functionsFolder = resolve(cwd(), options.functions) + functionsFolder = resolve(workingDir, options.functions) } else if (funcConfig) { functionsFolder = resolve(site.root, funcConfig) } else if (siteData?.build_settings?.functions_dir) { @@ -178,12 +181,21 @@ const validateFolders = async ({ deployFolder, functionsFolder }) => { return { deployFolderStat, functionsFolderStat } } +/** + * @param {object} config + * @param {string} config.deployFolder + * @param {*} config.site + * @returns + */ 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 + /** + * @param {string} filename + */ return (filename) => { if (filename == null) { return false @@ -496,6 +508,7 @@ const printResults = ({ deployToProduction, json, results, runBuildCommand }) => * @param {import('../base-command.mjs').default} command */ const deploy = async (options, command) => { + const { workingDir } = command const { api, site, siteInfo } = command.netlify const alias = options.alias || options.branch @@ -566,8 +579,8 @@ const deploy = async (options, command) => { }) const config = newConfig || command.netlify.config - const deployFolder = await getDeployFolder({ options, config, site, siteData }) - const functionsFolder = getFunctionsFolder({ options, config, site, siteData }) + const deployFolder = await getDeployFolder({ workingDir, options, config, site, siteData }) + const functionsFolder = getFunctionsFolder({ workingDir, options, config, site, siteData }) const { configPath } = site const edgeFunctionsConfig = command.netlify.config.edge_functions diff --git a/src/commands/dev/dev.mjs b/src/commands/dev/dev.mjs index d34afebb58f..8c879ea099b 100644 --- a/src/commands/dev/dev.mjs +++ b/src/commands/dev/dev.mjs @@ -9,7 +9,6 @@ import { printBanner } from '../../utils/banner.mjs' import { BANG, chalk, - exit, log, NETLIFYDEV, NETLIFYDEVERR, @@ -35,7 +34,7 @@ import { createDevExecCommand } from './dev-exec.mjs' * @param {object} config * @param {*} config.api * @param {import('commander').OptionValues} config.options - * @param {*} config.settings + * @param {import('../../utils/types.js').ServerSettings} config.settings * @param {*} config.site * @param {*} config.state * @returns @@ -68,6 +67,9 @@ const handleLiveTunnel = async ({ api, options, settings, site, state }) => { } } +/** + * @param {string} args + */ const validateShortFlagArgs = (args) => { if (args.startsWith('=')) { throw new Error( @@ -94,11 +96,13 @@ const dev = async (options, command) => { const { api, cachedConfig, config, repositoryRoot, site, siteInfo, state } = command.netlify config.dev = { ...config.dev } config.build = { ...config.build } - /** @type {import('./types').DevConfig} */ + /** @type {import('./types.js').DevConfig} */ const devConfig = { framework: '#auto', + autoLaunch: Boolean(options.open), ...(config.functionsDirectory && { functions: config.functionsDirectory }), ...(config.build.publish && { publish: config.build.publish }), + ...(config.build.base && { base: config.build.base }), ...config.dev, ...options, } @@ -124,20 +128,17 @@ const dev = async (options, command) => { siteInfo, }) - /** @type {Partial} */ - let settings = {} + /** @type {import('../../utils/types.js').ServerSettings} */ + let settings try { - settings = await detectServerSettings(devConfig, options, site.root, { - site: { - id: site.id, - url: siteUrl, - }, - }) + settings = await detectServerSettings(devConfig, options, command) cachedConfig.config = getConfigWithPlugins(cachedConfig.config, settings) } catch (error_) { - log(NETLIFYDEVERR, error_.message) - exit(1) + if (error_ && typeof error_ === 'object' && 'message' in error_) { + log(NETLIFYDEVERR, error_.message) + } + process.exit(1) } command.setAnalyticsPayload({ live: options.live }) @@ -151,10 +152,9 @@ const dev = async (options, command) => { log(`${NETLIFYDEVWARN} Setting up local development server`) const { configPath: configPathOverride } = await runDevTimeline({ - cachedConfig, + command, options, settings, - site, env: { URL: url, DEPLOY_URL: url, @@ -188,8 +188,11 @@ const dev = async (options, command) => { // TODO: We should consolidate this with the existing config watcher. const getUpdatedConfig = async () => { - const cwd = options.cwd || process.cwd() - const { config: newConfig } = await command.getConfig({ cwd, offline: true, state }) + const { config: newConfig } = await command.getConfig({ + cwd: command.workingDir, + offline: true, + state, + }) const normalizedNewConfig = normalizeConfig(newConfig) return normalizedNewConfig @@ -202,6 +205,7 @@ const dev = async (options, command) => { config, configPath: configPathOverride, debug: options.debug, + projectDir: command.workingDir, env, getUpdatedConfig, inspectSettings, @@ -248,6 +252,7 @@ export const createDevCommand = (program) => { .argParser((value) => Number.parseInt(value)) .hideHelp(true), ) + .addOption(new Option('--no-open', 'disables the automatic opening of a browser window')) .option('--target-port ', 'port of target app server', (value) => Number.parseInt(value)) .option('--framework ', 'framework to use. Defaults to #auto which automatically detects a framework') .option('-d ,--dir ', 'dir with static files') diff --git a/src/commands/serve/serve.mjs b/src/commands/serve/serve.mjs index 04d529033bf..f8333661197 100644 --- a/src/commands/serve/serve.mjs +++ b/src/commands/serve/serve.mjs @@ -38,6 +38,7 @@ const serve = async (options, command) => { const devConfig = { ...(config.functionsDirectory && { functions: config.functionsDirectory }), ...(config.build.publish && { publish: config.build.publish }), + ...config.dev, ...options, // Override the `framework` value so that we start a static server and not @@ -69,10 +70,9 @@ const serve = async (options, command) => { // Netlify Build are loaded. await getInternalFunctionsDir({ base: site.root, ensureExists: true }) - /** @type {Partial} */ - let settings = {} + let settings = /** @type {import('../../utils/types.js').ServerSettings} */ ({}) try { - settings = await detectServerSettings(devConfig, options, site.root) + settings = await detectServerSettings(devConfig, options, command) cachedConfig.config = getConfigWithPlugins(cachedConfig.config, settings) } catch (error_) { @@ -87,7 +87,11 @@ const serve = async (options, command) => { `${NETLIFYDEVWARN} Changes will not be hot-reloaded, so if you need to rebuild your site you must exit and run 'netlify serve' again`, ) - const { configPath: configPathOverride } = await runBuildTimeline({ cachedConfig, options, settings, site }) + const { configPath: configPathOverride } = await runBuildTimeline({ + command, + settings, + options, + }) await startFunctionsServer({ api, @@ -117,8 +121,7 @@ const serve = async (options, command) => { // TODO: We should consolidate this with the existing config watcher. const getUpdatedConfig = async () => { - const cwd = options.cwd || process.cwd() - const { config: newConfig } = await command.getConfig({ cwd, offline: true, state }) + const { config: newConfig } = await command.getConfig({ cwd: command.workingDir, offline: true, state }) const normalizedNewConfig = normalizeConfig(newConfig) return normalizedNewConfig @@ -135,6 +138,7 @@ const serve = async (options, command) => { getUpdatedConfig, inspectSettings, offline: options.offline, + projectDir: command.workingDir, settings, site, siteInfo, diff --git a/src/utils/build-info.mjs b/src/utils/build-info.mjs new file mode 100644 index 00000000000..186900ee44b --- /dev/null +++ b/src/utils/build-info.mjs @@ -0,0 +1,100 @@ +// @ts-check + +import fuzzy from 'fuzzy' +import inquirer from 'inquirer' + +import { chalk, log } from './command-helpers.mjs' + +/** + * Filters the inquirer settings based on the input + * @param {ReturnType} scriptInquirerOptions + * @param {string} input + */ +const filterSettings = function (scriptInquirerOptions, input) { + const filterOptions = scriptInquirerOptions.map((scriptInquirerOption) => scriptInquirerOption.name) + // TODO: remove once https://github.com/sindresorhus/eslint-plugin-unicorn/issues/1394 is fixed + // eslint-disable-next-line unicorn/no-array-method-this-argument + const filteredSettings = fuzzy.filter(input, filterOptions) + const filteredSettingNames = new Set( + filteredSettings.map((filteredSetting) => (input ? filteredSetting.string : filteredSetting)), + ) + return scriptInquirerOptions.filter((t) => filteredSettingNames.has(t.name)) +} + +/** @typedef {import('@netlify/build-info').Settings} Settings */ + +/** + * @param {Settings[]} settings + * @param {'dev' | 'build'} type The type of command (dev or build) + */ +const formatSettingsArrForInquirer = function (settings, type = 'dev') { + return settings.map((setting) => { + const cmd = type === 'dev' ? setting.devCommand : setting.buildCommand + return { + name: `[${chalk.yellow(setting.framework.name)}] '${cmd}'`, + value: { ...setting, commands: [cmd] }, + short: `${setting.name}-${cmd}`, + } + }) +} + +/** + * Uses @netlify/build-info to detect the dev settings and port based on the framework + * and the build system that is used. + * @param {import('../commands/base-command.mjs').default} command + * @param {'dev' | 'build'} type The type of command (dev or build) + * @returns {Promise} + */ +export const detectFrameworkSettings = async (command, type = 'dev') => { + const { relConfigFilePath } = command.netlify + const settings = await detectBuildSettings(command) + if (settings.length === 1) { + return settings[0] + } + + if (settings.length > 1) { + /** multiple matching detectors, make the user choose */ + const scriptInquirerOptions = formatSettingsArrForInquirer(settings, type) + /** @type {{chosenSettings: Settings}} */ + const { chosenSettings } = await inquirer.prompt({ + name: 'chosenSettings', + message: `Multiple possible ${type} commands found`, + type: 'autocomplete', + source(/** @type {string} */ _, input = '') { + if (!input) return scriptInquirerOptions + // only show filtered results + return filterSettings(scriptInquirerOptions, input) + }, + }) + + log(` +Update your ${relConfigFilePath} to avoid this selection prompt next time: + +[build] +command = "${chosenSettings.buildCommand}" +publish = "${chosenSettings.dist}" + +[dev] +command = "${chosenSettings.devCommand}" +`) + return chosenSettings + } +} + +/** + * Detects and filters the build setting for a project and a command + * @param {import('../commands/base-command.mjs').default} command + */ +export const detectBuildSettings = async (command) => { + const { project, workspacePackage } = command + const buildSettings = await project.getBuildSettings(project.workspace ? workspacePackage : '') + return buildSettings + .filter((setting) => { + if (project.workspace && project.relativeBaseDirectory && setting.packagePath) { + return project.relativeBaseDirectory.startsWith(setting.packagePath) + } + + return true + }) + .filter((setting) => setting.devCommand) +} diff --git a/src/utils/detect-server-settings.mjs b/src/utils/detect-server-settings.mjs index 656dd10755b..1d9c67521e5 100644 --- a/src/utils/detect-server-settings.mjs +++ b/src/utils/detect-server-settings.mjs @@ -1,26 +1,28 @@ // @ts-check import { readFile } from 'fs/promises' import { EOL } from 'os' -import path from 'path' -import process from 'process' - -import { Project } from '@netlify/build-info' -// eslint-disable-next-line import/extensions, n/no-missing-import -import { NodeFS } from '@netlify/build-info/node' -import { getFramework, listFrameworks } from '@netlify/framework-info' -import fuzzy from 'fuzzy' +import { dirname, relative, resolve } from 'path' + +import { getFramework, getSettings } from '@netlify/build-info' import getPort from 'get-port' -import inquirer from 'inquirer' -import inquirerAutocompletePrompt from 'inquirer-autocomplete-prompt' +import { detectFrameworkSettings } from './build-info.mjs' import { NETLIFYDEVWARN, chalk, log } from './command-helpers.mjs' import { acquirePort } from './dev.mjs' import { getInternalFunctionsDir } from './functions/functions.mjs' -import { reportError } from './telemetry/report-error.mjs' +import { getPluginsToAutoInstall } from './init/utils.mjs' +/** @param {string} str */ const formatProperty = (str) => chalk.magenta(`'${str}'`) +/** @param {string} str */ const formatValue = (str) => chalk.green(`'${str}'`) +/** + * @param {object} options + * @param {string} options.keyFile + * @param {string} options.certFile + * @returns {Promise<{ key: string, cert: string, keyFilePath: string, certFilePath: string }>} + */ const readHttpsSettings = async (options) => { if (typeof options !== 'object' || !options.keyFile || !options.certFile) { throw new TypeError( @@ -38,43 +40,43 @@ const readHttpsSettings = async (options) => { throw new TypeError(`Certificate file configuration should be a string`) } - const [{ reason: keyError, value: key }, { reason: certError, value: cert }] = await Promise.allSettled([ - readFile(keyFile, 'utf-8'), - readFile(certFile, 'utf-8'), - ]) + const [key, cert] = await Promise.allSettled([readFile(keyFile, 'utf-8'), readFile(certFile, 'utf-8')]) - if (keyError) { - throw new Error(`Error reading private key file: ${keyError.message}`) + if (key.status === 'rejected') { + throw new Error(`Error reading private key file: ${key.reason}`) } - if (certError) { - throw new Error(`Error reading certificate file: ${certError.message}`) + if (cert.status === 'rejected') { + throw new Error(`Error reading certificate file: ${cert.reason}`) } - return { key, cert, keyFilePath: path.resolve(keyFile), certFilePath: path.resolve(certFile) } -} - -const validateStringProperty = ({ devConfig, property }) => { - if (devConfig[property] && typeof devConfig[property] !== 'string') { - const formattedProperty = formatProperty(property) - throw new TypeError( - `Invalid ${formattedProperty} option provided in config. The value of ${formattedProperty} option must be a string`, - ) - } + return { key: key.value, cert: cert.value, keyFilePath: resolve(keyFile), certFilePath: resolve(certFile) } } -const validateNumberProperty = ({ devConfig, property }) => { - if (devConfig[property] && typeof devConfig[property] !== 'number') { +/** + * Validates a property inside the devConfig to be of a given type + * @param {import('../commands/dev/types.js').DevConfig} devConfig The devConfig + * @param {keyof import('../commands/dev/types.js').DevConfig} property The property to validate + * @param {'string' | 'number'} type The type it should have + */ +function validateProperty(devConfig, property, type) { + // eslint-disable-next-line valid-typeof + if (devConfig[property] && typeof devConfig[property] !== type) { const formattedProperty = formatProperty(property) throw new TypeError( - `Invalid ${formattedProperty} option provided in config. The value of ${formattedProperty} option must be an integer`, + `Invalid ${formattedProperty} option provided in config. The value of ${formattedProperty} option must be of type ${type}`, ) } } +/** + * + * @param {object} config + * @param {import('../commands/dev/types.js').DevConfig} config.devConfig + */ const validateFrameworkConfig = ({ devConfig }) => { - validateStringProperty({ devConfig, property: 'command' }) - validateNumberProperty({ devConfig, property: 'port' }) - validateNumberProperty({ devConfig, property: 'targetPort' }) + validateProperty(devConfig, 'command', 'string') + validateProperty(devConfig, 'port', 'number') + validateProperty(devConfig, 'targetPort', 'number') if (devConfig.targetPort && devConfig.targetPort === devConfig.port) { throw new Error( @@ -85,6 +87,11 @@ const validateFrameworkConfig = ({ devConfig }) => { } } +/** + * @param {object} config + * @param {import('../commands/dev/types.js').DevConfig} config.devConfig + * @param {number=} config.detectedPort + */ const validateConfiguredPort = ({ detectedPort, devConfig }) => { if (devConfig.port && devConfig.port === detectedPort) { const formattedPort = formatProperty('port') @@ -97,13 +104,22 @@ const validateConfiguredPort = ({ detectedPort, devConfig }) => { const DEFAULT_PORT = 8888 const DEFAULT_STATIC_PORT = 3999 -const getDefaultDist = () => { +/** + * Logs a message that it was unable to determine the dist directory and falls back to the workingDir + * @param {string} workingDir + */ +const getDefaultDist = (workingDir) => { log(`${NETLIFYDEVWARN} Unable to determine public folder to serve files from. Using current working directory`) log(`${NETLIFYDEVWARN} Setup a netlify.toml file with a [dev] section to specify your dev server settings.`) log(`${NETLIFYDEVWARN} See docs at: https://cli.netlify.com/netlify-dev#project-detection`) - return process.cwd() + return workingDir } +/** + * @param {object} config + * @param {import('../commands/dev/types.js').DevConfig} config.devConfig + * @returns {Promise} + */ const getStaticServerPort = async ({ devConfig }) => { const port = await acquirePort({ configuredPort: devConfig.staticServerPort, @@ -116,16 +132,16 @@ const getStaticServerPort = async ({ devConfig }) => { /** * - * @param {object} param0 - * @param {import('../commands/dev/types.js').DevConfig} param0.devConfig - * @param {import('commander').OptionValues} param0.options - * @param {string} param0.projectDir - * @returns {Promise} + * @param {object} config + * @param {import('../commands/dev/types.js').DevConfig} config.devConfig + * @param {import('commander').OptionValues} config.flags + * @param {string} config.workingDir + * @returns {Promise & {command?: string}>} */ -const handleStaticServer = async ({ devConfig, options, projectDir }) => { - validateNumberProperty({ devConfig, property: 'staticServerPort' }) +const handleStaticServer = async ({ devConfig, flags, workingDir }) => { + validateProperty(devConfig, 'staticServerPort', 'number') - if (options.dir) { + if (flags.dir) { log(`${NETLIFYDEVWARN} Using simple static server because ${formatProperty('--dir')} flag was specified`) } else if (devConfig.framework === '#static') { log( @@ -143,8 +159,8 @@ const handleStaticServer = async ({ devConfig, options, projectDir }) => { ) } - const dist = options.dir || devConfig.publish || getDefaultDist() - log(`${NETLIFYDEVWARN} Running static server from "${path.relative(path.dirname(projectDir), dist)}"`) + const dist = flags.dir || devConfig.publish || getDefaultDist(workingDir) + log(`${NETLIFYDEVWARN} Running static server from "${relative(dirname(workingDir), dist)}"`) const frameworkPort = await getStaticServerPort({ devConfig }) return { @@ -157,144 +173,39 @@ const handleStaticServer = async ({ devConfig, options, projectDir }) => { /** * Retrieves the settings from a framework - * @param {import('./types.js').FrameworkInfo} framework - * @returns {import('./types.js').BaseServerSettings} + * @param {import('@netlify/build-info').Settings} [settings] + * @returns {import('./types.js').BaseServerSettings | undefined} */ -const getSettingsFromFramework = (framework) => { - const { - build: { directory: dist }, - dev: { - commands: [command], - pollingStrategies = [], - port: frameworkPort, - }, - env = {}, - name: frameworkName, - plugins, - staticAssetsDirectory: staticDir, - } = framework - +const getSettingsFromDetectedSettings = (settings) => { + if (!settings) { + return + } return { - command, - frameworkPort, - dist: staticDir || dist, - framework: frameworkName, - env, - pollingStrategies: pollingStrategies.map(({ name }) => name), - plugins, + baseDirectory: settings.baseDirectory, + command: settings.devCommand, + frameworkPort: settings.frameworkPort, + dist: settings.dist, + framework: settings.framework.name, + env: settings.env, + pollingStrategies: settings.pollingStrategies, + plugins: getPluginsToAutoInstall(settings.plugins_from_config_file, settings.plugins_recommended), } } -const hasDevCommand = (framework) => Array.isArray(framework.dev.commands) && framework.dev.commands.length !== 0 - /** - * The new build setting detection with build systems and frameworks combined - * @param {string} projectDir + * @param {import('../commands/dev/types.js').DevConfig} devConfig */ -const detectSettings = async (projectDir) => { - const fs = new NodeFS() - const project = new Project(fs, projectDir) - - return await project.getBuildSettings() -} - -/** - * - * @param {import('./types.js').BaseServerSettings | undefined} frameworkSettings - * @param {import('@netlify/build-info').Settings[]} newSettings - * @param {Record>} [metadata] - */ -const detectChangesInNewSettings = (frameworkSettings, newSettings, metadata) => { - /** @type {string[]} */ - const message = [''] - const [setting] = newSettings - - if (frameworkSettings?.framework !== setting?.framework.name) { - message.push( - `- Framework does not match:`, - ` [old]: ${frameworkSettings?.framework}`, - ` [new]: ${setting?.framework.name}`, - '', - ) - } - - if (frameworkSettings?.command !== setting?.devCommand) { - message.push( - `- command does not match:`, - ` [old]: ${frameworkSettings?.command}`, - ` [new]: ${setting?.devCommand}`, - '', - ) - } - - if (frameworkSettings?.dist !== setting?.dist) { - message.push(`- dist does not match:`, ` [old]: ${frameworkSettings?.dist}`, ` [new]: ${setting?.dist}`, '') - } - - if (frameworkSettings?.frameworkPort !== setting?.frameworkPort) { - message.push( - `- frameworkPort does not match:`, - ` [old]: ${frameworkSettings?.frameworkPort}`, - ` [new]: ${setting?.frameworkPort}`, - '', - ) - } - - if (message.length !== 0) { - reportError( - { - name: 'NewSettingsDetectionMismatch', - errorMessage: 'New Settings detection does not match old one', - message: message.join('\n'), - }, - { severity: 'info', metadata }, - ) - } -} - -const detectFrameworkSettings = async ({ projectDir }) => { - const projectFrameworks = await listFrameworks({ projectDir }) - const frameworks = projectFrameworks.filter((framework) => hasDevCommand(framework)) - - if (frameworks.length === 1) { - return getSettingsFromFramework(frameworks[0]) - } - - if (frameworks.length > 1) { - /** multiple matching detectors, make the user choose */ - inquirer.registerPrompt('autocomplete', inquirerAutocompletePrompt) - const scriptInquirerOptions = formatSettingsArrForInquirer(frameworks) - const { chosenFramework } = await inquirer.prompt({ - name: 'chosenFramework', - message: `Multiple possible start commands found`, - type: 'autocomplete', - source(_, input) { - if (!input || input === '') { - return scriptInquirerOptions - } - // only show filtered results - return filterSettings(scriptInquirerOptions, input) - }, - }) - log( - `Add ${formatProperty( - `framework = "${chosenFramework.id}"`, - )} to the [dev] section of your netlify.toml to avoid this selection prompt next time`, - ) - - return getSettingsFromFramework(chosenFramework) - } -} - -const hasCommandAndTargetPort = ({ devConfig }) => devConfig.command && devConfig.targetPort +const hasCommandAndTargetPort = (devConfig) => devConfig.command && devConfig.targetPort /** * Creates settings for the custom framework - * @param {*} param0 + * @param {object} config + * @param {import('../commands/dev/types.js').DevConfig} config.devConfig + * @param {string} config.workingDir * @returns {import('./types.js').BaseServerSettings} */ -const handleCustomFramework = ({ devConfig }) => { - if (!hasCommandAndTargetPort({ devConfig })) { +const handleCustomFramework = ({ devConfig, workingDir }) => { + if (!hasCommandAndTargetPort(devConfig)) { throw new Error( `${formatProperty('command')} and ${formatProperty('targetPort')} properties are required when ${formatProperty( 'framework', @@ -304,101 +215,100 @@ const handleCustomFramework = ({ devConfig }) => { return { command: devConfig.command, frameworkPort: devConfig.targetPort, - dist: devConfig.publish || getDefaultDist(), + dist: devConfig.publish || getDefaultDist(workingDir), framework: '#custom', pollingStrategies: devConfig.pollingStrategies || [], } } -const mergeSettings = async ({ devConfig, frameworkSettings = {} }) => { - const { - command: frameworkCommand, - dist, - env, - framework, - frameworkPort: frameworkDetectedPort, - pollingStrategies = [], - } = frameworkSettings - - const command = devConfig.command || frameworkCommand - const frameworkPort = devConfig.targetPort || frameworkDetectedPort +/** + * Merges the framework settings with the devConfig + * @param {object} config + * @param {import('../commands/dev/types.js').DevConfig} config.devConfig + * @param {string} config.workingDir + * @param {Partial=} config.frameworkSettings + */ +const mergeSettings = async ({ devConfig, frameworkSettings = {}, workingDir }) => { + const command = devConfig.command || frameworkSettings.command + const frameworkPort = devConfig.targetPort || frameworkSettings.frameworkPort // if the framework doesn't start a server, we use a static one const useStaticServer = !(command && frameworkPort) return { + baseDirectory: devConfig.base || frameworkSettings.baseDirectory, command, frameworkPort: useStaticServer ? await getStaticServerPort({ devConfig }) : frameworkPort, - dist: devConfig.publish || dist || getDefaultDist(), - framework, - env, - pollingStrategies, + dist: devConfig.publish || frameworkSettings.dist || getDefaultDist(workingDir), + framework: frameworkSettings.framework, + env: frameworkSettings.env, + pollingStrategies: frameworkSettings.pollingStrategies || [], useStaticServer, } } /** * Handles a forced framework and retrieves the settings for it - * @param {*} param0 + * @param {object} config + * @param {import('../commands/dev/types.js').DevConfig} config.devConfig + * @param {import('@netlify/build-info').Project} config.project + * @param {string} config.workingDir + * @param {string=} config.workspacePackage * @returns {Promise} */ -const handleForcedFramework = async ({ devConfig, projectDir }) => { +const handleForcedFramework = async ({ devConfig, project, workingDir, workspacePackage }) => { // this throws if `devConfig.framework` is not a supported framework - const frameworkSettings = getSettingsFromFramework(await getFramework(devConfig.framework, { projectDir })) - return mergeSettings({ devConfig, frameworkSettings }) + const framework = await getFramework(devConfig.framework, project) + const settings = await getSettings(framework, project, workspacePackage || '') + const frameworkSettings = getSettingsFromDetectedSettings(settings) + return mergeSettings({ devConfig, workingDir, frameworkSettings }) } /** * Get the server settings based on the flags and the devConfig * @param {import('../commands/dev/types.js').DevConfig} devConfig - * @param {import('commander').OptionValues} options - * @param {string} projectDir - * @param {Record>} [metadata] + * @param {import('commander').OptionValues} flags + * @param {import('../commands/base-command.mjs').default} command * @returns {Promise} */ -const detectServerSettings = async (devConfig, options, projectDir, metadata) => { - validateStringProperty({ devConfig, property: 'framework' }) + +const detectServerSettings = async (devConfig, flags, command) => { + validateProperty(devConfig, 'framework', 'string') /** @type {Partial} */ let settings = {} - if (options.dir || devConfig.framework === '#static') { + if (flags.dir || devConfig.framework === '#static') { // serving files statically without a framework server - settings = await handleStaticServer({ options, devConfig, projectDir }) + settings = await handleStaticServer({ flags, devConfig, workingDir: command.workingDir }) } else if (devConfig.framework === '#auto') { // this is the default CLI behavior - const runDetection = !hasCommandAndTargetPort({ devConfig }) - const frameworkSettings = runDetection ? await detectFrameworkSettings({ projectDir }) : undefined - const newSettings = runDetection ? await detectSettings(projectDir) : undefined - - // just report differences in the settings - detectChangesInNewSettings(frameworkSettings, newSettings || [], { - ...metadata, - settings: { - projectDir, - devConfig, - options, - old: frameworkSettings, - settings: newSettings, - }, - }) - + const runDetection = !hasCommandAndTargetPort(devConfig) + const frameworkSettings = runDetection + ? getSettingsFromDetectedSettings(await detectFrameworkSettings(command, 'dev')) + : undefined if (frameworkSettings === undefined && runDetection) { log(`${NETLIFYDEVWARN} No app server detected. Using simple static server`) - settings = await handleStaticServer({ options, devConfig, projectDir }) + settings = await handleStaticServer({ flags, devConfig, workingDir: command.workingDir }) } else { validateFrameworkConfig({ devConfig }) - settings = await mergeSettings({ devConfig, frameworkSettings }) + + settings = await mergeSettings({ devConfig, frameworkSettings, workingDir: command.workingDir }) } - settings.plugins = frameworkSettings && frameworkSettings.plugins + settings.plugins = frameworkSettings?.plugins } else if (devConfig.framework === '#custom') { validateFrameworkConfig({ devConfig }) // when the users wants to configure `command` and `targetPort` - settings = handleCustomFramework({ devConfig }) + settings = handleCustomFramework({ devConfig, workingDir: command.workingDir }) } else if (devConfig.framework) { validateFrameworkConfig({ devConfig }) // this is when the user explicitly configures a framework, e.g. `framework = "gatsby"` - settings = await handleForcedFramework({ devConfig, projectDir }) + settings = await handleForcedFramework({ + devConfig, + project: command.project, + workingDir: command.workingDir, + workspacePackage: command.workspacePackage, + }) } validateConfiguredPort({ devConfig, detectedPort: settings.frameworkPort }) @@ -409,7 +319,7 @@ const detectServerSettings = async (devConfig, options, projectDir, metadata) => errorMessage: `Could not acquire required ${formatProperty('port')}`, }) const functionsDir = devConfig.functions || settings.functions - const internalFunctionsDir = await getInternalFunctionsDir({ base: projectDir }) + const internalFunctionsDir = await getInternalFunctionsDir({ base: command.workingDir }) const shouldStartFunctionsServer = Boolean(functionsDir || internalFunctionsDir) return { @@ -423,28 +333,6 @@ const detectServerSettings = async (devConfig, options, projectDir, metadata) => } } -const filterSettings = function (scriptInquirerOptions, input) { - const filterOptions = scriptInquirerOptions.map((scriptInquirerOption) => scriptInquirerOption.name) - // TODO: remove once https://github.com/sindresorhus/eslint-plugin-unicorn/issues/1394 is fixed - // eslint-disable-next-line unicorn/no-array-method-this-argument - const filteredSettings = fuzzy.filter(input, filterOptions) - const filteredSettingNames = new Set( - filteredSettings.map((filteredSetting) => (input ? filteredSetting.string : filteredSetting)), - ) - return scriptInquirerOptions.filter((t) => filteredSettingNames.has(t.name)) -} - -const formatSettingsArrForInquirer = function (frameworks) { - const formattedArr = frameworks.map((framework) => - framework.dev.commands.map((command) => ({ - name: `[${chalk.yellow(framework.name)}] '${command}'`, - value: { ...framework, commands: [command] }, - short: `${framework.name}-${command}`, - })), - ) - return formattedArr.flat() -} - /** * Returns a copy of the provided config with any plugins provided by the * server settings diff --git a/src/utils/framework-server.mjs b/src/utils/framework-server.mjs index 698f5fcc60c..36b2c60a834 100644 --- a/src/utils/framework-server.mjs +++ b/src/utils/framework-server.mjs @@ -18,13 +18,14 @@ const FRAMEWORK_PORT_TIMEOUT = 6e5 /** * Start a static server if the `useStaticServer` is provided or a framework specific server * @param {object} config - * @param {Partial} config.settings + * @param {import('./types.js').ServerSettings} config.settings + * @param {string} config.cwd * @returns {Promise} */ -export const startFrameworkServer = async function ({ settings }) { +export const startFrameworkServer = async function ({ cwd, settings }) { if (settings.useStaticServer) { if (settings.command) { - runCommand(settings.command, settings.env) + runCommand(settings.command, { env: settings.env, cwd }) } await startStaticServer({ settings }) @@ -37,7 +38,7 @@ export const startFrameworkServer = async function ({ settings }) { text: `Waiting for framework port ${settings.frameworkPort}. This can be configured using the 'targetPort' property in the netlify.toml`, }) - runCommand(settings.command, settings.env, spinner) + runCommand(settings.command, { env: settings.env, spinner, cwd }) let port try { @@ -46,7 +47,7 @@ export const startFrameworkServer = async function ({ settings }) { host: 'localhost', output: 'silent', timeout: FRAMEWORK_PORT_TIMEOUT, - ...(settings.pollingStrategies.includes('HTTP') && { protocol: 'http' }), + ...(settings.pollingStrategies?.includes('HTTP') && { protocol: 'http' }), }) if (!port.open) { diff --git a/src/utils/init/utils.mjs b/src/utils/init/utils.mjs index c186f90779f..b7854b2d70f 100644 --- a/src/utils/init/utils.mjs +++ b/src/utils/init/utils.mjs @@ -1,64 +1,72 @@ // @ts-check import { writeFile } from 'fs/promises' import path from 'path' -import process from 'process' import cleanDeep from 'clean-deep' import inquirer from 'inquirer' import { fileExistsAsync } from '../../lib/fs.mjs' import { normalizeBackslash } from '../../lib/path.mjs' +import { detectBuildSettings } from '../build-info.mjs' import { chalk, error as failAndExit, log, warn } from '../command-helpers.mjs' -import { getFrameworkInfo } from './frameworks.mjs' -import { detectNodeVersion } from './node-version.mjs' import { getRecommendPlugins, getUIPlugins } from './plugins.mjs' -const normalizeDir = ({ baseDirectory, defaultValue, dir }) => { - if (dir === undefined) { - return defaultValue - } - - const relativeDir = path.relative(baseDirectory, dir) - return relativeDir || defaultValue -} - -const getDefaultBase = ({ baseDirectory, repositoryRoot }) => { - if (baseDirectory !== repositoryRoot && baseDirectory.startsWith(repositoryRoot)) { - return path.relative(repositoryRoot, baseDirectory) - } -} +// these plugins represent runtimes that are +// expected to be "automatically" installed. Even though +// they can be installed on package/toml, we always +// want them installed in the site settings. When installed +// there our build will automatically install the latest without +// user management of the versioning. +const pluginsToAlwaysInstall = new Set(['@netlify/plugin-nextjs']) + +/** + * Retrieve a list of plugins to auto install + * @param {string[]=} pluginsInstalled + * @param {string[]=} pluginsRecommended + * @returns + */ +export const getPluginsToAutoInstall = (pluginsInstalled = [], pluginsRecommended = []) => + pluginsRecommended.reduce( + (acc, plugin) => + pluginsInstalled.includes(plugin) && !pluginsToAlwaysInstall.has(plugin) ? acc : [...acc, plugin], + + /** @type {string[]} */ ([]), + ) -const getDefaultSettings = ({ - baseDirectory, - config, - frameworkBuildCommand, - frameworkBuildDir, - frameworkPlugins, - repositoryRoot, -}) => { - const recommendedPlugins = getRecommendPlugins(frameworkPlugins, config) - const { - command: defaultBuildCmd = frameworkBuildCommand, - functions: defaultFunctionsDir, - publish: defaultBuildDir = frameworkBuildDir, - } = config.build +/** + * + * @param {Partial} settings + * @param {*} config + * @param {import('../../commands/base-command.mjs').default} command + */ +const normalizeSettings = (settings, config, command) => { + const plugins = getPluginsToAutoInstall(settings.plugins_from_config_file, settings.plugins_recommended) + const recommendedPlugins = getRecommendPlugins(plugins, config) return { - defaultBaseDir: getDefaultBase({ repositoryRoot, baseDirectory }), - defaultBuildCmd, - defaultBuildDir: normalizeDir({ baseDirectory, dir: defaultBuildDir, defaultValue: '.' }), - defaultFunctionsDir: normalizeDir({ baseDirectory, dir: defaultFunctionsDir, defaultValue: 'netlify/functions' }), + defaultBaseDir: settings.baseDirectory ?? command.project.relativeBaseDirectory ?? '', + defaultBuildCmd: config.build.command || settings.buildCommand, + defaultBuildDir: settings.dist, + defaultFunctionsDir: config.build.functions || 'netlify/functions', recommendedPlugins, } } +/** + * + * @param {object} param0 + * @param {string} param0.defaultBaseDir + * @param {string} param0.defaultBuildCmd + * @param {string=} param0.defaultBuildDir + * @returns + */ const getPromptInputs = ({ defaultBaseDir, defaultBuildCmd, defaultBuildDir }) => { const inputs = [ defaultBaseDir !== undefined && { type: 'input', name: 'baseDir', - message: 'Base directory (e.g. projects/frontend):', + message: 'Base directory `(blank for current dir):', default: defaultBaseDir, }, { @@ -79,34 +87,22 @@ const getPromptInputs = ({ defaultBaseDir, defaultBuildCmd, defaultBuildDir }) = return inputs.filter(Boolean) } -// `repositoryRoot === siteRoot` means the base directory wasn't detected by @netlify/config, so we use cwd() -const getBaseDirectory = ({ repositoryRoot, siteRoot }) => - path.normalize(repositoryRoot) === path.normalize(siteRoot) ? process.cwd() : siteRoot - -export const getBuildSettings = async ({ config, env, repositoryRoot, siteRoot }) => { - const baseDirectory = getBaseDirectory({ repositoryRoot, siteRoot }) - const nodeVersion = await detectNodeVersion({ baseDirectory, env }) - const { - frameworkBuildCommand, - frameworkBuildDir, - frameworkName, - frameworkPlugins = [], - } = await getFrameworkInfo({ - baseDirectory, - nodeVersion, - }) +/** + * @param {object} param0 + * @param {*} param0.config + * @param {import('../../commands/base-command.mjs').default} param0.command + */ +export const getBuildSettings = async ({ command, config }) => { + const settings = await detectBuildSettings(command) + // TODO: add prompt for asking to choose the build command + /** @type {Partial} */ + // eslint-disable-next-line unicorn/explicit-length-check + const setting = settings.length > 0 ? settings[0] : {} const { defaultBaseDir, defaultBuildCmd, defaultBuildDir, defaultFunctionsDir, recommendedPlugins } = - await getDefaultSettings({ - repositoryRoot, - config, - baseDirectory, - frameworkBuildCommand, - frameworkBuildDir, - frameworkPlugins, - }) - - if (recommendedPlugins.length !== 0) { - log(`Configuring ${formatTitle(frameworkName)} runtime...`) + await normalizeSettings(setting, config, command) + + if (recommendedPlugins.length !== 0 && setting.framework?.name) { + log(`Configuring ${formatTitle(setting.framework?.name)} runtime...`) log() } @@ -199,6 +195,9 @@ export const formatErrorMessage = ({ error, message }) => { return `${message} with error: ${chalk.red(errorMessage)}` } +/** + * @param {string} title + */ const formatTitle = (title) => chalk.cyan(title) export const createDeployKey = async ({ api }) => { diff --git a/src/utils/run-build.mjs b/src/utils/run-build.mjs index c3493d1adc8..b298c4699fd 100644 --- a/src/utils/run-build.mjs +++ b/src/utils/run-build.mjs @@ -1,7 +1,6 @@ // @ts-check import { promises as fs } from 'fs' -import path from 'path' -import process from 'process' +import path, { join } from 'path' import { INTERNAL_EDGE_FUNCTIONS_FOLDER } from '../lib/edge-functions/consts.mjs' import { getPathInProject } from '../lib/settings.mjs' @@ -12,10 +11,14 @@ import { INTERNAL_FUNCTIONS_FOLDER } from './functions/index.mjs' const netlifyBuildPromise = import('@netlify/build') -// Copies `netlify.toml`, if one is defined, into the `.netlify` internal -// directory and returns the path to its new location. -const copyConfig = async ({ configPath, siteRoot }) => { - const newConfigPath = path.resolve(siteRoot, getPathInProject(['netlify.toml'])) +/** + * Copies `netlify.toml`, if one is defined, into the `.netlify` internal + * directory and returns the path to its new location. + * @param {string} configPath + * @param {string} destinationFolder The folder where it should be copied to either the root of the repo or a package inside a monorepo + */ +const copyConfig = async (configPath, destinationFolder) => { + const newConfigPath = path.resolve(destinationFolder, getPathInProject(['netlify.toml'])) try { await fs.copyFile(configPath, newConfigPath) @@ -26,6 +29,9 @@ const copyConfig = async ({ configPath, siteRoot }) => { return newConfigPath } +/** + * @param {string} basePath + */ const cleanInternalDirectory = async (basePath) => { const ops = [INTERNAL_FUNCTIONS_FOLDER, INTERNAL_EDGE_FUNCTIONS_FOLDER, 'netlify.toml'].map((name) => { const fullPath = path.resolve(basePath, getPathInProject([name])) @@ -36,38 +42,52 @@ const cleanInternalDirectory = async (basePath) => { await Promise.all(ops) } -const getBuildOptions = ({ - cachedConfig, - options: { configPath, context, cwd = process.cwd(), debug, dry, offline, quiet, saveConfig }, -}) => ({ - cachedConfig, - configPath, - siteId: cachedConfig.siteInfo.id, - token: cachedConfig.token, - dry, - debug, - context, - mode: 'cli', - telemetry: false, - buffer: false, - offline, - cwd, - quiet, - saveConfig, -}) - -const runNetlifyBuild = async ({ cachedConfig, env, options, settings, site, timeline = 'build' }) => { +/** + * @param {object} params + * @param {import('../commands/base-command.mjs').default} params.command + * @param {import('../commands/base-command.mjs').default} params.command + * @param {*} params.options The flags of the command + * @param {import('./types.js').ServerSettings} params.settings + * @param {NodeJS.ProcessEnv} [params.env] + * @param {'build' | 'dev'} [params.timeline] + * @returns + */ +export const runNetlifyBuild = async ({ command, env = {}, options, settings, timeline = 'build' }) => { + const { cachedConfig, site } = command.netlify + const { default: buildSite, startDev } = await netlifyBuildPromise - const sharedOptions = getBuildOptions({ + + const sharedOptions = { cachedConfig, - options, - }) + configPath: cachedConfig.configPath, + siteId: cachedConfig.siteInfo.id, + token: cachedConfig.token, + dry: options.dry, + debug: options.debug, + context: options.context, + mode: 'cli', + telemetry: false, + buffer: false, + offline: options.offline, + cwd: cachedConfig.buildDir, + quiet: options.quiet, + saveConfig: options.saveConfig, + } + const devCommand = async (settingsOverrides = {}) => { + let cwd = command.workingDir + + if (command.project.workspace?.packages.length) { + console.log('packages', settings.baseDirectory) + cwd = join(command.project.jsWorkspaceRoot, settings.baseDirectory || '') + } + const { ipVersion } = await startFrameworkServer({ settings: { ...settings, ...settingsOverrides, }, + cwd, }) settings.frameworkHost = ipVersion === 6 ? '::1' : '127.0.0.1' @@ -80,7 +100,7 @@ const runNetlifyBuild = async ({ cachedConfig, env, options, settings, site, tim // Copy `netlify.toml` into the internal directory. This will be the new // location of the config file for the duration of the command. - const tempConfigPath = await copyConfig({ configPath: cachedConfig.configPath, siteRoot: site.root }) + const tempConfigPath = await copyConfig(cachedConfig.configPath, command.workingDir) const buildSiteOptions = { ...sharedOptions, outputConfigPath: tempConfigPath, @@ -118,13 +138,19 @@ const runNetlifyBuild = async ({ cachedConfig, env, options, settings, site, tim // Run Netlify Build using the `startDev` entry point. const { error: startDevError, success } = await startDev(devCommand, startDevOptions) - if (!success) { + if (!success && startDevError) { error(`Could not start local development server\n\n${startDevError.message}\n\n${startDevError.stack}`) } return {} } +/** + * @param {Omit[0], 'timeline'>} options + */ export const runDevTimeline = (options) => runNetlifyBuild({ ...options, timeline: 'dev' }) +/** + * @param {Omit[0], 'timeline'>} options + */ export const runBuildTimeline = (options) => runNetlifyBuild({ ...options, timeline: 'build' }) diff --git a/src/utils/shell.mjs b/src/utils/shell.mjs index f765cb7f0ed..d386b611ab3 100644 --- a/src/utils/shell.mjs +++ b/src/utils/shell.mjs @@ -40,17 +40,26 @@ const cleanupBeforeExit = async ({ exitCode }) => { /** * Run a command and pipe stdout, stderr and stdin * @param {string} command - * @param {NodeJS.ProcessEnv} env + * @param {object} options + * @param {import('ora').Ora|null} [options.spinner] + * @param {NodeJS.ProcessEnv} [options.env] + * @param {string} [options.cwd] * @returns {execa.ExecaChildProcess} */ -export const runCommand = (command, env = {}, spinner = null) => { +export const runCommand = (command, options = {}) => { + const { cwd, env = {}, spinner = null } = options const commandProcess = execa.command(command, { preferLocal: true, // we use reject=false to avoid rejecting synchronously when the command doesn't exist reject: false, - env, + env: { + // we want always colorful terminal outputs + FORCE_COLOR: 'true', + ...env, + }, // windowsHide needs to be false for child process to terminate properly on Windows windowsHide: false, + cwd, }) // This ensures that an active spinner stays at the bottom of the commandline @@ -82,15 +91,16 @@ export const runCommand = (command, env = {}, spinner = null) => { const [commandWithoutArgs] = command.split(' ') if (result.failed && isNonExistingCommandError({ command: commandWithoutArgs, error: result })) { log( - NETLIFYDEVERR, - `Failed running command: ${command}. Please verify ${chalk.magenta(`'${commandWithoutArgs}'`)} exists`, + `\n\n${NETLIFYDEVERR} Failed running command: ${command}. Please verify ${chalk.magenta( + `'${commandWithoutArgs}'`, + )} exists`, ) } else { const errorMessage = result.failed ? `${NETLIFYDEVERR} ${result.shortMessage}` : `${NETLIFYDEVWARN} "${command}" exited with code ${result.exitCode}` - log(`${errorMessage}. Shutting down Netlify Dev server`) + log(`\n\n${errorMessage}. Shutting down Netlify Dev server`) } return await cleanupBeforeExit({ exitCode: 1 }) @@ -100,6 +110,13 @@ export const runCommand = (command, env = {}, spinner = null) => { return commandProcess } +/** + * + * @param {object} config + * @param {string} config.command + * @param {*} config.error + * @returns + */ const isNonExistingCommandError = ({ command, error: commandError }) => { // `ENOENT` is only returned for non Windows systems // See https://github.com/sindresorhus/execa/pull/447 @@ -108,7 +125,7 @@ const isNonExistingCommandError = ({ command, error: commandError }) => { } // if the command is a package manager we let it report the error - if (['yarn', 'npm'].includes(command)) { + if (['yarn', 'npm', 'pnpm'].includes(command)) { return false } diff --git a/src/utils/static-server.mjs b/src/utils/static-server.mjs index 95cd4f11a16..5f6ea4224d8 100644 --- a/src/utils/static-server.mjs +++ b/src/utils/static-server.mjs @@ -6,6 +6,10 @@ import Fastify from 'fastify' import { log, NETLIFYDEVLOG } from './command-helpers.mjs' +/** + * @param {object} config + * @param {import('./types.js').ServerSettings} config.settings + */ export const startStaticServer = async ({ settings }) => { const server = Fastify() const rootPath = path.resolve(settings.dist) diff --git a/tests/integration/commands/functions-with-args/functions-with-args.test.mjs b/tests/integration/commands/functions-with-args/functions-with-args.test.mjs index 9d83001c184..69ec036b28c 100644 --- a/tests/integration/commands/functions-with-args/functions-with-args.test.mjs +++ b/tests/integration/commands/functions-with-args/functions-with-args.test.mjs @@ -9,7 +9,6 @@ import got from '../../utils/got.cjs' import { pause } from '../../utils/pause.cjs' import { withSiteBuilder } from '../../utils/site-builder.cjs' -// eslint-disable-next-line no-underscore-dangle const __dirname = path.dirname(fileURLToPath(import.meta.url)) const testMatrix = [{ args: [] }, { args: ['esbuild'] }] diff --git a/tests/integration/frameworks/eleventy.test.mjs b/tests/integration/frameworks/eleventy.test.mjs index 21a852850e5..393b2bd7d8d 100644 --- a/tests/integration/frameworks/eleventy.test.mjs +++ b/tests/integration/frameworks/eleventy.test.mjs @@ -8,7 +8,6 @@ import { afterAll, beforeAll, describe, test } from 'vitest' import { clientIP, originalIP } from '../../lib/local-ip.cjs' import { startDevServer } from '../utils/dev-server.cjs' - const __dirname = path.dirname(fileURLToPath(import.meta.url)) const context = {}