netlify-cli 15.11.0 → 16.0.0-alpha.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 (44) hide show
  1. package/bin/run.mjs +6 -5
  2. package/npm-shrinkwrap.json +628 -42
  3. package/package.json +4 -5
  4. package/src/commands/base-command.mjs +295 -118
  5. package/src/commands/build/build.mjs +9 -1
  6. package/src/commands/deploy/deploy.mjs +42 -18
  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 +3 -3
  11. package/src/commands/functions/functions-serve.mjs +1 -0
  12. package/src/commands/init/init.mjs +1 -1
  13. package/src/commands/link/link.mjs +5 -5
  14. package/src/commands/serve/serve.mjs +10 -6
  15. package/src/commands/sites/sites-create-template.mjs +1 -1
  16. package/src/commands/sites/sites-create.mjs +1 -1
  17. package/src/functions-templates/javascript/google-analytics/package.json +1 -1
  18. package/src/functions-templates/typescript/scheduled-function/package.json +1 -1
  19. package/src/lib/edge-functions/deploy.mjs +11 -4
  20. package/src/lib/edge-functions/internal.mjs +5 -3
  21. package/src/lib/edge-functions/proxy.mjs +29 -5
  22. package/src/lib/functions/runtimes/js/builders/zisi.mjs +20 -3
  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/deploy/deploy-site.mjs +4 -4
  29. package/src/utils/deploy/hash-fns.mjs +2 -2
  30. package/src/utils/detect-server-settings.mjs +133 -245
  31. package/src/utils/framework-server.mjs +6 -5
  32. package/src/utils/functions/functions.mjs +8 -5
  33. package/src/utils/get-repo-data.mjs +5 -6
  34. package/src/utils/init/config-github.mjs +2 -2
  35. package/src/utils/init/config-manual.mjs +24 -7
  36. package/src/utils/init/utils.mjs +68 -68
  37. package/src/utils/proxy-server.mjs +7 -4
  38. package/src/utils/proxy.mjs +4 -3
  39. package/src/utils/read-repo-url.mjs +4 -0
  40. package/src/utils/run-build.mjs +58 -32
  41. package/src/utils/shell.mjs +24 -7
  42. package/src/utils/state-config.mjs +5 -1
  43. package/src/utils/static-server.mjs +4 -0
  44. package/src/utils/init/frameworks.mjs +0 -23
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "netlify-cli",
3
3
  "description": "Netlify command line tool",
4
- "version": "15.11.0",
4
+ "version": "16.0.0-alpha.0",
5
5
  "author": "Netlify Inc.",
6
6
  "type": "module",
7
7
  "engines": {
8
- "node": "^14.18.0 || >=16.0.0"
8
+ "node": ">=16.16.0"
9
9
  },
10
10
  "files": [
11
11
  "/bin",
@@ -44,11 +44,10 @@
44
44
  "dependencies": {
45
45
  "@bugsnag/js": "7.20.2",
46
46
  "@fastify/static": "6.10.2",
47
- "@netlify/build": "29.17.3",
47
+ "@netlify/build": "29.19.0",
48
48
  "@netlify/build-info": "7.7.3",
49
- "@netlify/config": "20.6.4",
49
+ "@netlify/config": "20.8.0",
50
50
  "@netlify/edge-bundler": "8.17.1",
51
- "@netlify/framework-info": "9.8.10",
52
51
  "@netlify/local-functions-proxy": "1.1.1",
53
52
  "@netlify/serverless-functions-api": "1.5.2",
54
53
  "@netlify/zip-it-and-ship-it": "9.13.1",
@@ -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(['api', '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 the 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 its 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,56 @@ 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
  )
127
- .option(
128
- '--http-proxy [address]',
129
- 'Proxy server address to route requests through.',
130
- process.env.HTTP_PROXY || process.env.HTTPS_PROXY,
131
- )
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
- )
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 `--filter` option to commands that are workspace aware
201
+ if (!COMMANDS_WITHOUT_WORKSPACE_OPTIONS.has(name)) {
202
+ base.option('--filter <app>', 'For monorepos, specify the name of the application to run the command in')
203
+ }
204
+
205
+ return base.hook('preAction', async (_parentCommand, actionCommand) => {
206
+ if (actionCommand.opts()?.debug) {
207
+ process.env.DEBUG = '*'
208
+ }
209
+ debug(`${name}:preAction`)('start')
210
+ this.analytics = { startTime: process.hrtime.bigint() }
211
+ // @ts-ignore cannot type actionCommand as BaseCommand
212
+ await this.init(actionCommand)
213
+ debug(`${name}:preAction`)('end')
214
+ })
141
215
  }
142
216
 
143
217
  /** @private */
@@ -149,7 +223,7 @@ export default class BaseCommand extends Command {
149
223
  return this
150
224
  }
151
225
 
152
- /** The examples list for the command (used inside doc generation and help page) */
226
+ /** @type {string[]} The examples list for the command (used inside doc generation and help page) */
153
227
  examples = []
154
228
 
155
229
  /**
@@ -172,23 +246,27 @@ export default class BaseCommand extends Command {
172
246
  const term =
173
247
  this.name() === 'netlify'
174
248
  ? `${HELP_$} ${command.name()} [COMMAND]`
175
- : `${HELP_$} ${command.parent.name()} ${command.name()} ${command.usage()}`
249
+ : `${HELP_$} ${command.parent?.name()} ${command.name()} ${command.usage()}`
176
250
 
177
251
  return padLeft(term, HELP_INDENT_WIDTH)
178
252
  }
179
253
 
254
+ /**
255
+ * @param {BaseCommand} command
256
+ */
180
257
  const getCommands = (command) => {
181
258
  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
- })
259
+ return (
260
+ parentCommand?.commands.filter((cmd) => {
261
+ if (cmd._hidden) return false
262
+ // the root command
263
+ if (this.name() === 'netlify') {
264
+ // don't include subcommands on the main page
265
+ return !cmd.name().includes(':')
266
+ }
267
+ return cmd.name().startsWith(`${command.name()}:`)
268
+ }) || []
269
+ )
192
270
  }
