netlify-cli 15.9.1 → 15.10.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/bin/run.mjs +6 -5
  2. package/npm-shrinkwrap.json +369 -266
  3. package/package.json +8 -9
  4. package/src/commands/base-command.mjs +295 -116
  5. package/src/commands/build/build.mjs +9 -1
  6. package/src/commands/deploy/deploy.mjs +22 -9
  7. package/src/commands/dev/dev.mjs +22 -17
  8. package/src/commands/functions/functions-create.mjs +118 -89
  9. package/src/commands/functions/functions-invoke.mjs +10 -7
  10. package/src/commands/functions/functions-list.mjs +2 -2
  11. package/src/commands/init/init.mjs +1 -1
  12. package/src/commands/link/link.mjs +5 -5
  13. package/src/commands/serve/serve.mjs +10 -6
  14. package/src/commands/sites/sites-create-template.mjs +1 -1
  15. package/src/commands/sites/sites-create.mjs +1 -1
  16. package/src/functions-templates/typescript/hello-world/package-lock.json +6 -6
  17. package/src/lib/edge-functions/bootstrap.mjs +1 -1
  18. package/src/lib/edge-functions/headers.mjs +1 -0
  19. package/src/lib/edge-functions/internal.mjs +5 -3
  20. package/src/lib/edge-functions/proxy.mjs +29 -4
  21. package/src/lib/functions/runtimes/js/index.mjs +1 -1
  22. package/src/lib/functions/runtimes/js/worker.mjs +1 -1
  23. package/src/lib/functions/server.mjs +3 -2
  24. package/src/lib/spinner.mjs +1 -1
  25. package/src/recipes/vscode/index.mjs +24 -6
  26. package/src/utils/build-info.mjs +100 -0
  27. package/src/utils/command-helpers.mjs +16 -7
  28. package/src/utils/detect-server-settings.mjs +133 -245
  29. package/src/utils/framework-server.mjs +6 -5
  30. package/src/utils/functions/functions.mjs +8 -5
  31. package/src/utils/get-repo-data.mjs +5 -6
  32. package/src/utils/init/config-github.mjs +2 -2
  33. package/src/utils/init/config-manual.mjs +24 -7
  34. package/src/utils/init/utils.mjs +62 -63
  35. package/src/utils/proxy-server.mjs +7 -4
  36. package/src/utils/proxy.mjs +4 -3
  37. package/src/utils/read-repo-url.mjs +4 -0
  38. package/src/utils/run-build.mjs +58 -32
  39. package/src/utils/shell.mjs +24 -7
  40. package/src/utils/state-config.mjs +5 -1
  41. package/src/utils/static-server.mjs +4 -0
  42. package/src/utils/telemetry/report-error.mjs +8 -4
  43. package/src/utils/init/frameworks.mjs +0 -23
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "netlify-cli",
3
3
  "description": "Netlify command line tool",
4
- "version": "15.9.1",
4
+ "version": "15.10.0-rc.0",
5
5
  "author": "Netlify Inc.",
6
6
  "type": "module",
