Repository: netlify/build (js)

Issue:
build-info collect settings from toml file as well
Inside buildinfo we should expose a method that takes already existing `netlify.toml` into account as well and extract things like the build or dev command.

This is needed for the UI and the CLI if we leverage the new build-info package.

Pull request description:
feat(build-info): add detection for toml files and settings collection
🎉 Thanks for submitting a pull request! 🎉

#### Summary

Fixes #5098

expose a function called `getTomlSettings` that reads up all possible config files and returns the settings for it.

---

For us to review and ship your PR efficiently, please perform the following steps:

- [ ] Open a [bug/issue](https://github.com/netlify/build/issues/new/choose) before writing your code 🧑‍💻. This ensures
      we can discuss the changes and get feedback from everyone that should be involved. If you\`re fixing a typo or
      something that\`s on fire 🔥 (e.g. incident related), you can skip this step.
- [ ] Read the [contribution guidelines](https://github.com/netlify/build/blob/main/CONTRIBUTING.md) 📖. This ensures
      your code follows our style guide and passes our tests.
- [ ] Update or add tests (if any source code was changed or added) 🧪
- [ ] Update or add documentation (if features were changed or added) 📝
- [ ] Make sure the status checks below are successful ✅

**A picture of a cute animal (not mandatory, but encouraged)**


Patch:
diff --git a/package-lock.json b/package-lock.json
index 5066ac053..78ff9b6e0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25653,6 +25653,7 @@
         "minimatch": "^9.0.0",
         "read-pkg": "^7.1.0",
         "semver": "^7.3.8",
+        "toml": "^3.0.0",
         "yaml": "^2.1.3",
         "yargs": "^17.6.0"
       },
@@ -32294,6 +32295,7 @@
         "node-fetch": "^3.3.1",
         "read-pkg": "^7.1.0",
         "semver": "^7.3.8",
+        "toml": "^3.0.0",
         "typescript": "^5.0.0",
         "unionfs": "^4.4.0",
         "vite": "^4.0.4",
diff --git a/packages/build-info/package.json b/packages/build-info/package.json
index 7064a483e..0c411b352 100644
--- a/packages/build-info/package.json
+++ b/packages/build-info/package.json
@@ -51,6 +51,7 @@
     "minimatch": "^9.0.0",
     "read-pkg": "^7.1.0",
     "semver": "^7.3.8",
+    "toml": "^3.0.0",
     "yaml": "^2.1.3",
     "yargs": "^17.6.0"
   },
diff --git a/packages/build-info/src/index.ts b/packages/build-info/src/index.ts
index f420a16fe..bd870fe8a 100644
--- a/packages/build-info/src/index.ts
+++ b/packages/build-info/src/index.ts
@@ -1,7 +1,8 @@
 export * from './file-system.js'
+export { DetectedFramework, FrameworkInfo } from './frameworks/framework.js'
 export * from './get-framework.js'
 export * from './project.js'
-export { FrameworkInfo, DetectedFramework } from './frameworks/framework.js'
-export { Settings } from './settings/get-build-settings.js'
-export * from './settings/netlify-toml.js'
+export * from './settings/get-build-settings.js'
+export * from './settings/get-toml-settings.js'
+export { NetlifyTOML } from './settings/netlify-toml.js'
 export { WorkspaceInfo } from './workspaces/detect-workspace.js'
diff --git a/packages/build-info/src/settings/get-build-settings.ts b/packages/build-info/src/settings/get-build-settings.ts
index d7c25d3a8..05d24c235 100644
--- a/packages/build-info/src/settings/get-build-settings.ts
+++ b/packages/build-info/src/settings/get-build-settings.ts
@@ -18,13 +18,17 @@ export type Settings = {
   /** The dist directory that contains the build output */
   dist: string
   env: Record<string, string | undefined>
-  plugins: string[]
+  /** Plugins installed via the netlify.toml */
+  plugins_from_config_file: string[]
+  /** Plugins that are detected via the framework detection and therefore recommended */
+  plugins_recommended: string[]
   pollingStrategies: string[]
   /** The baseDirectory for the UI to configure (used to run the command in this working directory) */
   baseDirectory?: string
+  functionsDir?: string
   /** the workspace package path of an app */
   packagePath?: string
-  tomlModifications?: string
+  template?: string
 }
 
 async function applyBuildSystemOverrides(
@@ -77,7 +81,8 @@ async function getSettings(framework: Framework, project: Project, baseDirectory
     frameworkPort: framework.dev?.port,
     dist: project.fs.join(baseDirectory, frameworkDist),
     env: framework.env || {},
-    plugins: framework.plugins || [],
+    plugins_from_config_file: [],
+    plugins_recommended: framework.plugins || [],
     framework: {
       id: framework.id,
       name: framework.name,
diff --git a/packages/build-info/src/settings/get-toml-settings.ts b/packages/build-info/src/settings/get-toml-settings.ts
index 06620836d..c9c1fd13c 100644
--- a/packages/build-info/src/settings/get-toml-settings.ts
+++ b/packages/build-info/src/settings/get-toml-settings.ts
@@ -1,26 +1,18 @@
+import { dirname } from 'path'
+
 import Bugsnag from '@bugsnag/js'
 import { parse } from 'toml'
 
 import { FileSystem } from '../file-system.js'
+import { Project } from '../project.js'
 
+import { Settings } from './get-build-settings.js'
 import { NetlifyTOML } from './netlify-toml.js'
 
 const {
   default: { notify },
 } = Bugsnag
 
-export type BuildSettings = {
-  cmd: string
-  dir: string
-  framework: string
-  frameworkName: string
-  functions_dir?: string
-  plugins: { package: string }[]
-  plugins_installed?: undefined
-  plugins_recommended: string[]
-  template?: string
-}
-
 /** Gracefully parses a toml file and reports errors to bugsnag */
 function gracefulParseToml<T>(content: string): T {
   try {
@@ -34,22 +26,44 @@ function gracefulParseToml<T>(content: string): T {
   }
 }
 
-export async function getNetlifyTomlSettings(fs: FileSystem, directory: string) {
+export async function getTomlSettingsFromPath(
+  fs: FileSystem,
+  directory: string,
+): Promise<Partial<Settings> | undefined> {
   const tomlFilePath = fs.join(directory, 'netlify.toml')
 
   try {
-    const settings: Partial<BuildSettings> = {}
-    const { build, functions, template } = gracefulParseToml<NetlifyTOML>(await fs.readFile(tomlFilePath))
+    const settings: Partial<Settings> = {}
+    const { build, dev, functions, template, plugins } = gracefulParseToml<NetlifyTOML>(await fs.readFile(tomlFilePath))
 
-    if (build) {
-      settings.cmd = build?.command
-      settings.dir = build.publish
-    }
+    settings.buildCommand = build?.command ?? settings.buildCommand
+    settings.dist = build?.publish ?? settings.dist
+    settings.devCommand = dev?.command ?? settings.devCommand
+    settings.frameworkPort = dev?.port ?? settings.frameworkPort
+    settings.plugins_from_config_file = plugins?.map((p) => p.package) ?? settings.plugins_from_config_file
+    settings.functionsDir = (build?.functions || functions?.directory) ?? settings.functionsDir
+    settings.template = template ?? settings.template
 
-    settings.functions_dir = build?.functions || functions?.directory
-    settings.template = template
+    return settings
   } catch {
-    // no toml found
-    return {}
+    // no toml found or issue while parsing it
+  }
+}
+
+export async function getTomlSettings(project: Project, configFilePath?: string): Promise<Partial<Settings>> {
+  if (configFilePath?.length) {
+    return (await getTomlSettingsFromPath(project.fs, dirname(configFilePath))) || {}
   }
+
+  const baseDirSettings = await getTomlSettingsFromPath(project.fs, project.baseDirectory)
+  if (baseDirSettings) {
+    return baseDirSettings
+  }
+
+  if (project.root) {
+    const rootSettings = await getTomlSettingsFromPath(project.fs, project.root)
+    return rootSettings || {}
+  }
+
+  return {}
 }


Test patch:
diff --git a/packages/build-info/src/frameworks/next.test.ts b/packages/build-info/src/frameworks/next.test.ts
index fce9c3a79..b065d8851 100644
--- a/packages/build-info/src/frameworks/next.test.ts
+++ b/packages/build-info/src/frameworks/next.test.ts
@@ -134,7 +134,7 @@ describe('Nx monorepo', () => {
       devCommand: 'nx run website:serve',
       dist: join('dist/packages/website/.next'),
       frameworkPort: 3000,
-      plugins: ['@netlify/plugin-nextjs'],
+      plugins_recommended: ['@netlify/plugin-nextjs'],
     })
   })
 })
@@ -152,7 +152,7 @@ describe('Nx turborepo', () => {
       devCommand: 'turbo run dev --filter web',
       dist: join('apps/web/.next'),
       frameworkPort: 3000,
-      plugins: ['@netlify/plugin-nextjs'],
+      plugins_recommended: ['@netlify/plugin-nextjs'],
     })
   })
 })
diff --git a/packages/build-info/src/node/__snapshots__/get-build-info.test.ts.snap b/packages/build-info/src/node/__snapshots__/get-build-info.test.ts.snap
index 4747f6757..b72efbe5a 100644
--- a/packages/build-info/src/node/__snapshots__/get-build-info.test.ts.snap
+++ b/packages/build-info/src/node/__snapshots__/get-build-info.test.ts.snap
@@ -57,7 +57,8 @@ exports[`should retrieve the build info for providing a rootDir 1`] = `
       "frameworkPort": 3000,
       "name": "PNPM + Astro packages/blog",
       "packagePath": "packages/blog",
-      "plugins": [],
+      "plugins_from_config_file": [],
+      "plugins_recommended": [],
       "pollingStrategies": [
         "TCP",
         "HTTP",
@@ -76,7 +77,8 @@ exports[`should retrieve the build info for providing a rootDir 1`] = `
       "frameworkPort": 3000,
       "name": "PNPM + Next.js packages/website",
       "packagePath": "packages/website",
-      "plugins": [
+      "plugins_from_config_file": [],
+      "plugins_recommended": [
         "@netlify/plugin-nextjs",
       ],
       "pollingStrategies": [
@@ -144,7 +146,8 @@ exports[`should retrieve the build info for providing a rootDir and a nested pro
       "frameworkPort": 3000,
       "name": "PNPM + Astro packages/blog",
       "packagePath": "packages/blog",
-      "plugins": [],
+      "plugins_from_config_file": [],
+      "plugins_recommended": [],
       "pollingStrategies": [
         "TCP",
         "HTTP",
@@ -211,7 +214,8 @@ exports[`should retrieve the build info for providing a rootDir and the same pro
       "frameworkPort": 3000,
       "name": "PNPM + Astro packages/blog",
       "packagePath": "packages/blog",
-      "plugins": [],
+      "plugins_from_config_file": [],
+      "plugins_recommended": [],
       "pollingStrategies": [
         "TCP",
         "HTTP",
@@ -230,7 +234,8 @@ exports[`should retrieve the build info for providing a rootDir and the same pro
       "frameworkPort": 3000,
       "name": "PNPM + Next.js packages/website",
       "packagePath": "packages/website",
-      "plugins": [
+      "plugins_from_config_file": [],
+      "plugins_recommended": [
         "@netlify/plugin-nextjs",
       ],
       "pollingStrategies": [
diff --git a/packages/build-info/src/settings/get-build-settings.test.ts b/packages/build-info/src/settings/get-build-settings.test.ts
index e38892124..c401bb818 100644
--- a/packages/build-info/src/settings/get-build-settings.test.ts
+++ b/packages/build-info/src/settings/get-build-settings.test.ts
@@ -27,7 +27,8 @@ test('get the settings for a next project', async (ctx) => {
       dist: '.next',
       env: {},
       frameworkPort: 3000,
-      plugins: [],
+      plugins_recommended: [],
+      plugins_from_config_file: [],
       pollingStrategies: ['TCP'],
     }),
   ])
@@ -46,7 +47,8 @@ test('get the settings for a next project if a build system has no commands and
       dist: '.next',
       env: {},
       frameworkPort: 3000,
-      plugins: [],
+      plugins_recommended: [],
+      plugins_from_config_file: [],
       pollingStrategies: ['TCP'],
     }),
   ])
diff --git a/packages/build-info/src/settings/get-toml-settings.test.ts b/packages/build-info/src/settings/get-toml-settings.test.ts
new file mode 100644
index 000000000..83dedb9aa
--- /dev/null
+++ b/packages/build-info/src/settings/get-toml-settings.test.ts
@@ -0,0 +1,113 @@
+import { join } from 'path'
+
+import { beforeEach, expect, test } from 'vitest'
+
+import { mockFileSystem } from '../../tests/mock-file-system.js'
+import { NodeFS } from '../node/file-system.js'
+import { NoopLogger } from '../node/get-build-info.js'
+import { Project } from '../project.js'
+
+import { getTomlSettings } from './get-toml-settings.js'
+
+beforeEach((ctx) => {
+  ctx.fs = new NodeFS()
+  ctx.fs.logger = new NoopLogger()
+})
+
+test('get the settings from the toml of the base directory', async ({ fs }) => {
+  const cwd = mockFileSystem({
+    'netlify.toml': '[build]\ncommand = "npm run build"',
+  })
+  fs.cwd = cwd
+  const project = new Project(fs, cwd)
+  const settings = await getTomlSettings(project)
+
+  expect(settings).toEqual(
+    expect.objectContaining({
+      buildCommand: 'npm run build',
+    }),
+  )
+})
+
+test('get the settings from the toml of the repository root', async ({ fs }) => {
+  const cwd = mockFileSystem({
+    'netlify.toml': '[build]\ncommand = "npm run buld"',
+    'frontend/package.json': JSON.stringify({ name: 'project' }),
+  })
+  fs.cwd = cwd
+  const project = new Project(fs, join(cwd, 'frontend'), cwd)
+  const settings = await getTomlSettings(project)
+
+  expect(settings).toEqual(
+    expect.objectContaining({
+      buildCommand: 'npm run buld',
+    }),
+  )
+})
+
+test('get the settings from the toml of the base directory and not from the root', async ({ fs }) => {
+  const cwd = mockFileSystem({
+    'netlify.toml': '[build]\ncommand = "npm run buld"',
+    'frontend/package.json': JSON.stringify({ name: 'project' }),
+    'frontend/netlify.toml': '[build]\ncommand = "take this"',
+  })
+  fs.cwd = cwd
+  const project = new Project(fs, join(cwd, 'frontend'), cwd)
+  const settings = await getTomlSettings(project)
+
+  expect(settings).toEqual(
+    expect.objectContaining({
+      buildCommand: 'take this',
+    }),
+  )
+})
+
+test('get the settings from the toml of the base directory and not from the root', async ({ fs }) => {
+  const cwd = mockFileSystem({
+    'netlify.toml': '[build]\ncommand = "npm run buld"',
+    'frontend/package.json': JSON.stringify({ name: 'project' }),
+    'frontend/netlify.toml': '[build]\ncommand = "yarn build"',
+    'frontend/other/path/netlify.toml': '[build]\ncommand = "take this"',
+  })
+  fs.cwd = cwd
+  const project = new Project(fs, join(cwd, 'frontend'), cwd)
+  const settings = await getTomlSettings(project, 'frontend/other/path/netlify.toml')
+
+  expect(settings).toEqual(
+    expect.objectContaining({
+      buildCommand: 'take this',
+    }),
+  )
+})
+
+test('get all toml fields that are important', async ({ fs }) => {
+  const cwd = mockFileSystem({
+    'netlify.toml': `
+[build]
+command = "next build"
+publish = ".next"
+functions = "api"
+
+[dev]
+command = "next"
+port = 3000
+
+[[plugins]]
+package = "@netlify/plugin-nextjs"
+    `,
+  })
+  fs.cwd = cwd
+  const project = new Project(fs, cwd)
+  const settings = await getTomlSettings(project)
+
+  expect(settings).toEqual(
+    expect.objectContaining({
+      buildCommand: 'next build',
+      devCommand: 'next',
+      dist: '.next',
+      frameworkPort: 3000,
+      functionsDir: 'api',
+      plugins_from_config_file: ['@netlify/plugin-nextjs'],
+    }),
+  )
+})