netlify-cli 16.8.0 → 16.9.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "netlify-cli",
3
3
  "description": "Netlify command line tool",
4
- "version": "16.8.0",
4
+ "version": "16.9.0",
5
5
  "author": "Netlify Inc.",
6
6
  "type": "module",
7
7
  "engines": {
@@ -49,6 +49,7 @@
49
49
  "@netlify/config": "20.9.0",
50
50
  "@netlify/edge-bundler": "9.3.0",
51
51
  "@netlify/local-functions-proxy": "1.1.1",
52
+ "@netlify/sdk": "^1.1.5",
52
53
  "@netlify/serverless-functions-api": "1.9.1",
53
54
  "@netlify/zip-it-and-ship-it": "9.25.1",
54
55
  "@octokit/rest": "19.0.13",
@@ -104,6 +105,7 @@
104
105
  "is-stream": "3.0.0",
105
106
  "is-wsl": "2.2.0",
106
107
  "isexe": "2.0.0",
108
+ "js-yaml": "^4.1.0",
107
109
  "jsonwebtoken": "9.0.1",
108
110
  "jwt-decode": "3.1.2",
109
111
  "lambda-local": "2.1.2",
@@ -9,9 +9,11 @@ import { getEnvelopeEnv, normalizeContext } from '../../utils/env/index.mjs'
9
9
  /**
10
10
  * @param {import('../../lib/build.mjs').BuildConfig} options
11
11
  */
12
- const checkOptions = ({ cachedConfig: { siteInfo = {} }, token }) => {
12
+ export const checkOptions = ({ cachedConfig: { siteInfo = {} }, token }) => {
13
13
  if (!siteInfo.id) {
14
- error('Could not find the site ID. Please run netlify link.')
14
+ error(
15
+ 'Could not find the site ID. If your site is not on Netlify, please run `netlify init` or `netlify deploy` first. If it is, please run `netlify link`.',
16
+ )
15
17
  }
16
18
 
17
19
  if (!token) {
@@ -475,12 +475,13 @@ const bundleEdgeFunctions = async (options, command) => {
475
475
  *
476
476
  * @param {object} config
477
477
  * @param {boolean} config.deployToProduction
478
+ * @param {boolean} config.isIntegrationDeploy If the user ran netlify integration:deploy instead of just netlify deploy
478
479
  * @param {boolean} config.json If the result should be printed as json message
479
480
  * @param {boolean} config.runBuildCommand If the build command should be run
480
481
  * @param {object} config.results
481
482
  * @returns {void}
482
483
  */
483
- const printResults = ({ deployToProduction, json, results, runBuildCommand }) => {
484
+ const printResults = ({ deployToProduction, isIntegrationDeploy, json, results, runBuildCommand }) => {
484
485
  const msgData = {
485
486
  'Build logs': results.logsUrl,
486
487
  'Function logs': results.functionLogsUrl,
@@ -518,7 +519,11 @@ const printResults = ({ deployToProduction, json, results, runBuildCommand }) =>
518
519
  if (!deployToProduction) {
519
520
  log()
520
521
  log('If everything looks good on your draft URL, deploy it to your main site URL with the --prod flag.')
521
- log(`${chalk.cyanBright.bold(`netlify deploy${runBuildCommand ? ' --build' : ''} --prod`)}`)
522
+ log(
523
+ `${chalk.cyanBright.bold(
524
+ `netlify ${isIntegrationDeploy ? 'integration:' : ''}deploy${runBuildCommand ? ' --build' : ''} --prod`,
525
+ )}`,
526
+ )
522
527
  log()
523
528
  }
524
529
  }
@@ -529,7 +534,7 @@ const printResults = ({ deployToProduction, json, results, runBuildCommand }) =>
529
534
  * @param {import('commander').OptionValues} options
530
535
  * @param {import('../base-command.mjs').default} command
531
536
  */
532
- const deploy = async (options, command) => {
537
+ export const deploy = async (options, command) => {
533
538
  const { workingDir } = command
534
539
  const { api, site, siteInfo } = command.netlify
535
540
  const alias = options.alias || options.branch
@@ -675,8 +680,11 @@ const deploy = async (options, command) => {
675
680
  // @ts-ignore
676
681
  await restoreConfig(configMutations, { buildDir: deployFolder, configPath, redirectsPath })
677
682
 
683
+ const isIntegrationDeploy = command.name() === 'integration:deploy'
684
+
678
685
  printResults({
679
686
  runBuildCommand: options.build,
687
+ isIntegrationDeploy,
680
688
  json: options.json,
681
689
  results,
682
690
  deployToProduction,
@@ -0,0 +1,397 @@
1
+ /* eslint-disable import/extensions */
2
+ import { resolve } from 'path'
3
+ import { exit, env } from 'process'
4
+
5
+ // eslint-disable-next-line n/no-missing-import
6
+ import { getConfiguration } from '@netlify/sdk/cli-utils'
7
+ // eslint-disable-next-line n/no-unpublished-import
8
+ import fs from 'fs-extra'
9
+ import inquirer from 'inquirer'
10
+ import yaml from 'js-yaml'
11
+ import fetch from 'node-fetch'
12
+
13
+ import { getBuildOptions } from '../../lib/build.mjs'
14
+ import { getToken, chalk, log } from '../../utils/command-helpers.mjs'
15
+ import { getSiteInformation } from '../../utils/dev.mjs'
16
+ import { checkOptions } from '../build/build.mjs'
17
+ import { deploy as siteDeploy } from '../deploy/deploy.mjs'
18
+
19
+ function getIntegrationAPIUrl() {
20
+ return env.INTEGRATION_URL || 'https://api.netlifysdk.com'
21
+ }
22
+
23
+ export function areScopesEqual(localScopes, remoteScopes) {
24
+ if (localScopes.length !== remoteScopes.length) {
25
+ return false
26
+ }
27
+
28
+ return localScopes.every((scope) => remoteScopes.includes(scope))
29
+ }
30
+
31
+ function logScopeConfirmationMessage(localScopes, remoteScopes) {
32
+ log(chalk.yellow(`This integration is already registered. The current required scopes are:`))
33
+ for (const scope of remoteScopes) {
34
+ log(chalk.green(`- ${scope}`))
35
+ }
36
+ log(chalk.yellow('and will be updated to:'))
37
+ for (const scope of localScopes) {
38
+ log(chalk.green(`- ${scope}`))
39
+ }
40
+ log(chalk.yellow('if you continue. This will only affect future installations of the integration.'))
41
+ }
42
+
43
+ function formatScopesToWrite(registeredIntegrationScopes) {
44
+ let scopesToWrite = {}
45
+
46
+ for (const scope of registeredIntegrationScopes) {
47
+ const [resource, permission] = scope.split(':')
48
+ if (resource === 'all') {
49
+ scopesToWrite = { all: true }
50
+ break
51
+ } else {
52
+ if (!scopesToWrite[resource]) {
53
+ scopesToWrite[resource] = []
54
+ }
55
+ scopesToWrite[resource].push(permission)
56
+ }
57
+ }
58
+ return scopesToWrite
59
+ }
60
+
61
+ function formatScopesForRemote(scopes) {
62
+ const scopesToWrite = []
63
+ if (scopes.all) {
64
+ scopesToWrite.push('all')
65
+ } else {
66
+ const scopeResources = Object.keys(scopes)
67
+ scopeResources.forEach((resource) => {
68
+ const permissionsRequested = scopes[resource]
69
+ permissionsRequested.forEach((permission) => {
70
+ scopesToWrite.push(`${resource}:${permission}`)
71
+ })
72
+ })
73
+ }
74
+ return scopesToWrite.join(',')
75
+ }
76
+
77
+ function verifyRequiredFieldsAreInConfig(name, description, scopes, integrationLevel) {
78
+ const missingFields = []
79
+
80
+ if (!name) {
81
+ missingFields.push('name')
82
+ }
83
+ if (!description) {
84
+ missingFields.push('description')
85
+ }
86
+ if (!scopes) {
87
+ missingFields.push('scopes')
88
+ }
89
+ if (!integrationLevel) {
90
+ missingFields.push('integrationLevel')
91
+ }
92
+ if (missingFields.length !== 0) {
93
+ log(
94
+ chalk.yellow(
95
+ `You are missing the following fields for the integration to be deployed: ${missingFields.join(
96
+ ', ',
97
+ )}. Please add a these fields as an entry to the integration.yaml file and try again.`,
98
+ ),
99
+ )
100
+ log(
101
+ chalk.yellow(
102
+ 'For more information on the required fields, please see the documentation: https://ntl.fyi/create-private-integration',
103
+ ),
104
+ )
105
+ return false
106
+ }
107
+ return true
108
+ }
109
+
110
+ // eslint-disable-next-line max-params
111
+ export async function registerIntegration(workingDir, siteId, accountId, localIntegrationConfig, token) {
112
+ const { description, integrationLevel, name, scopes, slug } = localIntegrationConfig
113
+ log(chalk.yellow(`An integration associated with the site ID ${siteId} is not registered.`))
114
+ const registerPrompt = await inquirer.prompt([
115
+ {
116
+ type: 'confirm',
117
+ name: 'registerIntegration',
118
+ message: `Would you like to register this site as a private integration now?`,
119
+ default: false,
120
+ },
121
+ ])
122
+
123
+ if (!registerPrompt.registerIntegration) {
124
+ log(
125
+ chalk.white(
126
+ "Cancelling deployment. Please run 'netlify int deploy' again when you are ready to register the integration.",
127
+ ),
128
+ )
129
+ log(
130
+ chalk.white(
131
+ "You can also register the integration through the Netlify UI on the 'Integrations' > 'Create private integration' page",
132
+ ),
133
+ )
134
+ exit(1)
135
+ }
136
+
137
+ if (!verifyRequiredFieldsAreInConfig(name, description, scopes, integrationLevel)) {
138
+ exit(1)
139
+ }
140
+
141
+ log(chalk.white('Registering the integration...'))
142
+
143
+ const { body, statusCode } = await fetch(`${getIntegrationAPIUrl()}/${accountId}/integrations`, {
144
+ method: 'POST',
145
+ headers: {
146
+ 'netlify-token': token,
147
+ },
148
+ body: JSON.stringify({
149
+ name,
150
+ slug,
151
+ description,
152
+ hostSiteId: siteId,
153
+ scopes: formatScopesForRemote(scopes),
154
+ integrationLevel,
155
+ }),
156
+ }).then(async (res) => {
157
+ const response = await res.json()
158
+ return { body: response, statusCode: res.status }
159
+ })
160
+
161
+ if (statusCode !== 201) {
162
+ log(chalk.red(`There was an error registering the integration:`))
163
+ log()
164
+ log(chalk.red(`-----------------------------------------------`))
165
+ log(chalk.red(body.msg))
166
+ log(chalk.red(`-----------------------------------------------`))
167
+ log()
168
+ log(chalk.red(`Please try again. If the problem persists, please contact support.`))
169
+ exit(1)
170
+ }
171
+
172
+ log(chalk.green(`Successfully registered the integration with the slug: ${body.slug}`))
173
+
174
+ const updatedIntegrationConfig = yaml.dump({
175
+ config: { name, description, slug: body.slug, scopes, integrationLevel },
176
+ })
177
+
178
+ const filePath = resolve(workingDir, 'integration.yaml')
179
+ await fs.writeFile(filePath, updatedIntegrationConfig)
180
+
181
+ log(chalk.yellow('Your integration.yaml file has been updated. Please commit and push these changes.'))
182
+ }
183
+
184
+ // eslint-disable-next-line max-params
185
+ export async function updateIntegration(
186
+ workingDir,
187
+ options,
188
+ siteId,
189
+ accountId,
190
+ localIntegrationConfig,
191
+ token,
192
+ registeredIntegration,
193
+ ) {
194
+ let { description, integrationLevel, name, scopes, slug } = localIntegrationConfig
195
+
196
+ let integrationSlug = slug
197
+ if (slug !== registeredIntegration.slug) {
198
+ // Update the project's integration.yaml file with the remote slug since that will
199
+ // be considered the source of truth and is a value that can't be edited by the user.
200
+ // Let the user know they need to commit and push the changes.
201
+ integrationSlug = registeredIntegration.slug
202
+ }
203
+
204
+ if (!name) {
205
+ // Disabling this lint rule because the destructuring was not assigning the variable correct and leading to a bug
206
+ // eslint-disable-next-line prefer-destructuring
207
+ name = registeredIntegration.name
208
+ }
209
+
210
+ if (!description) {
211
+ // eslint-disable-next-line prefer-destructuring
212
+ description = registeredIntegration.description
213
+ }
214
+
215
+ if (!integrationLevel) {
216
+ // eslint-disable-next-line prefer-destructuring
217
+ integrationLevel = registeredIntegration.integrationLevel
218
+ }
219
+
220
+ // This is returned as a comma separated string and will be easier to manage here as an array
221
+ const registeredIntegrationScopes = registeredIntegration.scopes.split(',')
222
+
223
+ const scopeResources = Object.keys(scopes)
224
+ let localScopes = []
225
+
226
+ if (scopeResources.includes('all')) {
227
+ localScopes = ['all']
228
+ } else {
229
+ scopeResources.forEach((resource) => {
230
+ const permissionsRequested = scopes[resource]
231
+ permissionsRequested.forEach((permission) => {
232
+ localScopes.push(`${resource}:${permission}`)
233
+ })
234
+ })
235
+ }
236
+
237
+ if (!areScopesEqual(localScopes, registeredIntegrationScopes)) {
238
+ logScopeConfirmationMessage(localScopes, registeredIntegrationScopes)
239
+
240
+ const scopePrompt = await inquirer.prompt([
241
+ {
242
+ type: 'confirm',
243
+ name: 'updateScopes',
244
+ message: `Do you want to update the scopes?`,
245
+ default: false,
246
+ },
247
+ ])
248
+
249
+ let scopesToWrite
250
+ if (scopePrompt.updateScopes) {
251
+ // Update the scopes in remote
252
+ scopesToWrite = scopes
253
+ const { statusCode, updateResponse } = await fetch(
254
+ `${getIntegrationAPIUrl()}/${accountId}/integrations/${integrationSlug}`,
255
+ {
256
+ method: 'PUT',
257
+ headers: {
258
+ 'netlify-token': token,
259
+ },
260
+ body: JSON.stringify({
261
+ name,
262
+ description,
263
+ hostSiteId: siteId,
264
+ scopes: localScopes.join(','),
265
+ integrationLevel,
266
+ }),
267
+ },
268
+ ).then(async (res) => {
269
+ const response = await res.json()
270
+ return { updateResponse: response, statusCode: res.status }
271
+ })
272
+
273
+ if (statusCode !== 200) {
274
+ log(
275
+ chalk.red(`There was an error updating the integration: ${updateResponse}`),
276
+ chalk.red('Please try again. If the problem persists, please contact support.'),
277
+ )
278
+ exit(1)
279
+ }
280
+ } else {
281
+ const useRegisteredScopesPrompt = await inquirer.prompt([
282
+ {
283
+ type: 'confirm',
284
+ name: 'useRegisteredScopes',
285
+ message: `Do you want to save the scopes registered for your integration in your local configuration file?`,
286
+ default: false,
287
+ },
288
+ ])
289
+
290
+ if (useRegisteredScopesPrompt.useRegisteredScopes) {
291
+ // Use the scopes that are already registered
292
+ log(chalk.white('Saving the currently registered scopes to the integration.yaml file.'))
293
+ scopesToWrite = formatScopesToWrite(registeredIntegrationScopes)
294
+ }
295
+
296
+ if (!useRegisteredScopesPrompt.useRegisteredScopes && options.prod) {
297
+ log(chalk.red('Unable to deploy your integration to production without updating the registered scopes.'))
298
+ exit(1)
299
+ }
300
+ }
301
+
302
+ const updatedIntegrationConfig = yaml.dump({
303
+ config: { name, description, slug: integrationSlug, scopes: scopesToWrite, integrationLevel },
304
+ })
305
+
306
+ const filePath = resolve(workingDir, 'integration.yaml')
307
+ await fs.writeFile(filePath, updatedIntegrationConfig)
308
+
309
+ log(chalk.yellow('Changes to the integration.yaml file are complete. Please commit and push these changes.'))
310
+ }
311
+ }
312
+
313
+ /**
314
+ * The deploy command for Netlify Integrations
315
+ * @param {import('commander').OptionValues} options
316
+ * * @param {import('../base-command.mjs').default} command
317
+ */
318
+ const deploy = async (options, command) => {
319
+ const { api, cachedConfig, site, siteInfo } = command.netlify
320
+ const { id: siteId } = site
321
+ const [token] = await getToken()
322
+ const workingDir = resolve(command.workingDir)
323
+ const buildOptions = await getBuildOptions({
324
+ cachedConfig,
325
+ packagePath: command.workspacePackage,
326
+ token,
327
+ options,
328
+ })
329
+
330
+ // Confirm that a site is linked and that the user is logged in
331
+ checkOptions(buildOptions)
332
+
333
+ const { description, integrationLevel, name, scopes, slug } = await getConfiguration()
334
+ const localIntegrationConfig = { name, description, scopes, slug, integrationLevel }
335
+
336
+ const { accountId } = await getSiteInformation({
337
+ api,
338
+ site,
339
+ siteInfo,
340
+ })
341
+
342
+ const { body: registeredIntegration, statusCode } = await fetch(
343
+ `${getIntegrationAPIUrl()}/${accountId}/integrations?site_id=${siteId}`,
344
+ {
345
+ headers: {
346
+ 'netlify-token': token,
347
+ },
348
+ },
349
+ ).then(async (res) => {
350
+ const body = await res.json()
351
+ return { body, statusCode: res.status }
352
+ })
353
+
354
+ // The integration is registered on the remote
355
+ statusCode === 200
356
+ ? await updateIntegration(
357
+ workingDir,
358
+ options,
359
+ siteId,
360
+ accountId,
361
+ localIntegrationConfig,
362
+ token,
363
+ registeredIntegration,
364
+ )
365
+ : await registerIntegration(workingDir, siteId, accountId, localIntegrationConfig, token)
366
+
367
+ // Set the prod flag to true if the integration is being initially registered because we don't want the user
368
+ // to be in a weird state where the card is appearing in the integrations list but there's no production
369
+ // version of the integration deployed
370
+ options = statusCode === 200 ? options : { ...options, prod: true }
371
+
372
+ // Deploy the integration to that site
373
+ await siteDeploy(options, command)
374
+
375
+ log(
376
+ `${chalk.cyanBright.bold(
377
+ `Your integration has been deployed. Next step is to enable it for a team or site.`,
378
+ )} https://ntl.fyi/create-private-integration`,
379
+ )
380
+ }
381
+
382
+ /**
383
+ * Creates the `netlify int deploy` command
384
+ * @param {import('../base-command.mjs').default} program
385
+ * @returns
386
+ */
387
+ export const createDeployCommand = (program) =>
388
+ program
389
+ .command('integration:deploy')
390
+ .alias('int:deploy')
391
+ .description('Register, build, and deploy a private integration on Netlify')
392
+ .option('-p, --prod', 'Deploy to production', false)
393
+ .option('-b, --build', 'Build the integration', false)
394
+ .option('-a, --auth <token>', 'Netlify auth token to deploy with', env.NETLIFY_AUTH_TOKEN)
395
+ .option('-s, --site <name-or-id>', 'A site name or ID to deploy to', env.NETLIFY_SITE_ID)
396
+ .action(deploy)
397
+ /* eslint-enable import/extensions */
@@ -0,0 +1,25 @@
1
+ import { createDeployCommand } from './deploy.mjs'
2
+
3
+ /**
4
+ * The int command
5
+ * @param {import('commander').OptionValues} options
6
+ * @param {import('../base-command.mjs').default} command
7
+ */
8
+ const integrations = (options, command) => {
9
+ command.help()
10
+ }
11
+
12
+ /**
13
+ * Creates the `netlify integration` command
14
+ * @param {import('../base-command.mjs').default} program
15
+ * @returns
16
+ */
17
+ export const createIntegrationCommand = (program) => {
18
+ createDeployCommand(program)
19
+
20
+ return program
21
+ .command('integration')
22
+ .alias('int')
23
+ .description('Manage Netlify Integrations built with the Netlify SDK')
24
+ .action(integrations)
25
+ }
@@ -22,6 +22,7 @@ import { createDevCommand } from './dev/index.mjs'
22
22
  import { createEnvCommand } from './env/index.mjs'
23
23
  import { createFunctionsCommand } from './functions/index.mjs'
24
24
  import { createInitCommand } from './init/index.mjs'
25
+ import { createIntegrationCommand } from './integration/index.mjs'
25
26
  import { createLinkCommand } from './link/index.mjs'
26
27
  import { createLmCommand } from './lm/index.mjs'
27
28
  import { createLoginCommand } from './login/index.mjs'
@@ -191,6 +192,7 @@ export const createMainCommand = () => {
191
192
  createFunctionsCommand(program)
192
193
  createRecipesCommand(program)
193
194
  createInitCommand(program)
195
+ createIntegrationCommand(program)
194
196
  createLinkCommand(program)
195
197
  createLmCommand(program)
196
198
  createLoginCommand(program)
@@ -1,5 +1,5 @@
1
1
  // @ts-check
2
- import { mkdir } from 'fs/promises'
2
+ import { mkdir, stat } from 'fs/promises'
3
3
  import { createRequire } from 'module'
4
4
  import { basename, extname, isAbsolute, join, resolve } from 'path'
5
5
  import { env } from 'process'
@@ -39,6 +39,7 @@ export class FunctionsRegistry {
39
39
  debug = false,
40
40
  isConnected = false,
41
41
  logLambdaCompat,
42
+ manifest,
42
43
  projectRoot,
43
44
  settings,
44
45
  timeouts,
@@ -96,6 +97,14 @@ export class FunctionsRegistry {
96
97
  * @type {boolean}
97
98
  */
98
99
  this.logLambdaCompat = Boolean(logLambdaCompat)
100
+
101
+ /**
102
+ * Contents of a `manifest.json` file that can be looked up when dealing
103
+ * with built functions.
104
+ *
105
+ * @type {object}
106
+ */
107
+ this.manifest = manifest
99
108
  }
100
109
 
101
110
  checkTypesPackage() {
@@ -390,12 +399,30 @@ export class FunctionsRegistry {
390
399
  FunctionsRegistry.logEvent('extracted', { func })
391
400
  }
392
401
 
393
- func.mainFile = join(unzippedDirectory, `${func.name}.js`)
402
+ // If there's a manifest file, look up the function in order to extract
403
+ // the build data.
404
+ const manifestEntry = (this.manifest?.functions || []).find((manifestFunc) => manifestFunc.name === func.name)
405
+
406
+ func.buildData = manifestEntry?.buildData || {}
407
+
408
+ // When we look at an unzipped function, we don't know whether it uses
409
+ // the legacy entry file format (i.e. `[function name].js`) or the new
410
+ // one (i.e. `___netlify-entry-point.mjs`). Let's look for the new one
411
+ // and use it if it exists, otherwise use the old one.
412
+ try {
413
+ const v2EntryPointPath = join(unzippedDirectory, '___netlify-entry-point.mjs')
414
+
415
+ await stat(v2EntryPointPath)
416
+
417
+ func.mainFile = v2EntryPointPath
418
+ } catch {
419
+ func.mainFile = join(unzippedDirectory, `${func.name}.js`)
420
+ }
421
+ } else {
422
+ this.buildFunctionAndWatchFiles(func, !isReload)
394
423
  }
395
424
 
396
425
  this.functions.set(name, func)
397
-
398
- this.buildFunctionAndWatchFiles(func, !isReload)
399
426
  }
400
427
 
401
428
  /**
@@ -1,6 +1,7 @@
1
1
  // @ts-check
2
2
  import { Buffer } from 'buffer'
3
3
  import { promises as fs } from 'fs'
4
+ import path from 'path'
4
5
 
5
6
  import express from 'express'
6
7
  import expressLogging from 'express-logging'
@@ -261,6 +262,7 @@ export const startFunctionsServer = async (options) => {
261
262
  options
262
263
  const internalFunctionsDir = await getInternalFunctionsDir({ base: site.root })
263
264
  const functionsDirectories = []
265
+ let manifest
264
266
 
265
267
  // If the `loadDistFunctions` parameter is sent, the functions server will
266
268
  // use the built functions created by zip-it-and-ship-it rather than building
@@ -270,6 +272,18 @@ export const startFunctionsServer = async (options) => {
270
272
 
271
273
  if (distPath) {
272
274
  functionsDirectories.push(distPath)
275
+
276
+ // When using built functions, read the manifest file so that we can
277
+ // extract metadata such as routes and API version.
278
+ try {
279
+ const manifestPath = path.join(distPath, 'manifest.json')
280
+ // eslint-disable-next-line unicorn/prefer-json-parse-buffer
281
+ const data = await fs.readFile(manifestPath, 'utf8')
282
+
283
+ manifest = JSON.parse(data)
284
+ } catch {
285
+ // no-op
286
+ }
273
287
  }
274
288
  } else {
275
289
  // The order of the function directories matters. Rightmost directories take
@@ -297,6 +311,7 @@ export const startFunctionsServer = async (options) => {
297
311
  debug,
298
312
  isConnected: Boolean(siteUrl),
299
313
  logLambdaCompat: isFeatureFlagEnabled('cli_log_lambda_compat', siteInfo),
314
+ manifest,
300
315
  // functions always need to be inside the packagePath if set inside a monorepo
301
316
  projectRoot: command.workingDir,
302
317
  settings,