7
7
  "engines": {
@@ -44,16 +44,14 @@
44
44
  "dependencies": {
45
45
  "@bugsnag/js": "7.20.2",
46
46
  "@fastify/static": "6.10.2",
47
- "@netlify/build": "29.16.1",
48
- "@netlify/build-info": "7.4.1",
49
- "@netlify/config": "20.6.0",
50
- "@netlify/edge-bundler": "8.16.4",
51
- "@netlify/framework-info": "9.8.10",
47
+ "@netlify/build": "29.17.3",
48
+ "@netlify/build-info": "7.7.3",
49
+ "@netlify/config": "20.6.4",
50
+ "@netlify/edge-bundler": "8.17.1",
52
51
  "@netlify/local-functions-proxy": "1.1.1",
53
- "@netlify/serverless-functions-api": "1.5.1",
54
- "@netlify/zip-it-and-ship-it": "9.13.0",
52
+ "@netlify/serverless-functions-api": "1.5.2",
53
+ "@netlify/zip-it-and-ship-it": "9.13.1",
55
54
  "@octokit/rest": "19.0.13",
56
- "@skn0tt/lambda-local": "2.0.3",
57
55
  "ansi-escapes": "6.2.0",
58
56
  "ansi-styles": "6.2.1",
59
57
  "ansi-to-html": "0.7.2",
@@ -108,6 +106,7 @@
108
106
  "isexe": "2.0.0",
109
107
  "jsonwebtoken": "9.0.1",
110
108
  "jwt-decode": "3.1.2",
109
+ "lambda-local": "2.1.1",
111
110
  "listr": "0.14.3",
112
111
  "locate-path": "7.2.0",
113
112
  "lodash": "4.17.21",
@@ -1,13 +1,18 @@
1
1
  // @ts-check
2
+ import { existsSync } from 'fs'
3
+ import { join, relative, resolve } from 'path'
2
4
  import process from 'process'
3
5
  import { format } from 'util'
4
6
 
5
- import { Project } from '@netlify/build-info'
7
+ import { DefaultLogger, Project } from '@netlify/build-info'
6
8
  // eslint-disable-next-line import/extensions, n/no-missing-import
7
- import { NodeFS } from '@netlify/build-info/node'
9
+ import { NodeFS, NoopLogger } from '@netlify/build-info/node'
8
10
  import { resolveConfig } from '@netlify/config'
9
11
  import { Command, Option } from 'commander'
10
12
  import debug from 'debug'
13
+ import { findUp } from 'find-up'
14
+ import inquirer from 'inquirer'
15
+ import inquirerAutocompletePrompt from 'inquirer-autocomplete-prompt'
11
16
  import merge from 'lodash/merge.js'
12
17
  import { NetlifyAPI } from 'netlify'
13
18
 
@@ -30,22 +35,31 @@ import getGlobalConfig from '../utils/get-global-config.mjs'
30
35
  import { getSiteByName } from '../utils/get-site.mjs'
31
36
  import openBrowser from '../utils/open-browser.mjs'
32
37
  import StateConfig from '../utils/state-config.mjs'
33
- import { identify, track } from '../utils/telemetry/index.mjs'
38
+ import { identify, reportError, track } from '../utils/telemetry/index.mjs'
34
39
 
35
- // Netlify CLI client id. Lives in bot@netlify.com
40
+ // load the autocomplete plugin
41
+ inquirer.registerPrompt('autocomplete', inquirerAutocompletePrompt)
42
+ /** Netlify CLI client id. Lives in bot@netlify.com */
36
43
  // TODO: setup client for multiple environments
37
44
  const CLIENT_ID = 'd6f37de6614df7ae58664cfca524744d73807a377f5ee71f1a254f78412e3750'
38
45
 
39
46
  const NANO_SECS_TO_MSECS = 1e6
40
- // The fallback width for the help terminal
47
+ /** The fallback width for the help terminal */
41
48
  const FALLBACK_HELP_CMD_WIDTH = 80
42
49
 
43
50
  const HELP_$ = NETLIFY_CYAN('$')
44
- // indent on commands or description on the help page
51
+ /** indent on commands or description on the help page */
45
52
  const HELP_INDENT_WIDTH = 2
46
- // separator width between term and description
53
+ /** separator width between term and description */
47
54
  const HELP_SEPARATOR_WIDTH = 5
48
55
 
56
+ /**
57
+ * A list of commands where we don't have to perform the workspace selection at.
58
+ * Those commands work with the system or are not writing any config files that need to be
59
+ * workspace aware.
60
+ */
61
+ const COMMANDS_WITHOUT_WORKSPACE_OPTIONS = new Set(['recipes', 'completion', 'status', 'switch', 'login', 'lm'])
62
+
49
63
  /**
50
64
  * Formats a help list correctly with the correct indent
51
65
  * @param {string[]} textArray
@@ -64,30 +78,83 @@ const getDuration = function (startTime) {
64
78
  }
65
79
 
66
80
  /**
67
- * The netlify object inside each command with the state
68
- * @typedef NetlifyOptions
69
- * @type {object}
70
- * @property {import('netlify').NetlifyAPI} api
71
- * @property {*} repositoryRoot
72
- * @property {object} site
73
- * @property {*} site.root
74
- * @property {*} site.configPath
75
- * @property {*} site.id
76
- * @property {*} siteInfo
77
- * @property {*} config
78
- * @property {*} cachedConfig
79
- * @property {*} globalConfig
80
- * @property {import('../../utils/state-config.mjs').default} state,
81
+ * Retrieves a workspace package based of the filter flag that is provided.
82
+ * 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
83
+ * @param {Project} project
84
+ * @param {string=} filter
85
+ * @returns {Promise<string>}
81
86
  */
87
+ async function selectWorkspace(project, filter) {
88
+ const selected = project.workspace?.packages.find((pkg) => {
89
+ if (
90
+ project.relativeBaseDirectory &&
91
+ project.relativeBaseDirectory.length !== 0 &&
92
+ pkg.path.startsWith(project.relativeBaseDirectory)
93
+ ) {
94
+ return true
95
+ }
96
+ return (pkg.name && pkg.name === filter) || pkg.path === filter
97
+ })
98
+
99
+ if (!selected) {
100
+ log()
101
+ log(chalk.cyan(`We've detected multiple sites inside your repository!`))
102
+
103
+ const { result } = await inquirer.prompt({
104
+ name: 'result',
105
+ type: 'autocomplete',
106
+ message: 'Select a site you want to work with',
107
+ source: (/** @type {string} */ _, input = '') =>
108
+ (project.workspace?.packages || [])
109
+ .filter((pkg) => pkg.path.includes(input))
110
+ .map((pkg) => ({
111
+ name: `${pkg.name ? `${chalk.bold(pkg.name)} ` : ''}${pkg.path} ${chalk.dim(
112
+ `--filter ${pkg.name || pkg.path}`,
113
+ )}`,
114
+ value: pkg.path,
115
+ })),
116
+ })
117
+
118
+ return result
119
+ }
120
+ return selected.path
121
+ }
82
122
 
83
123
  /** Base command class that provides tracking and config initialization */
84
124
  export default class BaseCommand extends Command {
85
- /** @type {NetlifyOptions} */
125
+ /**
126
+ * The netlify object inside each command with the state
127
+ * @type {import('./types.js').NetlifyOptions}
128
+ */
86
129
  netlify
87
130
 
88
131
  /** @type {{ startTime: bigint, payload?: any}} */
89
132
  analytics = { startTime: process.hrtime.bigint() }
90
133
 
134
+ /** @type {Project} */
135
+ project
136
+
137
+ /**
138
+ * The working directory that is used for reading the `netlify.toml` file and storing the state.
139
+ * In a monorepo context this must not be the process working directory and can be an absolute path to the
140
+ * Package/Site that should be worked in.
141
+ */
142
+ // here we actually want to disable the lint rule as it's value is set
143
+ // eslint-disable-next-line workspace/no-process-cwd
144
+ workingDir = process.cwd()
145
+
146
+ /**
147
+ * The workspace root if inside a mono repository.
148
+ * Must not be the repository root!
149
+ * @type {string|undefined}
150
+ */
151
+ jsWorkspaceRoot
152
+ /**
153
+ * The current workspace package we should execute the commands in
154
+ * @type {string|undefined}
155
+ */
156
+ workspacePackage
157
+
91
158
  /**
92
159
  * IMPORTANT this function will be called for each command!
93
160
  * Don't do anything expensive in there.
@@ -95,49 +162,61 @@ export default class BaseCommand extends Command {
95
162
  * @returns
96
163
  */
97
164
  createCommand(name) {
98
- return (
99
- new BaseCommand(name)
100
- // If --silent or --json flag passed disable logger
101
- .addOption(new Option('--json', 'Output return values as JSON').hideHelp(true))
102
- .addOption(new Option('--silent', 'Silence CLI output').hideHelp(true))
103
- .addOption(new Option('--cwd <cwd>').hideHelp(true))
104
- .addOption(new Option('-o, --offline').hideHelp(true))
105
- .addOption(new Option('--auth <token>', 'Netlify auth token').hideHelp(true))
106
- .addOption(
107
- new Option(
108
- '--httpProxy [address]',
109
- 'Old, prefer --http-proxy. Proxy server address to route requests through.',
110
- )
111
- .default(process.env.HTTP_PROXY || process.env.HTTPS_PROXY)
112
- .hideHelp(true),
113
- )
114
- .addOption(
115
- new Option(
116
- '--httpProxyCertificateFilename [file]',
117
- 'Old, prefer --http-proxy-certificate-filename. Certificate file to use when connecting using a proxy server.',
118
- )
119
- .default(process.env.NETLIFY_PROXY_CERTIFICATE_FILENAME)
120
- .hideHelp(true),
165
+ const base = new BaseCommand(name)
166
+ // If --silent or --json flag passed disable logger
167
+ .addOption(new Option('--json', 'Output return values as JSON').hideHelp(true))
168
+ .addOption(new Option('--silent', 'Silence CLI output').hideHelp(true))
169
+ .addOption(new Option('--cwd <cwd>').hideHelp(true))
170
+ .addOption(new Option('-o, --offline').hideHelp(true))
171
+ .addOption(new Option('--auth <token>', 'Netlify auth token').hideHelp(true))
172
+ .addOption(
173
+ new Option('--httpProxy [address]', 'Old, prefer --http-proxy. Proxy server address to route requests through.')
174
+ .default(process.env.HTTP_PROXY || process.env.HTTPS_PROXY)
175
+ .hideHelp(true),
176
+ )
177
+ .addOption(
178
+ new Option(
179
+ '--httpProxyCertificateFilename [file]',
180
+ 'Old, prefer --http-proxy-certificate-filename. Certificate file to use when connecting using a proxy server.',
121
181
  )
122
- .option(
182
+ .default(process.env.NETLIFY_PROXY_CERTIFICATE_FILENAME)
183
+ .hideHelp(true),
184
+ )
185
+ .addOption(
186
+ new Option(
123
187
  '--http-proxy-certificate-filename [file]',
124
188
  'Certificate file to use when connecting using a proxy server',
125
- process.env.NETLIFY_PROXY_CERTIFICATE_FILENAME,
126
189
  )
190
+ .default(process.env.NETLIFY_PROXY_CERTIFICATE_FILENAME)
191
+ .hideHelp(true),
192
+ )
193
+ .addOption(
194
+ new Option('--httpProxy [address]', 'Proxy server address to route requests through.')
195
+ .default(process.env.HTTP_PROXY || process.env.HTTPS_PROXY)
196
+ .hideHelp(true),
197
+ )
198
+ .option('--debug', 'Print debugging information')
199
+
200
+ // only add the `--config` or `--filter` option to commands that are workspace aware
201
+ if (!COMMANDS_WITHOUT_WORKSPACE_OPTIONS.has(name)) {
202
+ base
203
+ .option('--config <configFilePath>', 'Custom path to a netlify configuration file')
127
204
  .option(
128
- '--http-proxy [address]',
129
- 'Proxy server address to route requests through.',
130
- process.env.HTTP_PROXY || process.env.HTTPS_PROXY,
205
+ '--filter <app>',
206
+ 'Optional name of an application to run the command in.\nThis option is needed for working in Monorepos',
131
207
  )
132
- .option('--debug', 'Print debugging information')
133
- .hook('preAction', async (_parentCommand, actionCommand) => {
134
- debug(`${name}:preAction`)('start')
135
- this.analytics = { startTime: process.hrtime.bigint() }
136
- // @ts-ignore cannot type actionCommand as BaseCommand
137
- await this.init(actionCommand)
138
- debug(`${name}:preAction`)('end')
139
- })
140
- )
208
+ }
209
+
210
+ return base.hook('preAction', async (_parentCommand, actionCommand) => {
211
+ if (actionCommand.opts()?.debug) {
212
+ process.env.DEBUG = '*'
213
+ }
214
+ debug(`${name}:preAction`)('start')
215
+ this.analytics = { startTime: process.hrtime.bigint() }
216
+ // @ts-ignore cannot type actionCommand as BaseCommand
217
+ await this.init(actionCommand)
218
+ debug(`${name}:preAction`)('end')
219
+ })
141
220
  }
142
221
 
143
222
  /** @private */
@@ -149,7 +228,7 @@ export default class BaseCommand extends Command {
149
228
  return this
150
229
  }
151
230
 
152
- /** The examples list for the command (used inside doc generation and help page) */
231
+ /** @type {string[]} The examples list for the command (used inside doc generation and help page) */
153
232
  examples = []
154
233
 
155
234
  /**
@@ -172,23 +251,27 @@ export default class BaseCommand extends Command {
172
251
  const term =
173
252
  this.name() === 'netlify'
174
253
  ? `${HELP_$} ${command.name()} [COMMAND]`
175
- : `${HELP_$} ${command.parent.name()} ${command.name()} ${command.usage()}`
254
+ : `${HELP_$} ${command.parent?.name()} ${command.name()} ${command.usage()}`
176
255
 
177
256
  return padLeft(term, HELP_INDENT_WIDTH)
178
257
  }
179
258
 
259
+ /**
260
+ * @param {BaseCommand} command
261
+ */
180
262
  const getCommands = (command) => {
181
263
  const parentCommand = this.name() === 'netlify' ? command : command.parent
182
- return parentCommand.commands.filter((cmd) => {
183
- // eslint-disable-next-line no-underscore-dangle
184
- if (cmd._hidden) return false
185
- // the root command
186
- if (this.name() === 'netlify') {
187
- // don't include subcommands on the main page
188
- return !cmd.name().includes(':')
189
- }
190
- return cmd.name().startsWith(`${command.name()}:`)
191
- })
264
+ return (
265
+ parentCommand?.commands.filter((cmd) => {
266
+ if (cmd._hidden) return false
267
+ // the root command
268
+ if (this.name() === 'netlify') {
269
+ // don't include subcommands on the main page
270
+ return !cmd.name().includes(':')
271
+ }
272
+ return cmd.name().startsWith(`${command.name()}:`)
273
+ }) || []
274
+ )
192
275
  }
193
276
 
194
277
  /**
@@ -281,9 +364,8 @@ export default class BaseCommand extends Command {
281
364
  }
282
365
 
283
366
  // Aliases
284
- // eslint-disable-next-line no-underscore-dangle
367
+
285
368
  if (command._aliases.length !== 0) {
286
- // eslint-disable-next-line no-underscore-dangle
287
369
  const aliases = command._aliases.map((alias) => formatItem(`${parentCommand.name()} ${alias}`, null, true))
288
370
  output = [...output, chalk.bold('ALIASES'), formatHelpList(aliases), '']
289
371
  }
@@ -337,6 +419,11 @@ export default class BaseCommand extends Command {
337
419
  }
338
420
  }
339
421
 
422
+ /**
423
+ *
424
+ * @param {string|undefined} tokenFromFlag
425
+ * @returns
426
+ */
340
427
  async authenticate(tokenFromFlag) {
341
428
  const [token] = await getToken(tokenFromFlag)
342
429
  if (token) {
@@ -406,6 +493,10 @@ export default class BaseCommand extends Command {
406
493
  return accessToken
407
494
  }
408
495
 
496
+ /**
497
+ * Adds some data to the analytics payload
498
+ * @param {Record<string, unknown>} payload
499
+ */
409
500
  setAnalyticsPayload(payload) {
410
501
  const newPayload = { ...this.analytics.payload, ...payload }
411
502
  this.analytics = { ...this.analytics, payload: newPayload }
@@ -418,12 +509,58 @@ export default class BaseCommand extends Command {
418
509
  */
419
510
  async init(actionCommand) {
420
511
  debug(`${actionCommand.name()}:init`)('start')
421
- const options = actionCommand.opts()
422
- const cwd = options.cwd || process.cwd()
423
- // Get site id & build state
424
- const state = new StateConfig(cwd)
512
+ const flags = actionCommand.opts()
513
+ // here we actually want to use the process.cwd as we are setting the workingDir
514
+ // eslint-disable-next-line workspace/no-process-cwd
515
+ this.workingDir = flags.cwd || process.cwd()
516
+
517
+ // ==================================================
518
+ // Create a Project and run the Heuristics to detect
519
+ // if we are run inside a monorepo or not.
520
+ // ==================================================
521
+
522
+ // retrieve the repository root
523
+ const rootDir = await getRepositoryRoot()
524
+ // Get framework, add to analytics payload for every command, if a framework is set
525
+ const fs = new NodeFS()
526
+ // disable logging inside the project and FS if not in debug mode
527
+ fs.logger = actionCommand.opts()?.debug ? new DefaultLogger('debug') : new NoopLogger()
528
+ this.project = new Project(fs, this.workingDir, rootDir)
529
+ .setEnvironment(process.env)
530
+ .setNodeVersion(process.version)
531
+ // eslint-disable-next-line promise/prefer-await-to-callbacks
532
+ .setReportFn((err, reportConfig) => {
533
+ reportError(err, {
534
+ severity: reportConfig?.severity || 'error',
535
+ metadata: reportConfig?.metadata,
536
+ })
537
+ })
538
+ const frameworks = await this.project.detectFrameworks()
539
+ /** @type { string|undefined} */
540
+ let packageConfig = flags.config ? resolve(flags.config) : undefined
541
+ // check if we have detected multiple projects inside which one we have to perform our operations.
542
+ // only ask to select one if on the workspace root
543
+ if (
544
+ !COMMANDS_WITHOUT_WORKSPACE_OPTIONS.has(actionCommand.name()) &&
545
+ this.project.workspace?.packages.length &&
546
+ this.project.workspace.isRoot
547
+ ) {
548
+ this.workspacePackage = await selectWorkspace(this.project, actionCommand.opts().filter)
549
+ this.workingDir = join(this.project.jsWorkspaceRoot, this.workspacePackage)
550
+ }
425
551
 
426
- const [token] = await getToken(options.auth)
552
+ this.jsWorkspaceRoot = this.project.jsWorkspaceRoot
553
+ // detect if a toml exists in this package.
554
+ const tomlFile = join(this.workingDir, 'netlify.toml')
555
+ if (!packageConfig && existsSync(tomlFile)) {
556
+ packageConfig = tomlFile
557
+ }
558
+
559
+ // ==================================================
560
+ // Retrieve Site id and build state from the state.json
561
+ // ==================================================
562
+ const state = new StateConfig(this.workingDir)
563
+ const [token] = await getToken(flags.auth)
427
564
 
428
565
  const apiUrlOpts = {
429
566
  userAgent: USER_AGENT,
@@ -437,12 +574,24 @@ export default class BaseCommand extends Command {
437
574
  process.env.NETLIFY_API_URL === `${apiUrl.protocol}//${apiUrl.host}` ? '/api/v1' : apiUrl.pathname
438
575
  }
439
576
 
440
- const cachedConfig = await actionCommand.getConfig({ cwd, state, token, ...apiUrlOpts })
577
+ // ==================================================
578
+ // Start retrieving the configuration through the
579
+ // configuration file and the API
580
+ // ==================================================
581
+ const cachedConfig = await actionCommand.getConfig({
582
+ cwd: this.jsWorkspaceRoot || this.workingDir,
583
+ repositoryRoot: rootDir,
584
+ // The config flag needs to be resolved from the actual process working directory
585
+ configFilePath: packageConfig,
586
+ state,
587
+ token,
588
+ ...apiUrlOpts,
589
+ })
441
590
  const { buildDir, config, configPath, repositoryRoot, siteInfo } = cachedConfig
442
591
  const normalizedConfig = normalizeConfig(config)
443
592
  const agent = await getAgent({
444
- httpProxy: options.httpProxy,
445
- certificateFile: options.httpProxyCertificateFilename,
593
+ httpProxy: flags.httpProxy,
594
+ certificateFile: flags.httpProxyCertificateFilename,
446
595
  })
447
596
  const apiOpts = { ...apiUrlOpts, agent }
448
597
  const api = new NetlifyAPI(token || '', apiOpts)
@@ -454,33 +603,44 @@ export default class BaseCommand extends Command {
454
603
  // options.site as a site name (and not just site id) was introduced for the deploy command, so users could
455
604
  // deploy by name along with by id
456
605
  let siteData = siteInfo
457
- if (!siteData.url && options.site) {
458
- siteData = await getSiteByName(api, options.site)
606
+ if (!siteData.url && flags.site) {
607
+ siteData = await getSiteByName(api, flags.site)
459
608
  }
460
609
 
461
610
  const globalConfig = await getGlobalConfig()
462
611
 
463
- // Get framework, add to analytics payload for every command, if a framework is set
464
- const fs = new NodeFS()
465
- const project = new Project(fs, buildDir)
466
- const frameworks = await project.detectFrameworks()
467
-
612
+ // ==================================================
613
+ // Perform analytics reporting
614
+ // ==================================================
468
615
  const frameworkIDs = frameworks?.map((framework) => framework.id)
469
-
470
616
  if (frameworkIDs?.length !== 0) {
471
617
  this.setAnalyticsPayload({ frameworks: frameworkIDs })
472
618
  }
473
-
474
619
  this.setAnalyticsPayload({
475
- packageManager: project.packageManager?.name,
476
- buildSystem: project.buildSystems.map(({ id }) => id),
620
+ monorepo: Boolean(this.project.workspace),
621
+ packageManager: this.project.packageManager?.name,
622
+ buildSystem: this.project.buildSystems.map(({ id }) => id),
477
623
  })
478
624
 
625
+ // set the project and the netlify api object on the command,
626
+ // to be accessible inside each command.
627
+ actionCommand.project = this.project
628
+ actionCommand.workingDir = this.workingDir
629
+ actionCommand.workspacePackage = this.workspacePackage
630
+ actionCommand.jsWorkspaceRoot = this.jsWorkspaceRoot
631
+
632
+ // Either an existing configuration file from `@netlify/config` or a file path
633
+ // that should be used for creating it.
634
+ const configFilePath = configPath || join(this.workingDir, 'netlify.toml')
635
+
479
636
  actionCommand.netlify = {
480
637
  // api methods
481
638
  api,
482
639
  apiOpts,
640
+ // The Absolute Repository root (detected through @netlify/config)
483
641
  repositoryRoot,
642
+ configFilePath,
643
+ relConfigFilePath: relative(repositoryRoot, configFilePath),
484
644
  // current site context
485
645
  site: {
486
646
  root: buildDir,
@@ -508,26 +668,36 @@ export default class BaseCommand extends Command {
508
668
 
509
669
  /**
510
670
  * Find and resolve the Netlify configuration
511
- * @param {*} config
512
- * @returns {ReturnType<import('@netlify/config/src/main')>}
671
+ * @param {object} config
672
+ * @param {string} config.cwd
673
+ * @param {string|null=} config.token
674
+ * @param {*} config.state
675
+ * @param {boolean=} config.offline
676
+ * @param {string=} config.configFilePath An optional path to the netlify configuration file e.g. netlify.toml
677
+ * @param {string=} config.repositoryRoot
678
+ * @param {string=} config.host
679
+ * @param {string=} config.pathPrefix
680
+ * @param {string=} config.scheme
681
+ * @returns {ReturnType<typeof resolveConfig>}
513
682
  */
514
683
  async getConfig(config) {
515
- const options = this.opts()
516
- const { cwd, host, offline = options.offline, pathPrefix, scheme, state, token } = config
684
+ // the flags that are passed to the command like `--debug` or `--offline`
685
+ const flags = this.opts()
517
686
 
518
687
  try {
519
688
  return await resolveConfig({
520
- config: options.config,
521
- cwd,
522
- context: options.context || process.env.CONTEXT || this.getDefaultContext(),
523
- debug: this.opts().debug,
524
- siteId: options.siteId || (typeof options.site === 'string' && options.site) || state.get('siteId'),
525
- token,
689
+ config: config.configFilePath,
690
+ repositoryRoot: config.repositoryRoot,
691
+ cwd: config.cwd,
692
+ context: flags.context || process.env.CONTEXT || this.getDefaultContext(),
693
+ debug: flags.debug,
694
+ siteId: flags.siteId || (typeof flags.site === 'string' && flags.site) || config.state.get('siteId'),
695
+ token: config.token,
526
696
  mode: 'cli',
527
- host,
528
- pathPrefix,
529
- scheme,
530
- offline,
697
+ host: config.host,
698
+ pathPrefix: config.pathPrefix,
699
+ scheme: config.scheme,
700
+ offline: config.offline ?? flags.offline,
531
701
  siteFeatureFlagPrefix: 'cli',
532
702
  })
533
703
  } catch (error_) {
@@ -539,17 +709,17 @@ export default class BaseCommand extends Command {
539
709
  //
540
710
  // @todo Replace this with a mechanism for calling `resolveConfig` with more granularity (i.e. having
541
711
  // the option to say that we don't need API data.)
542
- if (isUserError && !offline && token) {
543
- if (this.opts().debug) {
712
+ if (isUserError && !config.offline && config.token) {
713
+ if (flags.debug) {
544
714
  error(error_, { exit: false })
545
715
  warn('Failed to resolve config, falling back to offline resolution')
546
716
  }
547
- return this.getConfig({ cwd, offline: true, state, token })
717
+ // recursive call with trying to resolve offline
718
+ return this.getConfig({ ...config, offline: true })
548
719
  }
549
720
 
550
721
  const message = isUserError ? error_.message : error_.stack
551
- console.error(message)
552
- exit(1)
722
+ error(message, { exit: true })
553
723
  }
554
724
  }
555
725
 
@@ -558,13 +728,22 @@ export default class BaseCommand extends Command {
558
728
  * set. The default context is `dev` most of the time, but some commands may
559
729
  * wish to override that.
560
730
  *
561
- * @returns {string}
731
+ * @returns {'production' | 'dev'}
562
732
  */
563
733
  getDefaultContext() {
564
- if (this.name() === 'serve') {
565
- return 'production'
566
- }
734
+ return this.name() === 'serve' ? 'production' : 'dev'
735
+ }
736
+ }
567
737
 
568
- return 'dev'
738
+ /**
739
+ * Retrieves the repository root through a git command.
740
+ * Returns undefined if not a git project.
741
+ * @param {string} [cwd] The optional current working directory
742
+ * @returns {Promise<string|undefined>}
743
+ */
744
+ async function getRepositoryRoot(cwd) {
745
+ const res = await findUp('.git', { cwd, type: 'directory' })
746
+ if (res) {
747
+ return join(res, '..')
569
748
  }
570
749
  }
@@ -2,6 +2,7 @@
2
2
  import process from 'process'
3
3
 
4
4
  import { getBuildOptions, runBuild } from '../../lib/build.mjs'
5
+ import { detectFrameworkSettings } from '../../utils/build-info.mjs'
5
6
  import { error, exit, getToken } from '../../utils/command-helpers.mjs'
6
7
  import { getEnvelopeEnv, normalizeContext } from '../../utils/env/index.mjs'
7
8
 
@@ -33,11 +34,18 @@ const injectEnv = async function (command, { api, buildOptions, context, siteInf
33
34
  * @param {import('../base-command.mjs').default} command
34
35
  */
35
36
  const build = async (options, command) => {
37
+ const { cachedConfig, siteInfo } = command.netlify
36
38
  command.setAnalyticsPayload({ dry: options.dry })
37
39
  // Retrieve Netlify Build options
38
40
  const [token] = await getToken()
41
+ const settings = await detectFrameworkSettings(command, 'build')
42
+
43
+ // override the build command with the detection result if no command is specified through the config
44
+ if (!cachedConfig.config.build.command) {
45
+ cachedConfig.config.build.command = settings?.buildCommand
46
+ cachedConfig.config.build.commandOrigin = 'heuristics'
47
+ }
39
48
 
40
- const { cachedConfig, siteInfo } = command.netlify
41
49
  const buildOptions = await getBuildOptions({
42
50
  cachedConfig,
43
51
  token,