netlify-cli 17.2.1 → 17.3.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": "17.2.1",
4
+ "version": "17.3.0",
5
5
  "author": "Netlify Inc.",
6
6
  "type": "module",
7
7
  "engines": {
@@ -45,12 +45,12 @@
45
45
  "@bugsnag/js": "7.20.2",
46
46
  "@fastify/static": "6.10.2",
47
47
  "@netlify/blobs": "^4.0.0",
48
- "@netlify/build": "29.25.0",
49
- "@netlify/build-info": "7.10.2",
48
+ "@netlify/build": "29.26.3",
49
+ "@netlify/build-info": "7.11.1",
50
50
  "@netlify/config": "20.9.0",
51
51
  "@netlify/edge-bundler": "^10.1.0",
52
52
  "@netlify/local-functions-proxy": "1.1.1",
53
- "@netlify/zip-it-and-ship-it": "9.25.7",
53
+ "@netlify/zip-it-and-ship-it": "9.26.1",
54
54
  "@octokit/rest": "19.0.13",
55
55
  "ansi-escapes": "6.2.0",
56
56
  "ansi-styles": "6.2.1",
@@ -144,6 +144,7 @@
144
144
  "through2-map": "3.0.0",
145
145
  "to-readable-stream": "3.0.0",
146
146
  "toml": "3.0.0",
147
+ "tomlify-j0.4": "^3.0.0",
147
148
  "ulid": "2.3.0",
148
149
  "unixify": "1.0.0",
149
150
  "update-notifier": "6.0.2",
@@ -51,6 +51,7 @@ const build = async (options, command) => {
51
51
  const buildOptions = await getBuildOptions({
52
52
  cachedConfig,
53
53
  packagePath: command.workspacePackage,
54
+ currentDir: command.workingDir,
54
55
  token,
55
56
  options,
56
57
  })
@@ -4,11 +4,12 @@ import { basename, resolve } from 'path'
4
4
  import { env } from 'process'
5
5
 
6
6
  import { runCoreSteps } from '@netlify/build'
7
- import { restoreConfig, updateConfig } from '@netlify/config'
8
7
  import { Option } from 'commander'
9
8
  import inquirer from 'inquirer'
10
9
  import isEmpty from 'lodash/isEmpty.js'
11
10
  import isObject from 'lodash/isObject.js'
11
+ import { parseAllHeaders } from 'netlify-headers-parser'
12
+ import { parseAllRedirects } from 'netlify-redirect-parser'
12
13
  import prettyjson from 'prettyjson'
13
14
 
14
15
  import { cancelDeploy } from '../../lib/api.mjs'
@@ -220,7 +221,10 @@ const getDeployFilesFilter = ({ deployFolder, site }) => {
220
221
  (skipNodeModules && base === 'node_modules') ||
221
222
  (base.startsWith('.') && base !== '.well-known') ||
222
223
  base.startsWith('__MACOSX') ||
223
- base.includes('/.')
224
+ base.includes('/.') ||
225
+ // headers and redirects are bundled in the config
226
+ base === '_redirects' ||
227
+ base === '_headers'
224
228
 
225
229
  return !skipFile
226
230
  }
@@ -322,7 +326,7 @@ const runDeploy = async ({
322
326
  alias,
323
327
  api,
324
328
  command,
325
- configPath,
329
+ config,
326
330
  deployFolder,
327
331
  deployTimeout,
328
332
  deployToProduction,
@@ -358,9 +362,29 @@ const runDeploy = async ({
358
362
  const functionDirectories = [internalFunctionsFolder, functionsFolder].filter(Boolean)
359
363
  const manifestPath = skipFunctionsCache ? null : await getFunctionsManifestPath({ base: site.root, packagePath })
360
364
 
365
+ const redirectsPath = `${deployFolder}/_redirects`
366
+ const headersPath = `${deployFolder}/_headers`
367
+
368
+ const { redirects } = await parseAllRedirects({
369
+ configRedirects: config.redirects,
370
+ redirectsFiles: [redirectsPath],
371
+ minimal: true,
372
+ })
373
+
374
+ config.redirects = redirects
375
+
376
+ const { headers } = await parseAllHeaders({
377
+ configHeaders: config.headers,
378
+ // @ts-ignore
379
+ headersFiles: [headersPath],
380
+ minimal: true,
381
+ })
382
+
383
+ config.headers = headers
384
+
361
385
  // @ts-ignore
362
386
  results = await deploySite(api, siteId, deployFolder, {
363
- configPath,
387
+ config,
364
388
  fnDir: functionDirectories,
365
389
  functionsConfig,
366
390
  statusCb: silent ? () => {} : deployProgressCb(),
@@ -407,10 +431,12 @@ const runDeploy = async ({
407
431
  * @param {object} config
408
432
  * @param {*} config.cachedConfig
409
433
  * @param {string} [config.packagePath]
434
+ * @param {*} config.deployHandler
435
+ * @param {string} config.currentDir
410
436
  * @param {import('commander').OptionValues} config.options The options of the command
411
437
  * @returns
412
438
  */
413
- const handleBuild = async ({ cachedConfig, options, packagePath }) => {
439
+ const handleBuild = async ({ cachedConfig, currentDir, deployHandler, options, packagePath }) => {
414
440
  if (!options.build) {
415
441
  return {}
416
442
  }
@@ -420,6 +446,8 @@ const handleBuild = async ({ cachedConfig, options, packagePath }) => {
420
446
  packagePath,
421
447
  token,
422
448
  options,
449
+ currentDir,
450
+ deployHandler,
423
451
  })
424
452
  const { configMutations, exitCode, newConfig } = await runBuild(resolvedOptions)
425
453
  if (exitCode !== 0) {
@@ -529,67 +557,18 @@ const printResults = ({ deployToProduction, isIntegrationDeploy, json, results,
529
557
  }
530
558
  }
531
559
 
532
- /**
533
- * The deploy command
534
- * @param {import('commander').OptionValues} options
535
- * @param {import('../base-command.mjs').default} command
536
- */
537
- export const deploy = async (options, command) => {
538
- const { workingDir } = command
539
- const { api, site, siteInfo } = command.netlify
560
+ const prepAndRunDeploy = async ({
561
+ api,
562
+ command,
563
+ config,
564
+ deployToProduction,
565
+ options,
566
+ site,
567
+ siteData,
568
+ siteId,
569
+ workingDir,
570
+ }) => {
540
571
  const alias = options.alias || options.branch
541
-
542
- command.setAnalyticsPayload({ open: options.open, prod: options.prod, json: options.json, alias: Boolean(alias) })
543
-
544
- if (options.branch) {
545
- warn('--branch flag has been renamed to --alias and will be removed in future versions')
546
- }
547
-
548
- if (options.context && !options.build) {
549
- return error('--context flag is only available when using the --build flag')
550
- }
551
-
552
- await command.authenticate(options.auth)
553
-
554
- let siteId = site.id || options.site
555
-
556
- let siteData = {}
557
- if (siteId && !isEmpty(siteInfo)) {
558
- siteData = siteInfo
559
- siteId = siteData.id
560
- } else {
561
- log("This folder isn't linked to a site yet")
562
- const NEW_SITE = '+ Create & configure a new site'
563
- const EXISTING_SITE = 'Link this directory to an existing site'
564
-
565
- const initializeOpts = [EXISTING_SITE, NEW_SITE]
566
-
567
- const { initChoice } = await inquirer.prompt([
568
- {
569
- type: 'list',
570
- name: 'initChoice',
571
- message: 'What would you like to do?',
572
- choices: initializeOpts,
573
- },
574
- ])
575
- // create site or search for one
576
- if (initChoice === NEW_SITE) {
577
- siteData = await sitesCreate({}, command)
578
- site.id = siteData.id
579
- siteId = site.id
580
- } else if (initChoice === EXISTING_SITE) {
581
- siteData = await link({}, command)
582
- site.id = siteData.id
583
- siteId = site.id
584
- }
585
- }
586
-
587
- const deployToProduction = options.prod || (options.prodIfUnlocked && !siteData.published_deploy.locked)
588
-
589
- if (options.trigger) {
590
- return triggerDeploy({ api, options, siteData, siteId })
591
- }
592
-
593
572
  const isUsingEnvelope = siteData && siteData.use_envelope
594
573
  // if a context is passed besides dev, we need to pull env vars from that specific context
595
574
  if (isUsingEnvelope && options.context && options.context !== 'dev') {
@@ -601,16 +580,10 @@ export const deploy = async (options, command) => {
601
580
  })
602
581
  }
603
582
 
604
- const { configMutations = [], newConfig } = await handleBuild({
605
- packagePath: command.workspacePackage,
606
- cachedConfig: command.netlify.cachedConfig,
607
- options,
608
- })
609
- const config = newConfig || command.netlify.config
610
-
611
583
  const deployFolder = await getDeployFolder({ command, options, config, site, siteData })
612
584
  const functionsFolder = getFunctionsFolder({ workingDir, options, config, site, siteData })
613
585
  const { configPath } = site
586
+
614
587
  const edgeFunctionsConfig = command.netlify.config.edge_functions
615
588
 
616
589
  // build flag wasn't used and edge functions exist
@@ -648,20 +621,11 @@ export const deploy = async (options, command) => {
648
621
  siteEnv,
649
622
  })
650
623
 
651
- const redirectsPath = `${deployFolder}/_redirects`
652
- // @ts-ignore
653
- await updateConfig(configMutations, {
654
- buildDir: deployFolder,
655
- configPath,
656
- redirectsPath,
657
- context: command.netlify.cachedConfig.context,
658
- branch: command.netlify.cachedConfig.branch,
659
- })
660
624
  const results = await runDeploy({
661
625
  alias,
662
626
  api,
663
627
  command,
664
- configPath,
628
+ config,
665
629
  deployFolder,
666
630
  deployTimeout: options.timeout * SEC_TO_MILLISEC || DEFAULT_DEPLOY_TIMEOUT,
667
631
  deployToProduction,
@@ -677,9 +641,107 @@ export const deploy = async (options, command) => {
677
641
  title: options.message,
678
642
  })
679
643
 
680
- // @ts-ignore
681
- await restoreConfig(configMutations, { buildDir: deployFolder, configPath, redirectsPath })
644
+ return results
645
+ }
646
+
647
+ /**
648
+ * The deploy command
649
+ * @param {import('commander').OptionValues} options
650
+ * @param {import('../base-command.mjs').default} command
651
+ */
652
+ export const deploy = async (options, command) => {
653
+ const { workingDir } = command
654
+ const { api, site, siteInfo } = command.netlify
655
+ const alias = options.alias || options.branch
656
+
657
+ command.setAnalyticsPayload({ open: options.open, prod: options.prod, json: options.json, alias: Boolean(alias) })
658
+
659
+ if (options.branch) {
660
+ warn('--branch flag has been renamed to --alias and will be removed in future versions')
661
+ }
662
+
663
+ if (options.context && !options.build) {
664
+ return error('--context flag is only available when using the --build flag')
665
+ }
682
666
 
667
+ await command.authenticate(options.auth)
668
+
669
+ let siteId = site.id || options.site
670
+
671
+ let siteData = {}
672
+ if (siteId && !isEmpty(siteInfo)) {
673
+ siteData = siteInfo
674
+ siteId = siteData.id
675
+ } else {
676
+ log("This folder isn't linked to a site yet")
677
+ const NEW_SITE = '+ Create & configure a new site'
678
+ const EXISTING_SITE = 'Link this directory to an existing site'
679
+
680
+ const initializeOpts = [EXISTING_SITE, NEW_SITE]
681
+
682
+ const { initChoice } = await inquirer.prompt([
683
+ {
684
+ type: 'list',
685
+ name: 'initChoice',
686
+ message: 'What would you like to do?',
687
+ choices: initializeOpts,
688
+ },
689
+ ])
690
+ // create site or search for one
691
+ if (initChoice === NEW_SITE) {
692
+ siteData = await sitesCreate({}, command)
693
+ site.id = siteData.id
694
+ siteId = site.id
695
+ } else if (initChoice === EXISTING_SITE) {
696
+ siteData = await link({}, command)
697
+ site.id = siteData.id
698
+ siteId = site.id
699
+ }
700
+ }
701
+
702
+ if (options.trigger) {
703
+ return triggerDeploy({ api, options, siteData, siteId })
704
+ }
705
+
706
+ const deployToProduction = options.prod || (options.prodIfUnlocked && !siteData.published_deploy.locked)
707
+
708
+ let results = {}
709
+
710
+ if (options.build) {
711
+ await handleBuild({
712
+ packagePath: command.workspacePackage,
713
+ cachedConfig: command.netlify.cachedConfig,
714
+ currentDir: command.workingDir,
715
+ options,
716
+ deployHandler: async ({ netlifyConfig }) => {
717
+ results = await prepAndRunDeploy({
718
+ command,
719
+ options,
720
+ workingDir,
721
+ api,
722
+ site,
723
+ config: netlifyConfig,
724
+ siteData,
725
+ siteId,
726
+ deployToProduction,
727
+ })
728
+
729
+ return {}
730
+ },
731
+ })
732
+ } else {
733
+ results = await prepAndRunDeploy({
734
+ command,
735
+ options,
736
+ workingDir,
737
+ api,
738
+ site,
739
+ config: command.netlify.config,
740
+ siteData,
741
+ siteId,
742
+ deployToProduction,
743
+ })
744
+ }
683
745
  const isIntegrationDeploy = command.name() === 'integration:deploy'
684
746
 
685
747
  printResults({
@@ -374,6 +374,7 @@ const deploy = async (options, command) => {
374
374
  const buildOptions = await getBuildOptions({
375
375
  cachedConfig,
376
376
  packagePath: command.workspacePackage,
377
+ currentDir: command.workingDir,
377
378
  token,
378
379
  options,
379
380
  })
package/src/lib/build.mjs CHANGED
@@ -1,7 +1,9 @@
1
1
  // @ts-check
2
+ import fs from 'fs'
2
3
  import process from 'process'
3
4
 
4
5
  import build from '@netlify/build'
6
+ import tomlify from 'tomlify-j0.4'
5
7
 
6
8
  import { isFeatureFlagEnabled } from '../utils/feature-flags.mjs'
7
9
 
@@ -22,36 +24,66 @@ import { featureFlags as edgeFunctionsFeatureFlags } from './edge-functions/cons
22
24
  * @param {object} config
23
25
  * @param {*} config.cachedConfig
24
26
  * @param {string} [config.packagePath]
27
+ * @param {string} config.currentDir
25
28
  * @param {string} config.token
26
29
  * @param {import('commander').OptionValues} config.options
30
+ * @param {*} config.deployHandler
27
31
  * @returns {BuildConfig}
28
32
  */
29
33
  export const getBuildOptions = ({
30
34
  cachedConfig,
35
+ currentDir,
36
+ deployHandler,
31
37
  options: { context, cwd, debug, dry, json, offline, silent },
32
38
  packagePath,
33
39
  token,
34
- }) => ({
35
- cachedConfig,
36
- siteId: cachedConfig.siteInfo.id,
37
- packagePath,
38
- token,
39
- dry,
40
- debug,
41
- context,
42
- mode: 'cli',
43
- telemetry: false,
44
- // buffer = true will not stream output
45
- buffer: json || silent,
46
- offline,
47
- cwd,
48
- featureFlags: {
49
- ...edgeFunctionsFeatureFlags,
50
- ...getFeatureFlagsFromSiteInfo(cachedConfig.siteInfo),
51
- functionsBundlingManifest: true,
52
- },
53
- edgeFunctionsBootstrapURL: getBootstrapURL(),
54
- })
40
+ }) => {
41
+ const eventHandlers = {
42
+ onEnd: {
43
+ handler: ({ netlifyConfig }) => {
44
+ const string = tomlify.toToml(netlifyConfig)
45
+
46
+ if (!fs.existsSync(`${currentDir}/.netlify`)) {
47
+ fs.mkdirSync(`${currentDir}/.netlify`, { recursive: true })
48
+ }
49
+ fs.writeFileSync(`${currentDir}/.netlify/netlify.toml`, string)
50
+
51
+ return {}
52
+ },
53
+ description: 'Save updated config',
54
+ },
55
+ }
56
+
57
+ if (deployHandler) {
58
+ eventHandlers.onPostBuild = {
59
+ handler: deployHandler,
60
+ description: 'Deploy Site',
61
+ }
62
+ }
63
+
64
+ return {
65
+ cachedConfig,
66
+ siteId: cachedConfig.siteInfo.id,
67
+ packagePath,
68
+ token,
69
+ dry,
70
+ debug,
71
+ context,
72
+ mode: 'cli',
73
+ telemetry: false,
74
+ // buffer = true will not stream output
75
+ buffer: json || silent,
76
+ offline,
77
+ cwd,
78
+ featureFlags: {
79
+ ...edgeFunctionsFeatureFlags,
80
+ ...getFeatureFlagsFromSiteInfo(cachedConfig.siteInfo),
81
+ functionsBundlingManifest: true,
82
+ },
83
+ eventHandlers,
84
+ edgeFunctionsBootstrapURL: getBootstrapURL(),
85
+ }
86
+ }
55
87
 
56
88
  /**
57
89
  * @param {*} siteInfo
@@ -184,7 +184,7 @@ export default class NetlifyFunction {
184
184
  }
185
185
 
186
186
  // Invokes the function and returns its response object.
187
- async invoke(event, context = {}) {
187
+ async invoke(event = {}, context = {}) {
188
188
  await this.buildQueue
189
189
 
190
190
  if (this.buildError) {
@@ -200,10 +200,7 @@ export default class NetlifyFunction {
200
200
  token: this.blobsContext.token,
201
201
  })
202
202
 
203
- context.custom = {
204
- ...context?.custom,
205
- blobs: Buffer.from(payload).toString('base64'),
206
- }
203
+ event.blobs = Buffer.from(payload).toString('base64')
207
204
  }
208
205
 
209
206
  try {
@@ -233,10 +233,6 @@ const getFunctionsServer = (options) => {
233
233
  }),
234
234
  )
235
235
 
236
- app.get('/favicon.ico', function onRequest(_req, res) {
237
- res.status(204).end()
238
- })
239
-
240
236
  app.all(`${functionsPrefix}*`, functionHandler)
241
237
  app.all(`${buildersPrefix}*`, functionHandler)
242
238
 
@@ -13,6 +13,7 @@ import {
13
13
  DEFAULT_MAX_RETRY,
14
14
  DEFAULT_SYNC_LIMIT,
15
15
  } from './constants.mjs'
16
+ import { hashConfig } from './hash-config.mjs'
16
17
  import hashFiles from './hash-files.mjs'
17
18
  import hashFns from './hash-fns.mjs'
18
19
  import uploadFiles from './upload-files.mjs'
@@ -27,7 +28,7 @@ export const deploySite = async (
27
28
  branch,
28
29
  concurrentHash = DEFAULT_CONCURRENT_HASH,
29
30
  concurrentUpload = DEFAULT_CONCURRENT_UPLOAD,
30
- configPath = null,
31
+ config,
31
32
  deployId,
32
33
  deployTimeout = DEFAULT_DEPLOY_TIMEOUT,
33
34
  draft = false,
@@ -55,31 +56,39 @@ export const deploySite = async (
55
56
  })
56
57
 
57
58
  const edgeFunctionsDistPath = await getDistPathIfExists(workingDir)
58
- const [{ files, filesShaMap }, { fnConfig, fnShaMap, functionSchedules, functions, functionsWithNativeModules }] =
59
- await Promise.all([
60
- hashFiles({
61
- assetType,
62
- concurrentHash,
63
- directories: [configPath, dir, edgeFunctionsDistPath].filter(Boolean),
64
- filter,
65
- hashAlgorithm,
66
- normalizer: deployFileNormalizer.bind(null, workingDir),
67
- statusCb,
68
- }),
69
- hashFns(fnDir, {
70
- functionsConfig,
71
- tmpDir,
72
- concurrentHash,
73
- hashAlgorithm,
74
- statusCb,
75
- assetType,
76
- workingDir,
77
- manifestPath,
78
- skipFunctionsCache,
79
- siteEnv,
80
- rootDir: siteRoot,
81
- }),
82
- ])
59
+ const [
60
+ { files: staticFiles, filesShaMap: staticShaMap },
61
+ { fnConfig, fnShaMap, functionSchedules, functions, functionsWithNativeModules },
62
+ configFile,
63
+ ] = await Promise.all([
64
+ hashFiles({
65
+ assetType,
66
+ concurrentHash,
67
+ directories: [dir, edgeFunctionsDistPath].filter(Boolean),
68
+ filter,
69
+ hashAlgorithm,
70
+ normalizer: deployFileNormalizer.bind(null, workingDir),
71
+ statusCb,
72
+ }),
73
+ hashFns(fnDir, {
74
+ functionsConfig,
75
+ tmpDir,
76
+ concurrentHash,
77
+ hashAlgorithm,
78
+ statusCb,
79
+ assetType,
80
+ workingDir,
81
+ manifestPath,
82
+ skipFunctionsCache,
83
+ siteEnv,
84
+ rootDir: siteRoot,
85
+ }),
86
+ hashConfig({ config }),
87
+ ])
88
+
89
+ const files = { ...staticFiles, [configFile.normalizedPath]: configFile.hash }
90
+ const filesShaMap = { ...staticShaMap, [configFile.hash]: [configFile] }
91
+
83
92
  const edgeFunctionsCount = Object.keys(files).filter(isEdgeFunctionFile).length
84
93
  const filesCount = Object.keys(files).length - edgeFunctionsCount
85
94
  const functionsCount = Object.keys(functions).length
@@ -0,0 +1,26 @@
1
+ import hasha from 'hasha'
2
+ import tomlify from 'tomlify-j0.4'
3
+
4
+ export const hashConfig = ({ config }) => {
5
+ if (!config) throw new Error('Missing config option')
6
+ const configString = serializeToml(config)
7
+
8
+ const hash = hasha(configString, { algorithm: 'sha1' })
9
+
10
+ return {
11
+ assetType: 'file',
12
+ body: configString,
13
+ hash,
14
+ normalizedPath: 'netlify.toml',
15
+ }
16
+ }
17
+
18
+ export const serializeToml = function (object) {
19
+ return tomlify.toToml(object, { space: 2, replace: replaceTomlValue })
20
+ }
21
+
22
+ // `tomlify-j0.4` serializes integers as floats, e.g. `200.0`.
23
+ // This is a problem with `redirects[*].status`.
24
+ const replaceTomlValue = function (key, value) {
25
+ return Number.isInteger(value) ? String(value) : false
26
+ }
@@ -14,8 +14,9 @@ const uploadFiles = async (api, deployId, uploadList, { concurrentUpload, maxRet
14
14
  })
15
15
 
16
16
  const uploadFile = async (fileObj, index) => {
17
- const { assetType, filepath, invocationMode, normalizedPath, runtime } = fileObj
18
- const readStreamCtor = () => fs.createReadStream(filepath)
17
+ const { assetType, body, filepath, invocationMode, normalizedPath, runtime } = fileObj
18
+
19
+ const readStreamCtor = () => body ?? fs.createReadStream(filepath)
19
20
 
20
21
  statusCb({
21
22
  type: 'upload',