193
271
 
194
272
  /**
@@ -281,9 +359,8 @@ export default class BaseCommand extends Command {
281
359
  }
282
360
 
283
361
  // Aliases
284
- // eslint-disable-next-line no-underscore-dangle
362
+
285
363
  if (command._aliases.length !== 0) {
286
- // eslint-disable-next-line no-underscore-dangle
287
364
  const aliases = command._aliases.map((alias) => formatItem(`${parentCommand.name()} ${alias}`, null, true))
288
365
  output = [...output, chalk.bold('ALIASES'), formatHelpList(aliases), '']
289
366
  }
@@ -337,6 +414,11 @@ export default class BaseCommand extends Command {
337
414
  }
338
415
  }
339
416
 
417
+ /**
418
+ *
419
+ * @param {string|undefined} tokenFromFlag
420
+ * @returns
421
+ */
340
422
  async authenticate(tokenFromFlag) {
341
423
  const [token] = await getToken(tokenFromFlag)
342
424
  if (token) {
@@ -406,6 +488,10 @@ export default class BaseCommand extends Command {
406
488
  return accessToken
407
489
  }
408
490
 
491
+ /**
492
+ * Adds some data to the analytics payload
493
+ * @param {Record<string, unknown>} payload
494
+ */
409
495
  setAnalyticsPayload(payload) {
410
496
  const newPayload = { ...this.analytics.payload, ...payload }
411
497
  this.analytics = { ...this.analytics, payload: newPayload }
@@ -418,12 +504,58 @@ export default class BaseCommand extends Command {
418
504
  */
419
505
  async init(actionCommand) {
420
506
  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)
507
+ const flags = actionCommand.opts()
508
+ // here we actually want to use the process.cwd as we are setting the workingDir
509
+ // eslint-disable-next-line workspace/no-process-cwd
510
+ this.workingDir = flags.cwd || process.cwd()
511
+
512
+ // ==================================================
513
+ // Create a Project and run the Heuristics to detect
514
+ // if we are running inside a monorepo or not.
515
+ // ==================================================
516
+
517
+ // retrieve the repository root
518
+ const rootDir = await getRepositoryRoot()
519
+ // Get framework, add to analytics payload for every command, if a framework is set
520
+ const fs = new NodeFS()
521
+ // disable logging inside the project and FS if not in debug mode
522
+ fs.logger = actionCommand.opts()?.debug ? new DefaultLogger('debug') : new NoopLogger()
523
+ this.project = new Project(fs, this.workingDir, rootDir)
524
+ .setEnvironment(process.env)
525
+ .setNodeVersion(process.version)
526
+ // eslint-disable-next-line promise/prefer-await-to-callbacks
527
+ .setReportFn((err, reportConfig) => {
528
+ reportError(err, {
529
+ severity: reportConfig?.severity || 'error',
530
+ metadata: reportConfig?.metadata,
531
+ })
532
+ })
533
+ const frameworks = await this.project.detectFrameworks()
534
+ /** @type { string|undefined} */
535
+ let packageConfig = flags.config ? resolve(flags.config) : undefined
536
+ // check if we have detected multiple projects inside which one we have to perform our operations.
537
+ // only ask to select one if on the workspace root
538
+ if (
539
+ !COMMANDS_WITHOUT_WORKSPACE_OPTIONS.has(actionCommand.name()) &&
540
+ this.project.workspace?.packages.length &&
541
+ this.project.workspace.isRoot
542
+ ) {
543
+ this.workspacePackage = await selectWorkspace(this.project, actionCommand.opts().filter)
544
+ this.workingDir = join(this.project.jsWorkspaceRoot, this.workspacePackage)
545
+ }
425
546
 
426
- const [token] = await getToken(options.auth)
547
+ this.jsWorkspaceRoot = this.project.jsWorkspaceRoot
548
+ // detect if a toml exists in this package.
549
+ const tomlFile = join(this.workingDir, 'netlify.toml')
550
+ if (!packageConfig && existsSync(tomlFile)) {
551
+ packageConfig = tomlFile
552
+ }
553
+
554
+ // ==================================================
555
+ // Retrieve Site id and build state from the state.json
556
+ // ==================================================
557
+ const state = new StateConfig(this.workingDir)
558
+ const [token] = await getToken(flags.auth)
427
559
 
428
560
  const apiUrlOpts = {
429
561
  userAgent: USER_AGENT,
@@ -437,12 +569,25 @@ export default class BaseCommand extends Command {
437
569
  process.env.NETLIFY_API_URL === `${apiUrl.protocol}//${apiUrl.host}` ? '/api/v1' : apiUrl.pathname
438
570
  }
439
571
 
440
- const cachedConfig = await actionCommand.getConfig({ cwd, state, token, ...apiUrlOpts })
572
+ // ==================================================
573
+ // Start retrieving the configuration through the
574
+ // configuration file and the API
575
+ // ==================================================
576
+ const cachedConfig = await actionCommand.getConfig({
577
+ cwd: this.jsWorkspaceRoot || this.workingDir,
578
+ repositoryRoot: rootDir,
579
+ packagePath: this.workspacePackage,
580
+ // The config flag needs to be resolved from the actual process working directory
581
+ configFilePath: packageConfig,
582
+ state,
583
+ token,
584
+ ...apiUrlOpts,
585
+ })
441
586
  const { buildDir, config, configPath, repositoryRoot, siteInfo } = cachedConfig
442
587
  const normalizedConfig = normalizeConfig(config)
443
588
  const agent = await getAgent({
444
- httpProxy: options.httpProxy,
445
- certificateFile: options.httpProxyCertificateFilename,
589
+ httpProxy: flags.httpProxy,
590
+ certificateFile: flags.httpProxyCertificateFilename,
446
591
  })
447
592
  const apiOpts = { ...apiUrlOpts, agent }
448
593
  const api = new NetlifyAPI(token || '', apiOpts)
@@ -454,33 +599,44 @@ export default class BaseCommand extends Command {
454
599
  // options.site as a site name (and not just site id) was introduced for the deploy command, so users could
455
600
  // deploy by name along with by id
456
601
  let siteData = siteInfo
457
- if (!siteData.url && options.site) {
458
- siteData = await getSiteByName(api, options.site)
602
+ if (!siteData.url && flags.site) {
603
+ siteData = await getSiteByName(api, flags.site)
459
604
  }
460
605
 
461
606
  const globalConfig = await getGlobalConfig()
462
607
 
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
-
608
+ // ==================================================
609
+ // Perform analytics reporting
610
+ // ==================================================
468
611
  const frameworkIDs = frameworks?.map((framework) => framework.id)
469
-
470
612
  if (frameworkIDs?.length !== 0) {
471
613
  this.setAnalyticsPayload({ frameworks: frameworkIDs })
472
614
  }
473
-
474
615
  this.setAnalyticsPayload({
475
- packageManager: project.packageManager?.name,
476
- buildSystem: project.buildSystems.map(({ id }) => id),
616
+ monorepo: Boolean(this.project.workspace),
617
+ packageManager: this.project.packageManager?.name,
618
+ buildSystem: this.project.buildSystems.map(({ id }) => id),
477
619
  })
478
620
 
621
+ // set the project and the netlify api object on the command,
622
+ // to be accessible inside each command.
623
+ actionCommand.project = this.project
624
+ actionCommand.workingDir = this.workingDir
625
+ actionCommand.workspacePackage = this.workspacePackage
626
+ actionCommand.jsWorkspaceRoot = this.jsWorkspaceRoot
627
+
628
+ // Either an existing configuration file from `@netlify/config` or a file path
629
+ // that should be used for creating it.
630
+ const configFilePath = configPath || join(this.workingDir, 'netlify.toml')
631
+
479
632
  actionCommand.netlify = {
480
633
  // api methods
481
634
  api,
482
635
  apiOpts,
636
+ // The absolute repository root (detected through @netlify/config)
483
637
  repositoryRoot,
638
+ configFilePath,
639
+ relConfigFilePath: relative(repositoryRoot, configFilePath),
484
640
  // current site context
485
641
  site: {
486
642
  root: buildDir,
@@ -508,26 +664,38 @@ export default class BaseCommand extends Command {
508
664
 
509
665
  /**
510
666
  * Find and resolve the Netlify configuration
511
- * @param {*} config
512
- * @returns {ReturnType<import('@netlify/config/src/main')>}
667
+ * @param {object} config
668
+ * @param {string} config.cwd
669
+ * @param {string|null=} config.token
670
+ * @param {*} config.state
671
+ * @param {boolean=} config.offline
672
+ * @param {string=} config.configFilePath An optional path to the netlify configuration file e.g. netlify.toml
673
+ * @param {string=} config.packagePath
674
+ * @param {string=} config.repositoryRoot
675
+ * @param {string=} config.host
676
+ * @param {string=} config.pathPrefix
677
+ * @param {string=} config.scheme
678
+ * @returns {ReturnType<typeof resolveConfig>}
513
679
  */
514
680
  async getConfig(config) {
515
- const options = this.opts()
516
- const { cwd, host, offline = options.offline, pathPrefix, scheme, state, token } = config
681
+ // the flags that are passed to the command like `--debug` or `--offline`
682
+ const flags = this.opts()
517
683
 
518
684
  try {
519
685
  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,
686
+ config: config.configFilePath,
687
+ packagePath: config.packagePath,
688
+ repositoryRoot: config.repositoryRoot,
689
+ cwd: config.cwd,
690
+ context: flags.context || process.env.CONTEXT || this.getDefaultContext(),
691
+ debug: flags.debug,
692
+ siteId: flags.siteId || (typeof flags.site === 'string' && flags.site) || config.state.get('siteId'),
693
+ token: config.token,
526
694
  mode: 'cli',
527
- host,
528
- pathPrefix,
529
- scheme,
530
- offline,
695
+ host: config.host,
696
+ pathPrefix: config.pathPrefix,
697
+ scheme: config.scheme,
698
+ offline: config.offline ?? flags.offline,
531
699
  siteFeatureFlagPrefix: 'cli',
532
700
  })
533
701
  } catch (error_) {
@@ -539,17 +707,17 @@ export default class BaseCommand extends Command {
539
707
  //
540
708
  // @todo Replace this with a mechanism for calling `resolveConfig` with more granularity (i.e. having
541
709
  // the option to say that we don't need API data.)
542
- if (isUserError && !offline && token) {
543
- if (this.opts().debug) {
710
+ if (isUserError && !config.offline && config.token) {
711
+ if (flags.debug) {
544
712
  error(error_, { exit: false })
545
713
  warn('Failed to resolve config, falling back to offline resolution')
546
714
  }
547
- return this.getConfig({ cwd, offline: true, state, token })
715
+ // recursive call with trying to resolve offline
716
+ return this.getConfig({ ...config, offline: true })
548
717
  }
549
718
 
550
719
  const message = isUserError ? error_.message : error_.stack
551
- console.error(message)
552
- exit(1)
720
+ error(message, { exit: true })
553
721
  }
554
722
  }
555
723
 
@@ -558,13 +726,22 @@ export default class BaseCommand extends Command {
558
726
  * set. The default context is `dev` most of the time, but some commands may
559
727
  * wish to override that.
560
728
  *
561
- * @returns {string}
729
+ * @returns {'production' | 'dev'}
562
730
  */
563
731
  getDefaultContext() {
564
- if (this.name() === 'serve') {
565
- return 'production'
566
- }
732
+ return this.name() === 'serve' ? 'production' : 'dev'
733
+ }
734
+ }
567
735
 
568
- return 'dev'
736
+ /**
737
+ * Retrieves the repository root through a git command.
738
+ * Returns undefined if not a git project.
739
+ * @param {string} [cwd] The optional current working directory
740
+ * @returns {Promise<string|undefined>}
741
+ */
742
+ async function getRepositoryRoot(cwd) {
743
+ const res = await findUp('.git', { cwd, type: 'directory' })
744
+ if (res) {
745
+ return join(res, '..')
569
746
  }
570
747
  }
@@ -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,