From be9f33852059555d310495bd6060e99cf1d38ee3 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Wed, 22 Apr 2026 16:54:42 +0100 Subject: [PATCH 1/8] Replace various old packages --- lib/aws/request.js | 7 +- .../aws/deploy/lib/check-for-changes.js | 2 +- lib/plugins/aws/invoke-local/index.js | 4 +- lib/plugins/package/lib/package-service.js | 2 +- lib/plugins/package/lib/zip-service.js | 2 +- lib/utils/glob.js | 151 +++++++++ lib/utils/serverless-utils/config.js | 4 +- lib/utils/serverless-utils/download.js | 126 ++++--- lib/utils/serverless-utils/inquirer/index.js | 78 +++-- lib/utils/yaml-ast-parser.js | 315 +++++++++--------- package.json | 17 +- scripts/serverless.js | 4 +- .../aws/custom-deployment-bucket.test.js | 4 +- test/unit/commands/plugin-uninstall.test.js | 47 +++ test/unit/lib/aws/request.test.js | 62 ++++ .../aws/custom-resources/generate-zip.test.js | 2 +- .../aws/deploy/lib/check-for-changes.test.js | 2 +- test/unit/lib/plugins/create/create.test.js | 36 +- .../plugins/package/lib/zip-service.test.js | 2 +- .../lib/utils/serverless-utils/config.test.js | 33 ++ .../utils/serverless-utils/inquirer.test.js | 57 ++-- test/unit/lib/utils/yaml-ast-parser.test.js | 26 ++ 22 files changed, 671 insertions(+), 312 deletions(-) create mode 100644 lib/utils/glob.js diff --git a/lib/aws/request.js b/lib/aws/request.js index 3dd289d951..e38b47e600 100644 --- a/lib/aws/request.js +++ b/lib/aws/request.js @@ -2,7 +2,7 @@ const _ = require('lodash'); const memoize = require('memoizee'); -const PromiseQueue = require('promise-queue'); +const promiseLimit = require('ext/promise/limit').bind(Promise); const sdk = require('./sdk-v2'); const ServerlessError = require('../../lib/serverless-error'); const { log } = require('../utils/serverless-utils/log'); @@ -74,8 +74,7 @@ const timeout = process.env.AWS_CLIENT_TIMEOUT || process.env.aws_client_timeout if (timeout) { sdk.config.httpOptions.timeout = parseInt(timeout, 10); } -PromiseQueue.configure(Promise); -const requestQueue = new PromiseQueue(2, Infinity); +const requestQueue = promiseLimit(2, async (task) => task()); const MAX_RETRIES = (() => { const userValue = Number(process.env.SLS_AWS_REQUEST_MAX_RETRIES); @@ -172,7 +171,7 @@ async function awsRequest(service, method, ...args) { throw e; } }; - const request = await requestQueue.add(() => + const request = await requestQueue(() => persistentRequest(async () => { const requestId = ++requestCounter; const awsService = getServiceInstance(service, method); diff --git a/lib/plugins/aws/deploy/lib/check-for-changes.js b/lib/plugins/aws/deploy/lib/check-for-changes.js index c55323ea9b..0d89201792 100644 --- a/lib/plugins/aws/deploy/lib/check-for-changes.js +++ b/lib/plugins/aws/deploy/lib/check-for-changes.js @@ -3,7 +3,7 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); -const globby = require('globby'); +const globby = require('../../../../utils/glob'); const BbPromise = require('bluebird'); const _ = require('lodash'); const normalizeFiles = require('../../lib/normalize-files'); diff --git a/lib/plugins/aws/invoke-local/index.js b/lib/plugins/aws/invoke-local/index.js index da389fbf7f..7deb235b79 100644 --- a/lib/plugins/aws/invoke-local/index.js +++ b/lib/plugins/aws/invoke-local/index.js @@ -16,7 +16,7 @@ const download = require('../../../utils/serverless-utils/download'); const { ensureDir } = require('fs-extra'); const cachedir = require('cachedir'); const decompress = require('decompress'); -const { v4: uuidv4 } = require('uuid'); +const { randomUUID } = require('node:crypto'); const ServerlessError = require('../../../serverless-error'); const dirExists = require('../../../utils/fs/dir-exists'); const fileExists = require('../../../utils/fs/file-exists'); @@ -923,7 +923,7 @@ class AwsInvokeLocal { Number(this.serverless.service.provider.timeout) || 6; let context = { - awsRequestId: uuidv4(), + awsRequestId: randomUUID(), invokeid: 'id', logGroupName: this.provider.naming.getLogGroupName(this.options.functionObj.name), logStreamName: '2015/09/22/[HEAD]13370a84ca4ed8b77c427af260', diff --git a/lib/plugins/package/lib/package-service.js b/lib/plugins/package/lib/package-service.js index 97ed0d3334..f330c6e9b2 100644 --- a/lib/plugins/package/lib/package-service.js +++ b/lib/plugins/package/lib/package-service.js @@ -2,7 +2,7 @@ const path = require('path'); const fsp = require('fs').promises; -const globby = require('globby'); +const globby = require('../../../utils/glob'); const _ = require('lodash'); const micromatch = require('micromatch'); const ServerlessError = require('../../../serverless-error'); diff --git a/lib/plugins/package/lib/zip-service.js b/lib/plugins/package/lib/zip-service.js index debc8ed116..092039a7a0 100644 --- a/lib/plugins/package/lib/zip-service.js +++ b/lib/plugins/package/lib/zip-service.js @@ -7,7 +7,7 @@ const path = require('path'); const crypto = require('crypto'); const fs = BbPromise.promisifyAll(require('fs')); const childProcess = BbPromise.promisifyAll(require('child_process')); -const globby = require('globby'); +const globby = require('../../../utils/glob'); const _ = require('lodash'); const ServerlessError = require('../../../serverless-error'); const { log } = require('../../../utils/serverless-utils/log'); diff --git a/lib/utils/glob.js b/lib/utils/glob.js new file mode 100644 index 0000000000..59a3112052 --- /dev/null +++ b/lib/utils/glob.js @@ -0,0 +1,151 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const fastGlob = require('fast-glob'); + +const toArray = (patterns) => { + return Array.isArray(patterns) ? patterns : [patterns]; +}; + +const isDynamicPattern = (pattern) => /[*?{}()[\]]/.test(pattern); + +const maybeExpandDirectoryPattern = (pattern, cwd) => { + if (isDynamicPattern(pattern)) { + return pattern; + } + + const absolutePattern = path.resolve(cwd || process.cwd(), pattern); + try { + if (fs.statSync(absolutePattern).isDirectory()) { + const normalizedPattern = pattern.replace(/\\/g, '/').replace(/\/$/, ''); + return `${normalizedPattern}/**/*`; + } + } catch { + // Ignore missing paths and let fast-glob handle them. + } + + return pattern; +}; + +const expandPattern = (pattern, cwd, expandDirectories) => { + if (!expandDirectories) { + return pattern; + } + + if (pattern.startsWith('!')) { + return `!${maybeExpandDirectoryPattern(pattern.slice(1), cwd)}`; + } + + return maybeExpandDirectoryPattern(pattern, cwd); +}; + +const normalizeOptions = (options = {}) => { + const globOptions = { ...options }; + const expandDirectories = + globOptions.expandDirectories !== undefined ? globOptions.expandDirectories : true; + + delete globOptions.expandDirectories; + delete globOptions.nosort; + + if (globOptions.follow !== undefined) { + globOptions.followSymbolicLinks = globOptions.follow; + delete globOptions.follow; + } + + if (globOptions.nodir !== undefined) { + globOptions.onlyFiles = globOptions.nodir; + delete globOptions.nodir; + } + + if (globOptions.silent !== undefined) { + globOptions.suppressErrors = globOptions.silent; + delete globOptions.silent; + } + + return { expandDirectories, globOptions }; +}; + +const buildTasks = (patterns, globOptions) => { + const tasks = []; + + for (let index = 0; index < patterns.length; index += 1) { + const pattern = patterns[index]; + if (pattern.startsWith('!')) { + continue; + } + + const ignore = patterns + .slice(index) + .filter((value) => value.startsWith('!')) + .map((value) => value.slice(1)); + + tasks.push({ + pattern, + options: { + ...globOptions, + ignore: [...(globOptions.ignore || []), ...ignore], + }, + }); + } + + return tasks; +}; + +const collectResults = async (tasks) => { + const seen = new Set(); + const results = []; + + for (const task of tasks) { + for (const entry of await fastGlob(task.pattern, task.options)) { + if (seen.has(entry)) { + continue; + } + + seen.add(entry); + results.push(entry); + } + } + + return results; +}; + +const collectResultsSync = (tasks) => { + const seen = new Set(); + const results = []; + + for (const task of tasks) { + for (const entry of fastGlob.sync(task.pattern, task.options)) { + if (seen.has(entry)) { + continue; + } + + seen.add(entry); + results.push(entry); + } + } + + return results; +}; + +const glob = async (patterns, options = {}) => { + const normalizedPatterns = toArray(patterns); + const { expandDirectories, globOptions } = normalizeOptions(options); + const expandedPatterns = normalizedPatterns.map((pattern) => + expandPattern(pattern, globOptions.cwd, expandDirectories) + ); + + return collectResults(buildTasks(expandedPatterns, globOptions)); +}; + +glob.sync = (patterns, options = {}) => { + const normalizedPatterns = toArray(patterns); + const { expandDirectories, globOptions } = normalizeOptions(options); + const expandedPatterns = normalizedPatterns.map((pattern) => + expandPattern(pattern, globOptions.cwd, expandDirectories) + ); + + return collectResultsSync(buildTasks(expandedPatterns, globOptions)); +}; + +module.exports = glob; diff --git a/lib/utils/serverless-utils/config.js b/lib/utils/serverless-utils/config.js index 3136022afc..929e0edf70 100644 --- a/lib/utils/serverless-utils/config.js +++ b/lib/utils/serverless-utils/config.js @@ -3,10 +3,10 @@ const p = require('path'); const os = require('os'); const fs = require('fs'); +const { randomUUID } = require('node:crypto'); const _ = require('lodash'); const writeFileAtomic = require('write-file-atomic'); -const uuid = require('uuid'); const { log } = require('./log'); const logDebug = log.get('config').debug; @@ -60,7 +60,7 @@ function storeConfig(config, configPath) { function createDefaultGlobalConfig() { const defaultConfig = { - frameworkId: uuid.v1(), + frameworkId: randomUUID(), meta: { created_at: Math.round(Date.now() / 1000), // config file creation date updated_at: null, // config file updated date diff --git a/lib/utils/serverless-utils/download.js b/lib/utils/serverless-utils/download.js index 33c4be1851..19dc7bedac 100644 --- a/lib/utils/serverless-utils/download.js +++ b/lib/utils/serverless-utils/download.js @@ -17,21 +17,17 @@ const fsp = require('fs').promises; const path = require('path'); const { URL } = require('url'); +const { Agent } = require('undici'); const contentDisposition = require('content-disposition'); const archiveType = require('archive-type'); const decompress = require('decompress'); const filenamify = require('filenamify'); -const getStream = require('get-stream'); -const got = require('got'); -const makeDir = require('make-dir'); -const pEvent = require('p-event'); -const FileType = require('file-type'); const extName = require('ext-name'); -const filenameFromPath = (res) => path.basename(new URL(res.requestUrl).pathname); +const filenameFromPath = (requestUrl) => path.basename(new URL(requestUrl).pathname); -const getExtFromMime = (res) => { - const header = res.headers['content-type']; +const getExtFromMime = (headers) => { + const header = headers['content-type']; if (!header) { return null; @@ -46,8 +42,12 @@ const getExtFromMime = (res) => { return exts[0].ext; }; -const getFilename = async (res, data) => { - const header = res.headers['content-disposition']; +const getFilename = ({ requestUrl, headers, data, explicitFilename }) => { + if (explicitFilename) { + return explicitFilename; + } + + const header = headers['content-disposition']; if (header) { const parsed = contentDisposition.parse(header); @@ -57,10 +57,11 @@ const getFilename = async (res, data) => { } } - let filename = filenameFromPath(res); + let filename = filenameFromPath(requestUrl); if (!path.extname(filename)) { - const ext = ((await FileType.fromBuffer(data)) || {}).ext || getExtFromMime(res); + const archive = archiveType(data); + const ext = (archive && archive.ext) || getExtFromMime(headers); if (ext) { filename = `${filename}.${ext}`; @@ -71,49 +72,70 @@ const getFilename = async (res, data) => { }; module.exports = (uri, output, opts) => { - if (typeof output === 'object') { - opts = output; - output = null; - } + return (async () => { + if (typeof output === 'object') { + opts = output; + output = null; + } - opts = Object.assign( - { - https: { - rejectUnauthorized: process.env.npm_config_strict_ssl !== 'false', + opts = Object.assign( + { + https: { + rejectUnauthorized: process.env.npm_config_strict_ssl !== 'false', + }, + responseType: 'buffer', }, - responseType: 'buffer', - }, - opts - ); - - const stream = got.stream(uri, opts); - - const promise = pEvent(stream, 'response') - .then((res) => { - const encoding = opts.responseType === 'buffer' ? 'buffer' : opts.encoding; - return Promise.all([getStream(stream, { encoding }), res]); - }) - .then(async (result) => { - const [data, res] = result; - - if (!output) { - return opts.extract && archiveType(data) ? decompress(data, opts) : data; - } - - const filename = opts.filename || filenamify(await getFilename(res, data)); - const outputFilepath = path.join(output, filename); - - if (opts.extract && archiveType(data)) { - return decompress(data, path.dirname(outputFilepath), opts); - } - - return makeDir(path.dirname(outputFilepath)) - .then(() => fsp.writeFile(outputFilepath, data)) - .then(() => data); + opts + ); + + const headers = { ...(opts.headers || {}) }; + if (opts.username || opts.password) { + headers.authorization = `Basic ${Buffer.from( + `${opts.username || ''}:${opts.password || ''}` + ).toString('base64')}`; + } + + const dispatcher = + opts.https && opts.https.rejectUnauthorized === false + ? new Agent({ connect: { rejectUnauthorized: false } }) + : undefined; + + const response = await fetch(uri, { + headers, + dispatcher, + signal: typeof opts.timeout === 'number' ? AbortSignal.timeout(opts.timeout) : undefined, }); - stream.then = promise.then.bind(promise); - stream.catch = promise.catch.bind(promise); + if (!response.ok) { + throw new Error(`Unexpected download response: ${response.status}`); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + const data = opts.responseType === 'buffer' ? buffer : buffer.toString(opts.encoding || 'utf8'); + const archive = archiveType(buffer); + + if (!output) { + return opts.extract && archive ? decompress(buffer, opts) : data; + } + + if (opts.extract && archive) { + return decompress(buffer, output, opts); + } - return stream; + const responseHeaders = Object.fromEntries(response.headers); + const filename = filenamify( + getFilename({ + requestUrl: response.url, + headers: responseHeaders, + data: buffer, + explicitFilename: opts.filename, + }) + ); + const outputFilepath = path.join(output, filename); + + await fsp.mkdir(path.dirname(outputFilepath), { recursive: true }); + await fsp.writeFile(outputFilepath, data); + + return data; + })(); }; diff --git a/lib/utils/serverless-utils/inquirer/index.js b/lib/utils/serverless-utils/inquirer/index.js index 25f6929638..5a62574051 100644 --- a/lib/utils/serverless-utils/inquirer/index.js +++ b/lib/utils/serverless-utils/inquirer/index.js @@ -1,37 +1,51 @@ -// Customize inquirer style - 'use strict'; -const { createRequire } = require('module'); -const identity = require('ext/function/identity'); -const requireUncached = require('ncjsm/require-uncached'); -const chalk = require('chalk'); -const { style } = require('../log'); - -const inquirersChalkPath = createRequire(require.resolve('inquirer')).resolve('chalk'); - -module.exports = requireUncached(inquirersChalkPath, () => { - // Ensure distinct chalk instance for inquirer and hack it with altered styles - Object.defineProperties(require(inquirersChalkPath), { - cyan: { - get() { - return chalk.bold; - }, - }, - bold: { - get() { - return identity; - }, - }, +const readline = require('node:readline/promises'); + +const parseConfirm = (value, defaultValue = true) => { + const answer = String(value || '') + .trim() + .toLowerCase(); + + if (!answer) { + return defaultValue; + } + + return answer === 'y' || answer === 'yes'; +}; + +const promptOne = async ({ message, type, name, default: defaultValue = true }) => { + if (type !== 'confirm') { + throw new Error(`Unsupported prompt type: ${type}`); + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, }); - const BasePrompt = require('inquirer/lib/prompts/base'); - const originalGetQuestion = BasePrompt.prototype.getQuestion; - BasePrompt.prototype.getQuestion = function () { - // Here we want to override the default prefix which is equal to `chalk.green('?')` - this.opt.prefix = style.strong('?'); - return originalGetQuestion.call(this); - }; + try { + const answer = await rl.question(`? ${message} (${defaultValue ? 'Y/n' : 'y/N'}) `); + return { [name]: parseConfirm(answer, defaultValue) }; + } finally { + rl.close(); + } +}; + +const prompt = async (questionConfig) => { + const questions = Array.isArray(questionConfig) ? questionConfig : [questionConfig]; + const answers = {}; + + for (const question of questions) { + Object.assign(answers, await promptOne(question)); + } + + return answers; +}; - return require('inquirer'); -}); +module.exports = { + prompt, + createPromptModule() { + return prompt; + }, +}; diff --git a/lib/utils/yaml-ast-parser.js b/lib/utils/yaml-ast-parser.js index 9583f4eb1d..e460ddc735 100644 --- a/lib/utils/yaml-ast-parser.js +++ b/lib/utils/yaml-ast-parser.js @@ -1,171 +1,188 @@ 'use strict'; -const yaml = require('yaml-ast-parser'); -const BbPromise = require('bluebird'); -const fs = BbPromise.promisifyAll(require('fs')); -const _ = require('lodash'); +const fsp = require('fs').promises; const os = require('os'); -const { log } = require('./serverless-utils/log'); - -const findKeyChain = (astContent) => { - let content = astContent; - const chain = [content.key.value]; - while (content.parent) { - content = content.parent; - if (content.key) { - chain.push(content.key.value); - } +const _ = require('lodash'); +const yaml = require('js-yaml'); +const cloudformationSchema = require('./serverless-utils/cloudformation-schema'); + +const topLevelKeyLineRegex = /^[^\s#][^:]*:(?:\s|$)/; +const documentMarkerRegex = /^(---|\.\.\.)(?:\s|$)/; + +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const stripLineEnding = (line) => line.replace(/[\r\n]+$/, ''); + +const splitIntoLines = (source) => source.match(/[^\n]*\n?|[^\n]+$/g) || ['']; + +const parseYaml = (source, filePath) => { + return ( + yaml.load(source, { + filename: filePath, + schema: cloudformationSchema, + }) || {} + ); +}; + +const serializeBranch = (headKey, branchValue) => { + return yaml.dump({ [headKey]: branchValue }, { lineWidth: -1, noRefs: true }); +}; + +const isTopLevelKeyLine = (line, key) => { + const normalizedLine = stripLineEnding(line); + + if (key) { + return new RegExp(`^${escapeRegExp(key)}:(?:\\s|$)`).test(normalizedLine); } - return chain.reverse().join('.'); + + return topLevelKeyLineRegex.test(normalizedLine) || documentMarkerRegex.test(normalizedLine); }; -const parseAST = (ymlAstContent, astObject) => { - let newAstObject = astObject || {}; - if (ymlAstContent.mappings && Array.isArray(ymlAstContent.mappings)) { - ymlAstContent.mappings.forEach((v) => { - if (!v.value) { - log.error(`Your serverless.yml has an invalid value with key: "${v.key.value}"`); - return; - } - - if (v.key.kind === 0 && v.value.kind === 0) { - newAstObject[findKeyChain(v)] = v.value; - } else if (v.key.kind === 0 && (v.value.kind === 2 || v.value.kind === 3)) { - newAstObject[findKeyChain(v)] = v.value; - newAstObject = parseAST(v.value, newAstObject); - } - }); - } else if (ymlAstContent.items && Array.isArray(ymlAstContent.items)) { - ymlAstContent.items.forEach((v, i) => { - if (v.kind === 0) { - const key = `${findKeyChain(ymlAstContent.parent)}[${i}]`; - newAstObject[key] = v; - } - }); +const findTopLevelBranchRange = (source, headKey) => { + const lines = splitIntoLines(source); + let offset = 0; + let start = null; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (start === null && isTopLevelKeyLine(line, headKey)) { + start = offset; + offset += line.length; + continue; + } + + if (start !== null && isTopLevelKeyLine(line)) { + return { start, end: offset }; + } + + offset += line.length; } - return newAstObject; + if (start === null) { + return null; + } + + return { start, end: source.length }; }; -const constructPlainObject = (ymlAstContent, branchObject) => { - const newbranchObject = branchObject || {}; - if (ymlAstContent.mappings && Array.isArray(ymlAstContent.mappings)) { - ymlAstContent.mappings.forEach((v) => { - if (!v.value) { - // no need to log twice, parseAST will log errors - return; - } - - if (v.key.kind === 0 && v.value.kind === 0) { - newbranchObject[v.key.value] = v.value.value; - } else if (v.key.kind === 0 && v.value.kind === 2) { - newbranchObject[v.key.value] = constructPlainObject(v.value, {}); - } else if (v.key.kind === 0 && v.value.kind === 3) { - const plainArray = []; - v.value.items.forEach((c) => { - plainArray.push(c.value); - }); - newbranchObject[v.key.value] = plainArray; - } - }); +const replaceTopLevelBranch = (source, headKey, branchValue) => { + const branchRange = findTopLevelBranchRange(source, headKey); + const branchText = branchValue === undefined ? '' : serializeBranch(headKey, branchValue); + + if (!branchRange) { + if (!branchText) { + return source; + } + + const trimmedSource = source.replace(/\s*$/, ''); + if (!trimmedSource) { + return branchText; + } + + return `${trimmedSource}${os.EOL}${branchText}`; } - return newbranchObject; + const nextSource = `${source.slice(0, branchRange.start)}${branchText}${source.slice( + branchRange.end + )}`; + return nextSource.trim() ? nextSource : ''; }; -const addNewArrayItem = (ymlFile, pathInYml, newValue) => - fs.readFileAsync(ymlFile, 'utf8').then((yamlContent) => { - const rawAstObject = yaml.load(yamlContent); - const astObject = parseAST(rawAstObject); - const plainObject = constructPlainObject(rawAstObject); - const pathInYmlArray = pathInYml.split('.'); - - let currentNode = plainObject; - for (let i = 0; i < pathInYmlArray.length - 1; i++) { - const propertyName = pathInYmlArray[i]; - const property = currentNode[propertyName]; - if (!property || _.isObject(property)) { - currentNode[propertyName] = property || {}; - currentNode = currentNode[propertyName]; - } else { - throw new Error(`${property} can only be undefined or an object!`); - } +const ensureObjectPath = (root, pathSegments) => { + let currentNode = root; + + for (const segment of pathSegments) { + const value = currentNode[segment]; + if (value == null) { + currentNode[segment] = {}; + currentNode = currentNode[segment]; + continue; + } + + if (!_.isPlainObject(value)) { + throw new Error(`${value} can only be undefined or an object!`); } - const arrayPropertyName = _.last(pathInYmlArray); - let arrayProperty = currentNode[arrayPropertyName]; - if (!arrayProperty || Array.isArray(arrayProperty)) { - arrayProperty = arrayProperty || []; - } else { - throw new Error(`${arrayProperty} can only be undefined or an array!`); + currentNode = value; + } + + return currentNode; +}; + +const pruneEmptyBranches = (root, pathSegments) => { + const nodes = [root]; + let currentNode = root; + + for (let index = 0; index < pathSegments.length; index += 1) { + currentNode = currentNode[pathSegments[index]]; + if (!_.isPlainObject(currentNode)) { + return; } - currentNode[arrayPropertyName] = _.union(arrayProperty, [newValue]); - - const branchToReplaceName = pathInYmlArray[0]; - const newObject = {}; - newObject[branchToReplaceName] = plainObject[branchToReplaceName]; - const newText = yaml.dump(newObject); - if (astObject[branchToReplaceName]) { - const beginning = yamlContent.substring( - 0, - astObject[branchToReplaceName].parent.key.startPosition - ); - const end = yamlContent.substring( - astObject[branchToReplaceName].endPosition, - yamlContent.length - ); - return fs.writeFileAsync(ymlFile, `${beginning}${newText}${end}`); + + nodes.push(currentNode); + } + + for (let index = pathSegments.length - 1; index >= 0; index -= 1) { + const node = nodes[index + 1]; + if (Object.keys(node).length > 0) { + break; } - return fs.writeFileAsync(ymlFile, `${yamlContent}${os.EOL}${newText}`); - }); - -const removeExistingArrayItem = async (ymlFile, pathInYml, removeValue) => - fs.readFileAsync(ymlFile, 'utf8').then((yamlContent) => { - const rawAstObject = yaml.load(yamlContent); - const astObject = parseAST(rawAstObject); - - if (astObject[pathInYml] && astObject[pathInYml].items) { - const plainObject = constructPlainObject(rawAstObject); - const pathInYmlArray = pathInYml.split('.'); - - let currentNode = plainObject; - const pathInObjectTree = []; - for (let i = 0; i < pathInYmlArray.length - 1; i++) { - pathInObjectTree.push(currentNode); - currentNode = currentNode[pathInYmlArray[i]]; - } - const arrayPropertyName = _.last(pathInYmlArray); - const arrayProperty = currentNode[arrayPropertyName]; - _.pull(arrayProperty, removeValue); - - if (!arrayProperty.length) { - delete currentNode[arrayPropertyName]; - pathInObjectTree.push(currentNode); - for (let i = pathInObjectTree.length - 1; i > 0; i--) { - if (Object.keys(pathInObjectTree[i]).length > 0) { - break; - } - delete pathInObjectTree[i - 1][pathInYmlArray[i - 1]]; - } - } - - const headObjectPath = pathInYmlArray[0]; - let newText = ''; - - if (plainObject[headObjectPath]) { - const newObject = {}; - newObject[headObjectPath] = plainObject[headObjectPath]; - newText = yaml.dump(newObject); - } - const beginning = yamlContent.substring( - 0, - astObject[headObjectPath].parent.key.startPosition - ); - const end = yamlContent.substring(astObject[pathInYml].endPosition, yamlContent.length); - return fs.writeFileAsync(ymlFile, `${beginning}${newText}${end}`); + + delete nodes[index][pathSegments[index]]; + } +}; + +const addNewArrayItem = async (ymlFile, pathInYml, newValue) => { + const yamlContent = await fsp.readFile(ymlFile, 'utf8'); + const data = parseYaml(yamlContent, ymlFile); + const pathSegments = pathInYml.split('.'); + const arrayPropertyName = _.last(pathSegments); + const currentNode = ensureObjectPath(data, pathSegments.slice(0, -1)); + const arrayProperty = currentNode[arrayPropertyName]; + + if (arrayProperty != null && !Array.isArray(arrayProperty)) { + throw new Error(`${arrayProperty} can only be undefined or an array!`); + } + + currentNode[arrayPropertyName] = _.union(arrayProperty || [], [newValue]); + + await fsp.writeFile( + ymlFile, + replaceTopLevelBranch(yamlContent, pathSegments[0], data[pathSegments[0]]) + ); +}; + +const removeExistingArrayItem = async (ymlFile, pathInYml, removeValue) => { + const yamlContent = await fsp.readFile(ymlFile, 'utf8'); + const data = parseYaml(yamlContent, ymlFile); + const pathSegments = pathInYml.split('.'); + const arrayPropertyName = _.last(pathSegments); + let currentNode = data; + + for (const segment of pathSegments.slice(0, -1)) { + currentNode = currentNode && currentNode[segment]; + if (!_.isPlainObject(currentNode)) { + return; } - return BbPromise.resolve(); - }); + } + + const arrayProperty = currentNode[arrayPropertyName]; + if (!Array.isArray(arrayProperty)) { + return; + } + + _.pull(arrayProperty, removeValue); + + if (!arrayProperty.length) { + delete currentNode[arrayPropertyName]; + pruneEmptyBranches(data, pathSegments.slice(0, -1)); + } + + await fsp.writeFile( + ymlFile, + replaceTopLevelBranch(yamlContent, pathSegments[0], data[pathSegments[0]]) + ); +}; module.exports = { addNewArrayItem, diff --git a/package.json b/package.json index 7d0e508514..24344cf5ce 100644 --- a/package.json +++ b/package.json @@ -57,9 +57,9 @@ "cachedir": "^2.3.0", "chalk": "^4.1.2", "child-process-ext": "^3.0.2", - "content-disposition": "^0.5.4", "ci-info": "^3.9.0", "cli-progress-footer": "^2.3.2", + "content-disposition": "^0.5.4", "d": "^1.0.1", "dayjs": "^1.11.8", "decompress": "^4.2.1", @@ -69,35 +69,28 @@ "event-emitter": "^0.3.5", "ext": "^1.7.0", "ext-name": "^5.0.0", + "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", - "file-type": "^16.5.4", "filenamify": "^4.3.0", "filesize": "^10.0.7", "fs-extra": "^10.1.0", "get-stdin": "^8.0.0", - "get-stream": "^6.0.1", - "globby": "^11.1.0", - "got": "^11.8.6", "graceful-fs": "^4.2.11", "https-proxy-agent": "^5.0.1", - "inquirer": "^8.2.7", "is-docker": "^2.2.1", "js-yaml": "^4.1.0", "json-cycle": "^1.5.0", "json-refs": "^3.0.15", + "lodash": "^4.17.21", "log": "^6.3.1", "log-node": "^8.0.3", - "lodash": "^4.17.21", - "make-dir": "^4.0.0", "memoizee": "^0.4.15", "micromatch": "^4.0.5", "module-alias": "^2.2.3", "ncjsm": "^4.3.2", "object-hash": "^3.0.0", "open": "^8.4.2", - "p-event": "^4.2.0", "process-utils": "^4.0.0", - "promise-queue": "^2.2.5", "punycode": "^2.3.1", "require-from-string": "^2.0.2", "semver": "^7.5.3", @@ -110,10 +103,8 @@ "undici": "^6.21.0", "uni-global": "^1.0.0", "untildify": "^4.0.0", - "uuid": "^9.0.0", "write-file-atomic": "^4.0.2", - "ws": "^7.5.9", - "yaml-ast-parser": "0.0.43" + "ws": "^7.5.9" }, "devDependencies": { "adm-zip": "^0.5.10", diff --git a/scripts/serverless.js b/scripts/serverless.js index 28fb4ff239..1249c8120f 100755 --- a/scripts/serverless.js +++ b/scripts/serverless.js @@ -129,7 +129,7 @@ process.once('uncaughtException', (error) => { } const path = require('path'); - const uuid = require('uuid'); + const { randomUUID } = require('node:crypto'); const _ = require('lodash'); const clear = require('ext/object/clear'); const Serverless = require('../lib/serverless'); @@ -503,7 +503,7 @@ process.once('uncaughtException', (error) => { }); try { - serverless.invocationId = uuid.v4(); + serverless.invocationId = randomUUID(); processLog.debug('initialize Serverless instance'); await serverless.init(); diff --git a/test/integration/aws/custom-deployment-bucket.test.js b/test/integration/aws/custom-deployment-bucket.test.js index 806ebb1a98..6ff67f870a 100644 --- a/test/integration/aws/custom-deployment-bucket.test.js +++ b/test/integration/aws/custom-deployment-bucket.test.js @@ -1,6 +1,6 @@ 'use strict'; -const uuid = require('uuid'); +const { randomUUID } = require('node:crypto'); const { expect } = require('chai'); const fixtures = require('../../fixtures/programmatic'); const awsRequest = require('../../lib/aws-request'); @@ -11,7 +11,7 @@ const { createBucket, deleteBucket } = require('../../utils/s3'); describe('Base AWS provider test', function () { this.timeout(1000 * 60 * 10); - const bucketName = `serverless-test-${uuid.v4()}`; + const bucketName = `serverless-test-${randomUUID()}`; let serviceDir; before(async () => { diff --git a/test/unit/commands/plugin-uninstall.test.js b/test/unit/commands/plugin-uninstall.test.js index f5709b691a..27339424a7 100644 --- a/test/unit/commands/plugin-uninstall.test.js +++ b/test/unit/commands/plugin-uninstall.test.js @@ -173,5 +173,52 @@ describe('test/unit/commands/plugin-uninstall.test.js', async () => { 'Fn::Sub': '${AWS::Region}', }); }); + + it('preserves later plugins siblings when removing the last object-form plugin entry', async () => { + const fixture = await fixturesEngine.setup('function'); + const fixtureServiceDir = fixture.servicePath; + const rawYaml = [ + 'service: raw-plugin-yaml', + 'configValidationMode: error', + "frameworkVersion: '*'", + '', + 'plugins:', + ' modules:', + ` - ${pluginName}`, + ' localPath: ./.serverless_plugins', + '', + 'custom:', + ' taggedValue: !Sub ${AWS::Region}', + '', + 'provider:', + ' name: aws', + ' runtime: nodejs20.x', + '', + ].join('\n'); + + const { configurationFilePath: fixtureConfigurationPath, configuration } = + await writeRawConfiguration(fixtureServiceDir, rawYaml); + + await uninstallPlugin({ + configuration, + serviceDir: fixtureServiceDir, + configurationFilename: path.basename(fixtureConfigurationPath), + options: { + name: pluginName, + }, + }); + + const fileText = await fse.readFile(fixtureConfigurationPath, 'utf8'); + const parsed = await readParsedConfiguration(fixtureConfigurationPath); + + expect(fileText).to.include('localPath: ./.serverless_plugins'); + expect(fileText).to.include('taggedValue: !Sub ${AWS::Region}'); + expect(fileText).to.not.include(`- ${pluginName}`); + expect(parsed.plugins.localPath).to.equal('./.serverless_plugins'); + expect(parsed.plugins.modules).to.equal(undefined); + expect(parsed.custom.taggedValue).to.deep.equal({ + 'Fn::Sub': '${AWS::Region}', + }); + }); }); }); diff --git a/test/unit/lib/aws/request.test.js b/test/unit/lib/aws/request.test.js index 6fc8de4a31..73502fd987 100644 --- a/test/unit/lib/aws/request.test.js +++ b/test/unit/lib/aws/request.test.js @@ -7,6 +7,17 @@ const overrideEnv = require('process-utils/override-env'); const expect = chai.expect; +const createDeferred = () => { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +}; + describe('#request', () => { describe('Credentials support', () => { // awsRequest supports credentials from two sources: @@ -359,6 +370,57 @@ describe('#request', () => { return expect(service.useAccelerateEndpoint).to.be.true; }); + it('should limit concurrent AWS requests to two at a time', async () => { + const requestDeferreds = [createDeferred(), createDeferred(), createDeferred()]; + let activeRequests = 0; + let maxActiveRequests = 0; + let requestIndex = 0; + + class FakeS3 { + putObject() { + const currentRequest = requestDeferreds[requestIndex]; + requestIndex += 1; + activeRequests += 1; + maxActiveRequests = Math.max(maxActiveRequests, activeRequests); + + return { + promise: () => + currentRequest.promise.finally(() => { + activeRequests -= 1; + }), + }; + } + } + + const awsRequest = proxyquire('../../../../lib/aws/request', { + './sdk-v2': { S3: FakeS3 }, + }); + + const requests = [ + awsRequest({ name: 'S3' }, 'putObject', {}), + awsRequest({ name: 'S3' }, 'putObject', {}), + awsRequest({ name: 'S3' }, 'putObject', {}), + ]; + + await Promise.resolve(); + await Promise.resolve(); + + expect(requestIndex).to.equal(2); + + requestDeferreds[0].resolve({ id: 1 }); + await requests[0]; + await Promise.resolve(); + + expect(requestIndex).to.equal(3); + + requestDeferreds[1].resolve({ id: 2 }); + requestDeferreds[2].resolve({ id: 3 }); + + await Promise.all(requests); + + expect(maxActiveRequests).to.equal(2); + }); + describe('Caching through memoize', () => { it('should reuse the result if arguments are the same', async () => { // mocking CF for testing diff --git a/test/unit/lib/plugins/aws/custom-resources/generate-zip.test.js b/test/unit/lib/plugins/aws/custom-resources/generate-zip.test.js index 274c295f7e..c51d17dfbe 100644 --- a/test/unit/lib/plugins/aws/custom-resources/generate-zip.test.js +++ b/test/unit/lib/plugins/aws/custom-resources/generate-zip.test.js @@ -1,7 +1,7 @@ 'use strict'; const path = require('path'); -const globby = require('globby'); +const globby = require('../../../../../../lib/utils/glob'); const requireUncached = require('ncjsm/require-uncached'); const { listZipFiles } = require('../../../../../utils/fs'); const { expect } = require('chai'); diff --git a/test/unit/lib/plugins/aws/deploy/lib/check-for-changes.test.js b/test/unit/lib/plugins/aws/deploy/lib/check-for-changes.test.js index 11486f71b3..ae70c769f8 100644 --- a/test/unit/lib/plugins/aws/deploy/lib/check-for-changes.test.js +++ b/test/unit/lib/plugins/aws/deploy/lib/check-for-changes.test.js @@ -5,7 +5,7 @@ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); -const globby = require('globby'); +const globby = require('../../../../../../../lib/utils/glob'); const sandbox = require('sinon'); const proxyquire = require('proxyquire'); const normalizeFiles = require('../../../../../../../lib/plugins/aws/lib/normalize-files'); diff --git a/test/unit/lib/plugins/create/create.test.js b/test/unit/lib/plugins/create/create.test.js index bf9e58e553..3c272990ae 100644 --- a/test/unit/lib/plugins/create/create.test.js +++ b/test/unit/lib/plugins/create/create.test.js @@ -206,6 +206,7 @@ describe('test/unit/lib/plugins/create/create.test.js', () => { const expectedTemplateUrl = 'https://github.com/johndoe/template/archive/master.zip'; const tempRoot = getTmpDirPath(); const targetDir = path.join(tempRoot, 'nested', 'custom-target-directory'); + const originalFetch = globalThis.fetch; const server = http.createServer((req, res) => { requests.push(req.url); @@ -222,30 +223,15 @@ describe('test/unit/lib/plugins/create/create.test.js', () => { await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const baseUrl = `http://127.0.0.1:${server.address().port}`; - const realGot = require('got'); const requestedUrls = []; - const wrappedGot = Object.assign( - (...args) => { - const [uri, options] = args; - const stringUri = String(uri); - requestedUrls.push(stringUri); - return realGot( - stringUri === expectedTemplateUrl ? `${baseUrl}/archive.zip` : uri, - options - ); - }, - realGot, - { - stream: (uri, options) => { - const stringUri = String(uri); - requestedUrls.push(stringUri); - return realGot.stream( - stringUri === expectedTemplateUrl ? `${baseUrl}/archive.zip` : uri, - options - ); - }, - } - ); + globalThis.fetch = async (uri, options) => { + const stringUri = String(uri); + requestedUrls.push(stringUri); + return originalFetch( + stringUri === expectedTemplateUrl ? `${baseUrl}/archive.zip` : stringUri, + options + ); + }; try { await runServerless({ @@ -255,9 +241,6 @@ describe('test/unit/lib/plugins/create/create.test.js', () => { 'template-url': 'https://github.com/johndoe/template', 'path': targetDir, }, - modulesCacheStub: { - got: wrappedGot, - }, }); const serverlessYml = await fsp.readFile(path.join(targetDir, 'serverless.yml'), 'utf8'); @@ -270,6 +253,7 @@ describe('test/unit/lib/plugins/create/create.test.js', () => { expect(requestedUrls).to.deep.equal([expectedTemplateUrl]); expect(requests).to.deep.equal(['/archive.zip']); } finally { + globalThis.fetch = originalFetch; await new Promise((resolve, reject) => { server.close((error) => { if (error) { diff --git a/test/unit/lib/plugins/package/lib/zip-service.test.js b/test/unit/lib/plugins/package/lib/zip-service.test.js index f08b057e71..4a3e521704 100644 --- a/test/unit/lib/plugins/package/lib/zip-service.test.js +++ b/test/unit/lib/plugins/package/lib/zip-service.test.js @@ -5,7 +5,7 @@ const os = require('os'); const path = require('path'); const JsZip = require('jszip'); -const globby = require('globby'); +const globby = require('../../../../../../lib/utils/glob'); const _ = require('lodash'); const BbPromise = require('bluebird'); const fs = BbPromise.promisifyAll(require('fs')); diff --git a/test/unit/lib/utils/serverless-utils/config.test.js b/test/unit/lib/utils/serverless-utils/config.test.js index 200d74ac6c..dd9930148e 100644 --- a/test/unit/lib/utils/serverless-utils/config.test.js +++ b/test/unit/lib/utils/serverless-utils/config.test.js @@ -137,6 +137,39 @@ describe('serverless-utils/config', () => { }); }); + it('preserves an existing frameworkId when updating config', async () => { + await withIsolatedHome('config-preserve-framework-id', async (homeDir) => { + const config = loadConfigModule(); + + await withLocalDir(homeDir, 'service', async () => { + const globalConfigPath = path.join(homeDir, config.CONFIG_FILE_NAME); + const legacyFrameworkId = 'f81d4fae-7dec-11d0-a765-00a0c91e6bf6'; + + await fs.promises.writeFile( + globalConfigPath, + JSON.stringify( + { + frameworkId: legacyFrameworkId, + meta: { + created_at: 123, + updated_at: 123, + }, + }, + null, + 2 + ) + ); + + expect(config.get('frameworkId')).to.equal(legacyFrameworkId); + + config.set('custom.value', 'somevalue'); + + const storedConfig = JSON.parse(await fs.promises.readFile(globalConfigPath, 'utf8')); + expect(storedConfig.frameworkId).to.equal(legacyFrameworkId); + }); + }); + }); + it('uses the ~/.config global config when it exists alone', async () => { await withIsolatedHome('config-home-config-only', async (homeDir) => { const config = loadConfigModule(); diff --git a/test/unit/lib/utils/serverless-utils/inquirer.test.js b/test/unit/lib/utils/serverless-utils/inquirer.test.js index 44ac44da64..e367db6214 100644 --- a/test/unit/lib/utils/serverless-utils/inquirer.test.js +++ b/test/unit/lib/utils/serverless-utils/inquirer.test.js @@ -1,36 +1,49 @@ 'use strict'; const sinon = require('sinon'); +const proxyquire = require('proxyquire').noCallThru(); const { expect } = require('chai'); -const requireUncached = require('ncjsm/require-uncached'); - -const configureInquirerStub = require('../../../../lib/configure-inquirer-stub'); describe('serverless-utils/inquirer', () => { - let originalIsTTY; - - beforeEach(() => { - originalIsTTY = process.stdin.isTTY; - process.stdin.isTTY = true; - }); - afterEach(() => { - if (originalIsTTY === undefined) { - delete process.stdin.isTTY; - } else { - process.stdin.isTTY = originalIsTTY; - } sinon.restore(); }); - it('wraps inquirer without breaking prompt behavior', async () => { - const inquirer = requireUncached(() => - require('../../../../../lib/utils/serverless-utils/inquirer') - ); + it('returns the confirmation answer from the local prompt facade', async () => { + const question = sinon.stub().resolves('yes'); + const close = sinon.stub(); + const createInterface = sinon.stub().returns({ + question, + close, + }); + + const inquirer = proxyquire('../../../../../lib/utils/serverless-utils/inquirer', { + 'node:readline/promises': { + createInterface, + }, + }); + + const result = await inquirer.prompt({ + message: 'Should?', + type: 'confirm', + name: 'shouldConfirm', + }); + + expect(result.shouldConfirm).to.equal(true); + expect(question.calledOnceWithExactly('? Should? (Y/n) ')).to.equal(true); + expect(close.calledOnce).to.equal(true); + }); + + it('defaults blank confirmation answers to yes', async () => { + const question = sinon.stub().resolves(''); + const createInterface = sinon.stub().returns({ + question, + close: sinon.stub(), + }); - configureInquirerStub(inquirer, { - confirm: { - shouldConfirm: true, + const inquirer = proxyquire('../../../../../lib/utils/serverless-utils/inquirer', { + 'node:readline/promises': { + createInterface, }, }); diff --git a/test/unit/lib/utils/yaml-ast-parser.test.js b/test/unit/lib/utils/yaml-ast-parser.test.js index 6388a39366..ddd18897ad 100644 --- a/test/unit/lib/utils/yaml-ast-parser.test.js +++ b/test/unit/lib/utils/yaml-ast-parser.test.js @@ -323,5 +323,31 @@ describe('#yamlAstParser', () => { expectedResult ); }); + + it('preserves sibling properties after removing the last nested array item', () => { + const yamlContent = [ + 'plugins:', + ' modules:', + ' - foo', + ' localPath: ./.serverless_plugins', + 'custom:', + ' taggedValue: keep-me', + ].join('\n'); + const expectedResult = { + plugins: { + localPath: './.serverless_plugins', + }, + custom: { + taggedValue: 'keep-me', + }, + }; + + return removeExistingArrayItemAndVerifyResult( + yamlContent, + 'plugins.modules', + 'foo', + expectedResult + ); + }); }); }); From cc9009e10695cc15c74c90a14fd4a4d516567f50 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Thu, 23 Apr 2026 00:30:02 +0100 Subject: [PATCH 2/8] Apply corrections from code review --- lib/utils/download-template-from-repo.js | 3 + lib/utils/glob.js | 11 +++- lib/utils/serverless-utils/download.js | 51 +++++++++++++++- lib/utils/serverless-utils/inquirer/index.js | 20 +++++- lib/utils/yaml-ast-parser.js | 5 +- test/unit/commands/plugin-install.test.js | 44 +++++++++++++ test/unit/commands/plugin-uninstall.test.js | 42 +++++++++++++ .../utils/download-template-from-repo.test.js | 15 +++++ test/unit/lib/utils/glob.test.js | 31 ++++++++++ .../utils/serverless-utils/download.test.js | 61 +++++++++++++++++++ .../utils/serverless-utils/inquirer.test.js | 51 ++++++++++++++++ test/unit/lib/utils/yaml-ast-parser.test.js | 40 ++++++++++++ 12 files changed, 368 insertions(+), 6 deletions(-) create mode 100644 test/unit/lib/utils/glob.test.js diff --git a/lib/utils/download-template-from-repo.js b/lib/utils/download-template-from-repo.js index 533cfb40f9..fd3306b42c 100644 --- a/lib/utils/download-template-from-repo.js +++ b/lib/utils/download-template-from-repo.js @@ -268,6 +268,8 @@ async function downloadTemplateFromRepo(inputUrl, requestedServiceName, download let sourceName; let downloadServicePath; const { username, password } = repoInformation; + const allowedAuthRedirectHostnames = + URL.parse(repoInformation.downloadUrl).hostname === 'github.com' ? ['codeload.github.com'] : []; if (repoInformation.isSubdirectory) { const folderName = repoInformation.pathToDirectory.split('/').splice(-1)[0]; @@ -313,6 +315,7 @@ async function downloadTemplateFromRepo(inputUrl, requestedServiceName, download mode: '755', username, password, + allowedAuthRedirectHostnames, }; // download service return download(repoInformation.downloadUrl, downloadServicePath, downloadOptions) diff --git a/lib/utils/glob.js b/lib/utils/glob.js index 59a3112052..314412c0d6 100644 --- a/lib/utils/glob.js +++ b/lib/utils/glob.js @@ -68,6 +68,15 @@ const normalizeOptions = (options = {}) => { const buildTasks = (patterns, globOptions) => { const tasks = []; + const leadingIgnore = []; + + for (const pattern of patterns) { + if (!pattern.startsWith('!')) { + break; + } + + leadingIgnore.push(pattern.slice(1)); + } for (let index = 0; index < patterns.length; index += 1) { const pattern = patterns[index]; @@ -84,7 +93,7 @@ const buildTasks = (patterns, globOptions) => { pattern, options: { ...globOptions, - ignore: [...(globOptions.ignore || []), ...ignore], + ignore: [...(globOptions.ignore || []), ...(tasks.length ? [] : leadingIgnore), ...ignore], }, }); } diff --git a/lib/utils/serverless-utils/download.js b/lib/utils/serverless-utils/download.js index 19dc7bedac..3659e162f4 100644 --- a/lib/utils/serverless-utils/download.js +++ b/lib/utils/serverless-utils/download.js @@ -24,6 +24,8 @@ const decompress = require('decompress'); const filenamify = require('filenamify'); const extName = require('ext-name'); +const redirectStatusCodes = new Set([301, 302, 303, 307, 308]); + const filenameFromPath = (requestUrl) => path.basename(new URL(requestUrl).pathname); const getExtFromMime = (headers) => { @@ -71,6 +73,51 @@ const getFilename = ({ requestUrl, headers, data, explicitFilename }) => { return filename; }; +const fetchWithRedirects = async ( + requestUrl, + { headers, dispatcher, signal, maxRedirects = 10, allowedAuthRedirectHostnames = [] } = {} +) => { + let currentUrl = new URL(requestUrl); + let currentHeaders = { ...(headers || {}) }; + const allowedAuthHostnames = new Set(allowedAuthRedirectHostnames); + + for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1) { + const response = await fetch(currentUrl, { + headers: currentHeaders, + dispatcher, + signal, + redirect: 'manual', + }); + + if (!redirectStatusCodes.has(response.status)) { + return response; + } + + if (redirectCount === maxRedirects) { + throw new Error('Too many redirects'); + } + + const location = response.headers.get('location'); + if (!location) { + throw new Error(`Redirect response missing location header: ${response.status}`); + } + + const nextUrl = new URL(location, currentUrl); + if ( + currentHeaders.authorization && + nextUrl.origin !== currentUrl.origin && + !allowedAuthHostnames.has(nextUrl.hostname) + ) { + currentHeaders = { ...currentHeaders }; + delete currentHeaders.authorization; + } + + currentUrl = nextUrl; + } + + throw new Error('Too many redirects'); +}; + module.exports = (uri, output, opts) => { return (async () => { if (typeof output === 'object') { @@ -100,10 +147,12 @@ module.exports = (uri, output, opts) => { ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined; - const response = await fetch(uri, { + const response = await fetchWithRedirects(uri, { headers, dispatcher, signal: typeof opts.timeout === 'number' ? AbortSignal.timeout(opts.timeout) : undefined, + maxRedirects: opts.maxRedirects, + allowedAuthRedirectHostnames: opts.allowedAuthRedirectHostnames, }); if (!response.ok) { diff --git a/lib/utils/serverless-utils/inquirer/index.js b/lib/utils/serverless-utils/inquirer/index.js index 5a62574051..5107b282ec 100644 --- a/lib/utils/serverless-utils/inquirer/index.js +++ b/lib/utils/serverless-utils/inquirer/index.js @@ -11,7 +11,15 @@ const parseConfirm = (value, defaultValue = true) => { return defaultValue; } - return answer === 'y' || answer === 'yes'; + if (answer === 'y' || answer === 'yes') { + return true; + } + + if (answer === 'n' || answer === 'no') { + return false; + } + + return undefined; }; const promptOne = async ({ message, type, name, default: defaultValue = true }) => { @@ -25,8 +33,14 @@ const promptOne = async ({ message, type, name, default: defaultValue = true }) }); try { - const answer = await rl.question(`? ${message} (${defaultValue ? 'Y/n' : 'y/N'}) `); - return { [name]: parseConfirm(answer, defaultValue) }; + let parsedAnswer; + + while (parsedAnswer === undefined) { + const answer = await rl.question(`? ${message} (${defaultValue ? 'Y/n' : 'y/N'}) `); + parsedAnswer = parseConfirm(answer, defaultValue); + } + + return { [name]: parsedAnswer }; } finally { rl.close(); } diff --git a/lib/utils/yaml-ast-parser.js b/lib/utils/yaml-ast-parser.js index e460ddc735..29afe505eb 100644 --- a/lib/utils/yaml-ast-parser.js +++ b/lib/utils/yaml-ast-parser.js @@ -32,7 +32,10 @@ const isTopLevelKeyLine = (line, key) => { const normalizedLine = stripLineEnding(line); if (key) { - return new RegExp(`^${escapeRegExp(key)}:(?:\\s|$)`).test(normalizedLine); + const escapedKey = escapeRegExp(key); + return new RegExp(`^(?:"${escapedKey}"|'${escapedKey}'|${escapedKey}):(?:\\s|$)`).test( + normalizedLine + ); } return topLevelKeyLineRegex.test(normalizedLine) || documentMarkerRegex.test(normalizedLine); diff --git a/test/unit/commands/plugin-install.test.js b/test/unit/commands/plugin-install.test.js index a36b6b09c5..6b7f33ee9e 100644 --- a/test/unit/commands/plugin-install.test.js +++ b/test/unit/commands/plugin-install.test.js @@ -214,5 +214,49 @@ describe('test/unit/commands/plugin-install.test.js', async () => { 'Fn::Sub': '${AWS::Region}', }); }); + + it('updates a quoted top level plugins array without duplicating the section', async () => { + const fixture = await fixturesEngine.setup('function'); + const serviceDir = fixture.servicePath; + const rawYaml = [ + 'service: raw-plugin-yaml', + 'configValidationMode: error', + "frameworkVersion: '*'", + '', + '"plugins":', + ' - existing-plugin', + '', + 'custom:', + ' taggedValue: !Sub ${AWS::Region}', + '', + 'provider:', + ' name: aws', + ' runtime: nodejs20.x', + '', + ].join('\n'); + + const { configurationFilePath, configuration } = await writeRawConfiguration( + serviceDir, + rawYaml + ); + + await installPlugin({ + configuration, + serviceDir, + configurationFilename: path.basename(configurationFilePath), + options: { + name: pluginName, + }, + }); + + const fileText = await fse.readFile(configurationFilePath, 'utf8'); + const parsed = await readParsedConfiguration(configurationFilePath); + + expect(fileText.match(/^(?:"plugins"|plugins):/gm)).to.have.length(1); + expect(parsed.plugins).to.deep.equal(['existing-plugin', pluginName]); + expect(parsed.custom.taggedValue).to.deep.equal({ + 'Fn::Sub': '${AWS::Region}', + }); + }); }); }); diff --git a/test/unit/commands/plugin-uninstall.test.js b/test/unit/commands/plugin-uninstall.test.js index 27339424a7..6e3836eb97 100644 --- a/test/unit/commands/plugin-uninstall.test.js +++ b/test/unit/commands/plugin-uninstall.test.js @@ -220,5 +220,47 @@ describe('test/unit/commands/plugin-uninstall.test.js', async () => { 'Fn::Sub': '${AWS::Region}', }); }); + + it('removes plugins from a quoted top level plugins array without leaving a duplicate section', async () => { + const fixture = await fixturesEngine.setup('function'); + const fixtureServiceDir = fixture.servicePath; + const rawYaml = [ + 'service: raw-plugin-yaml', + 'configValidationMode: error', + "frameworkVersion: '*'", + '', + '"plugins":', + ` - ${pluginName}`, + '', + 'custom:', + ' taggedValue: !Sub ${AWS::Region}', + '', + 'provider:', + ' name: aws', + ' runtime: nodejs20.x', + '', + ].join('\n'); + + const { configurationFilePath: fixtureConfigurationPath, configuration } = + await writeRawConfiguration(fixtureServiceDir, rawYaml); + + await uninstallPlugin({ + configuration, + serviceDir: fixtureServiceDir, + configurationFilename: path.basename(fixtureConfigurationPath), + options: { + name: pluginName, + }, + }); + + const fileText = await fse.readFile(fixtureConfigurationPath, 'utf8'); + const parsed = await readParsedConfiguration(fixtureConfigurationPath); + + expect(fileText.match(/^(?:"plugins"|plugins):/gm)).to.equal(null); + expect(parsed.plugins).to.equal(undefined); + expect(parsed.custom.taggedValue).to.deep.equal({ + 'Fn::Sub': '${AWS::Region}', + }); + }); }); }); diff --git a/test/unit/lib/utils/download-template-from-repo.test.js b/test/unit/lib/utils/download-template-from-repo.test.js index 5007bc6602..8cb26920de 100644 --- a/test/unit/lib/utils/download-template-from-repo.test.js +++ b/test/unit/lib/utils/download-template-from-repo.test.js @@ -225,6 +225,21 @@ describe('downloadTemplateFromRepo', () => { }); }); + it('passes the GitHub auth redirect allowlist through to the downloader', async () => { + const url = 'https://username:password@github.com/serverless/serverless'; + + await downloadTemplateFromRepo(url); + + expect(downloadStub.calledOnce).to.equal(true); + expect(downloadStub.firstCall.args[2]).to.deep.include({ + username: 'username', + password: 'password', + }); + expect(downloadStub.firstCall.args[2].allowedAuthRedirectHostnames).to.deep.equal([ + 'codeload.github.com', + ]); + }); + it('should download into the provided path and rename the service to the provided name', async () => { const url = 'https://github.com/johndoe/service-to-be-downloaded'; const name = 'new-service-name'; diff --git a/test/unit/lib/utils/glob.test.js b/test/unit/lib/utils/glob.test.js new file mode 100644 index 0000000000..edfccc248c --- /dev/null +++ b/test/unit/lib/utils/glob.test.js @@ -0,0 +1,31 @@ +'use strict'; + +const fs = require('node:fs').promises; +const os = require('node:os'); +const path = require('node:path'); +const fse = require('fs-extra'); +const { expect } = require('chai'); + +const glob = require('../../../../lib/utils/glob'); + +describe('test/unit/lib/utils/glob.test.js', () => { + let tmpDir; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'serverless-glob-')); + }); + + afterEach(async () => { + await fse.remove(tmpDir); + }); + + it('honors leading negation patterns for async and sync calls', async () => { + await Promise.all([ + fse.outputFile(path.join(tmpDir, 'keep.js'), 'keep\n'), + fse.outputFile(path.join(tmpDir, 'ignored', 'drop.js'), 'drop\n'), + ]); + + expect(await glob(['!ignored/**/*', '**/*.js'], { cwd: tmpDir })).to.deep.equal(['keep.js']); + expect(glob.sync(['!ignored/**/*', '**/*.js'], { cwd: tmpDir })).to.deep.equal(['keep.js']); + }); +}); diff --git a/test/unit/lib/utils/serverless-utils/download.test.js b/test/unit/lib/utils/serverless-utils/download.test.js index 86c77f2045..e38f1cfb19 100644 --- a/test/unit/lib/utils/serverless-utils/download.test.js +++ b/test/unit/lib/utils/serverless-utils/download.test.js @@ -116,4 +116,65 @@ describe('serverless-utils/download', () => { expect(await fsp.readFile(path.join(tmpDir, 'file.txt'), 'utf8')).to.equal('fixture'); }); + + it('preserves authorization across approved redirect hostnames', async () => { + let initialAuthorization; + let redirectedAuthorization; + + const redirectedServer = http.createServer((req, res) => { + redirectedAuthorization = req.headers.authorization; + res.statusCode = 200; + res.end('redirected payload'); + }); + + await new Promise((resolve) => redirectedServer.listen(0, '127.0.0.1', resolve)); + + const redirectingServer = http.createServer((req, res) => { + initialAuthorization = req.headers.authorization; + res.statusCode = 302; + res.setHeader('Location', `http://127.0.0.1:${redirectedServer.address().port}/final`); + res.end(); + }); + + await new Promise((resolve) => redirectingServer.listen(0, '127.0.0.1', resolve)); + + try { + const result = await download( + `http://127.0.0.1:${redirectingServer.address().port}/start`, + { + responseType: 'text', + username: 'user', + password: 'pass', + allowedAuthRedirectHostnames: ['127.0.0.1'], + } + ); + + expect(result).to.equal('redirected payload'); + expect(initialAuthorization).to.equal('Basic dXNlcjpwYXNz'); + expect(redirectedAuthorization).to.equal('Basic dXNlcjpwYXNz'); + } finally { + await Promise.all([ + new Promise((resolve, reject) => { + redirectingServer.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }), + new Promise((resolve, reject) => { + redirectedServer.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }), + ]); + } + }); }); diff --git a/test/unit/lib/utils/serverless-utils/inquirer.test.js b/test/unit/lib/utils/serverless-utils/inquirer.test.js index e367db6214..af2be5d0d9 100644 --- a/test/unit/lib/utils/serverless-utils/inquirer.test.js +++ b/test/unit/lib/utils/serverless-utils/inquirer.test.js @@ -55,4 +55,55 @@ describe('serverless-utils/inquirer', () => { expect(result.shouldConfirm).to.equal(true); }); + + it('defaults blank confirmation answers to no when default is false', async () => { + const question = sinon.stub().resolves(''); + const createInterface = sinon.stub().returns({ + question, + close: sinon.stub(), + }); + + const inquirer = proxyquire('../../../../../lib/utils/serverless-utils/inquirer', { + 'node:readline/promises': { + createInterface, + }, + }); + + const result = await inquirer.prompt({ + message: 'Should?', + type: 'confirm', + name: 'shouldConfirm', + default: false, + }); + + expect(result.shouldConfirm).to.equal(false); + expect(question.calledOnceWithExactly('? Should? (y/N) ')).to.equal(true); + }); + + it('re-prompts until it receives a valid confirmation answer', async () => { + const question = sinon.stub(); + question.onFirstCall().resolves('maybe'); + question.onSecondCall().resolves('n'); + const close = sinon.stub(); + const createInterface = sinon.stub().returns({ + question, + close, + }); + + const inquirer = proxyquire('../../../../../lib/utils/serverless-utils/inquirer', { + 'node:readline/promises': { + createInterface, + }, + }); + + const result = await inquirer.prompt({ + message: 'Should?', + type: 'confirm', + name: 'shouldConfirm', + }); + + expect(result.shouldConfirm).to.equal(false); + expect(question.calledTwice).to.equal(true); + expect(close.calledOnce).to.equal(true); + }); }); diff --git a/test/unit/lib/utils/yaml-ast-parser.test.js b/test/unit/lib/utils/yaml-ast-parser.test.js index ddd18897ad..1d83b758e9 100644 --- a/test/unit/lib/utils/yaml-ast-parser.test.js +++ b/test/unit/lib/utils/yaml-ast-parser.test.js @@ -163,6 +163,20 @@ describe('#yamlAstParser', () => { return addNewArrayItemAndVerifyResult(yamlContent, 'toplevel', 'foo', expectedResult); }); + it('should add an item under a quoted top level key', () => { + const yamlContent = ['"plugins":', ' - existing-plugin', 'custom:', ' taggedValue: keep-me'].join( + '\n' + ); + const expectedResult = { + plugins: ['existing-plugin', 'foo'], + custom: { + taggedValue: 'keep-me', + }, + }; + + return addNewArrayItemAndVerifyResult(yamlContent, 'plugins', 'foo', expectedResult); + }); + it('should survive with invalid yaml', () => { const yamlContent = 'service:'; const expectedResult = { service: null, toplevel: ['foo'] }; @@ -349,5 +363,31 @@ describe('#yamlAstParser', () => { expectedResult ); }); + + it('preserves quoted top level keys when removing the last nested array item', () => { + const yamlContent = [ + '"plugins":', + ' modules:', + ' - foo', + ' localPath: ./.serverless_plugins', + 'custom:', + ' taggedValue: keep-me', + ].join('\n'); + const expectedResult = { + plugins: { + localPath: './.serverless_plugins', + }, + custom: { + taggedValue: 'keep-me', + }, + }; + + return removeExistingArrayItemAndVerifyResult( + yamlContent, + 'plugins.modules', + 'foo', + expectedResult + ); + }); }); }); From 667a4bcd28c5a403ab8a138799dfc1ea71b959e2 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Thu, 23 Apr 2026 00:37:16 +0100 Subject: [PATCH 3/8] Update download-template-from-repo.js --- lib/utils/download-template-from-repo.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/utils/download-template-from-repo.js b/lib/utils/download-template-from-repo.js index fd3306b42c..71f12d1398 100644 --- a/lib/utils/download-template-from-repo.js +++ b/lib/utils/download-template-from-repo.js @@ -269,7 +269,7 @@ async function downloadTemplateFromRepo(inputUrl, requestedServiceName, download let downloadServicePath; const { username, password } = repoInformation; const allowedAuthRedirectHostnames = - URL.parse(repoInformation.downloadUrl).hostname === 'github.com' ? ['codeload.github.com'] : []; + repoInformation.downloadUrl.startsWith('https://github.com/') ? ['codeload.github.com'] : []; if (repoInformation.isSubdirectory) { const folderName = repoInformation.pathToDirectory.split('/').splice(-1)[0]; From dfe8db61bdbee022564c27c18015e3da11ac72ca Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Thu, 23 Apr 2026 00:43:17 +0100 Subject: [PATCH 4/8] Code style fixes --- lib/utils/download-template-from-repo.js | 7 ++++--- .../lib/utils/serverless-utils/download.test.js | 15 ++++++--------- test/unit/lib/utils/yaml-ast-parser.test.js | 9 ++++++--- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/lib/utils/download-template-from-repo.js b/lib/utils/download-template-from-repo.js index 71f12d1398..1dbf70280a 100644 --- a/lib/utils/download-template-from-repo.js +++ b/lib/utils/download-template-from-repo.js @@ -268,8 +268,9 @@ async function downloadTemplateFromRepo(inputUrl, requestedServiceName, download let sourceName; let downloadServicePath; const { username, password } = repoInformation; - const allowedAuthRedirectHostnames = - repoInformation.downloadUrl.startsWith('https://github.com/') ? ['codeload.github.com'] : []; + const authRedirectHostnames = repoInformation.downloadUrl.startsWith('https://github.com/') + ? ['codeload.github.com'] + : []; if (repoInformation.isSubdirectory) { const folderName = repoInformation.pathToDirectory.split('/').splice(-1)[0]; @@ -315,7 +316,7 @@ async function downloadTemplateFromRepo(inputUrl, requestedServiceName, download mode: '755', username, password, - allowedAuthRedirectHostnames, + allowedAuthRedirectHostnames: authRedirectHostnames, }; // download service return download(repoInformation.downloadUrl, downloadServicePath, downloadOptions) diff --git a/test/unit/lib/utils/serverless-utils/download.test.js b/test/unit/lib/utils/serverless-utils/download.test.js index e38f1cfb19..c78a954a84 100644 --- a/test/unit/lib/utils/serverless-utils/download.test.js +++ b/test/unit/lib/utils/serverless-utils/download.test.js @@ -139,15 +139,12 @@ describe('serverless-utils/download', () => { await new Promise((resolve) => redirectingServer.listen(0, '127.0.0.1', resolve)); try { - const result = await download( - `http://127.0.0.1:${redirectingServer.address().port}/start`, - { - responseType: 'text', - username: 'user', - password: 'pass', - allowedAuthRedirectHostnames: ['127.0.0.1'], - } - ); + const result = await download(`http://127.0.0.1:${redirectingServer.address().port}/start`, { + responseType: 'text', + username: 'user', + password: 'pass', + allowedAuthRedirectHostnames: ['127.0.0.1'], + }); expect(result).to.equal('redirected payload'); expect(initialAuthorization).to.equal('Basic dXNlcjpwYXNz'); diff --git a/test/unit/lib/utils/yaml-ast-parser.test.js b/test/unit/lib/utils/yaml-ast-parser.test.js index 1d83b758e9..1e6b92a22c 100644 --- a/test/unit/lib/utils/yaml-ast-parser.test.js +++ b/test/unit/lib/utils/yaml-ast-parser.test.js @@ -164,9 +164,12 @@ describe('#yamlAstParser', () => { }); it('should add an item under a quoted top level key', () => { - const yamlContent = ['"plugins":', ' - existing-plugin', 'custom:', ' taggedValue: keep-me'].join( - '\n' - ); + const yamlContent = [ + '"plugins":', + ' - existing-plugin', + 'custom:', + ' taggedValue: keep-me', + ].join('\n'); const expectedResult = { plugins: ['existing-plugin', 'foo'], custom: { From 1a6a70859889f99bbeb8b87bdab64b4f1bdce40d Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Thu, 23 Apr 2026 01:01:21 +0100 Subject: [PATCH 5/8] Make sure we are robust against invalid header names or non-lowercase --- lib/utils/serverless-utils/download.js | 4 +- .../utils/serverless-utils/download.test.js | 58 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/lib/utils/serverless-utils/download.js b/lib/utils/serverless-utils/download.js index 3659e162f4..f9d5f4e805 100644 --- a/lib/utils/serverless-utils/download.js +++ b/lib/utils/serverless-utils/download.js @@ -78,7 +78,8 @@ const fetchWithRedirects = async ( { headers, dispatcher, signal, maxRedirects = 10, allowedAuthRedirectHostnames = [] } = {} ) => { let currentUrl = new URL(requestUrl); - let currentHeaders = { ...(headers || {}) }; + // Use the platform header parser so redirect auth checks see normalized names and values. + let currentHeaders = Object.fromEntries(new globalThis.Headers(headers).entries()); const allowedAuthHostnames = new Set(allowedAuthRedirectHostnames); for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1) { @@ -103,6 +104,7 @@ const fetchWithRedirects = async ( } const nextUrl = new URL(location, currentUrl); + // Never forward credentials to a different origin unless the redirect target is allowlisted. if ( currentHeaders.authorization && nextUrl.origin !== currentUrl.origin && diff --git a/test/unit/lib/utils/serverless-utils/download.test.js b/test/unit/lib/utils/serverless-utils/download.test.js index c78a954a84..7d47405319 100644 --- a/test/unit/lib/utils/serverless-utils/download.test.js +++ b/test/unit/lib/utils/serverless-utils/download.test.js @@ -174,4 +174,62 @@ describe('serverless-utils/download', () => { ]); } }); + + it('normalizes and strips capitalized Authorization headers on disallowed cross-origin redirects', async () => { + let initialAuthorization; + let redirectedAuthorization; + + const redirectedServer = http.createServer((req, res) => { + redirectedAuthorization = req.headers.authorization; + res.statusCode = 200; + res.end('redirected payload'); + }); + + await new Promise((resolve) => redirectedServer.listen(0, '127.0.0.1', resolve)); + + const redirectingServer = http.createServer((req, res) => { + initialAuthorization = req.headers.authorization; + res.statusCode = 302; + res.setHeader('Location', `http://127.0.0.1:${redirectedServer.address().port}/final`); + res.end(); + }); + + await new Promise((resolve) => redirectingServer.listen(0, '127.0.0.1', resolve)); + + try { + const result = await download(`http://127.0.0.1:${redirectingServer.address().port}/start`, { + responseType: 'text', + headers: { + Authorization: '\tBearer token\t', + }, + }); + + expect(result).to.equal('redirected payload'); + expect(initialAuthorization).to.equal('Bearer token'); + expect(redirectedAuthorization).to.equal(undefined); + } finally { + await Promise.all([ + new Promise((resolve, reject) => { + redirectingServer.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }), + new Promise((resolve, reject) => { + redirectedServer.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }), + ]); + } + }); }); From 23d9c5e2652d439554473b6c629332512d78a019 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Thu, 23 Apr 2026 01:26:41 +0100 Subject: [PATCH 6/8] Applied more review feedback --- lib/utils/glob.js | 53 +++++++++-------- lib/utils/serverless-utils/download.js | 49 +++++++++------- test/unit/lib/utils/glob.test.js | 27 ++++++++- .../utils/serverless-utils/download.test.js | 58 +++++++++++++++++++ 4 files changed, 140 insertions(+), 47 deletions(-) diff --git a/lib/utils/glob.js b/lib/utils/glob.js index 314412c0d6..cc2568c09d 100644 --- a/lib/utils/glob.js +++ b/lib/utils/glob.js @@ -68,34 +68,39 @@ const normalizeOptions = (options = {}) => { const buildTasks = (patterns, globOptions) => { const tasks = []; - const leadingIgnore = []; - - for (const pattern of patterns) { - if (!pattern.startsWith('!')) { + let remainingPatterns = patterns; + + while (remainingPatterns.length > 0) { + const negativePatternIndex = remainingPatterns.findIndex((pattern) => pattern.startsWith('!')); + + if (negativePatternIndex === -1) { + tasks.push({ + patterns: remainingPatterns, + options: { + ...globOptions, + ignore: [...(globOptions.ignore || [])], + }, + }); break; } - leadingIgnore.push(pattern.slice(1)); - } + const ignorePattern = remainingPatterns[negativePatternIndex].slice(1); + + for (const task of tasks) { + task.options.ignore.push(ignorePattern); + } - for (let index = 0; index < patterns.length; index += 1) { - const pattern = patterns[index]; - if (pattern.startsWith('!')) { - continue; + if (negativePatternIndex !== 0) { + tasks.push({ + patterns: remainingPatterns.slice(0, negativePatternIndex), + options: { + ...globOptions, + ignore: [...(globOptions.ignore || []), ignorePattern], + }, + }); } - const ignore = patterns - .slice(index) - .filter((value) => value.startsWith('!')) - .map((value) => value.slice(1)); - - tasks.push({ - pattern, - options: { - ...globOptions, - ignore: [...(globOptions.ignore || []), ...(tasks.length ? [] : leadingIgnore), ...ignore], - }, - }); + remainingPatterns = remainingPatterns.slice(negativePatternIndex + 1); } return tasks; @@ -106,7 +111,7 @@ const collectResults = async (tasks) => { const results = []; for (const task of tasks) { - for (const entry of await fastGlob(task.pattern, task.options)) { + for (const entry of await fastGlob(task.patterns, task.options)) { if (seen.has(entry)) { continue; } @@ -124,7 +129,7 @@ const collectResultsSync = (tasks) => { const results = []; for (const task of tasks) { - for (const entry of fastGlob.sync(task.pattern, task.options)) { + for (const entry of fastGlob.sync(task.patterns, task.options)) { if (seen.has(entry)) { continue; } diff --git a/lib/utils/serverless-utils/download.js b/lib/utils/serverless-utils/download.js index f9d5f4e805..dc388a7ae2 100644 --- a/lib/utils/serverless-utils/download.js +++ b/lib/utils/serverless-utils/download.js @@ -94,27 +94,36 @@ const fetchWithRedirects = async ( return response; } - if (redirectCount === maxRedirects) { - throw new Error('Too many redirects'); + try { + if (redirectCount === maxRedirects) { + throw new Error('Too many redirects'); + } + + const location = response.headers.get('location'); + if (!location) { + throw new Error(`Redirect response missing location header: ${response.status}`); + } + + const nextUrl = new URL(location, currentUrl); + // Never forward credentials to a different origin unless the redirect target is allowlisted. + if ( + currentHeaders.authorization && + nextUrl.origin !== currentUrl.origin && + !allowedAuthHostnames.has(nextUrl.hostname) + ) { + currentHeaders = { ...currentHeaders }; + delete currentHeaders.authorization; + } + + currentUrl = nextUrl; + } finally { + // Manual redirects leave the intermediate response open unless we release it ourselves. + try { + await response.body?.cancel(); + } catch { + // Ignore cleanup errors and preserve the primary redirect failure, if any. + } } - - const location = response.headers.get('location'); - if (!location) { - throw new Error(`Redirect response missing location header: ${response.status}`); - } - - const nextUrl = new URL(location, currentUrl); - // Never forward credentials to a different origin unless the redirect target is allowlisted. - if ( - currentHeaders.authorization && - nextUrl.origin !== currentUrl.origin && - !allowedAuthHostnames.has(nextUrl.hostname) - ) { - currentHeaders = { ...currentHeaders }; - delete currentHeaders.authorization; - } - - currentUrl = nextUrl; } throw new Error('Too many redirects'); diff --git a/test/unit/lib/utils/glob.test.js b/test/unit/lib/utils/glob.test.js index edfccc248c..863c7bf92e 100644 --- a/test/unit/lib/utils/glob.test.js +++ b/test/unit/lib/utils/glob.test.js @@ -19,13 +19,34 @@ describe('test/unit/lib/utils/glob.test.js', () => { await fse.remove(tmpDir); }); - it('honors leading negation patterns for async and sync calls', async () => { + it('matches globby order-sensitive leading negation behavior for async and sync calls', async () => { await Promise.all([ fse.outputFile(path.join(tmpDir, 'keep.js'), 'keep\n'), + fse.outputFile(path.join(tmpDir, 'keep.ts'), 'keep\n'), fse.outputFile(path.join(tmpDir, 'ignored', 'drop.js'), 'drop\n'), + fse.outputFile(path.join(tmpDir, 'ignored', 'drop.ts'), 'drop\n'), ]); - expect(await glob(['!ignored/**/*', '**/*.js'], { cwd: tmpDir })).to.deep.equal(['keep.js']); - expect(glob.sync(['!ignored/**/*', '**/*.js'], { cwd: tmpDir })).to.deep.equal(['keep.js']); + expect( + (await glob(['!ignored/**/*', '**/*.js', '**/*.ts'], { cwd: tmpDir })).sort() + ).to.deep.equal(['ignored/drop.js', 'ignored/drop.ts', 'keep.js', 'keep.ts']); + expect( + glob.sync(['!ignored/**/*', '**/*.js', '**/*.ts'], { cwd: tmpDir }).sort() + ).to.deep.equal(['ignored/drop.js', 'ignored/drop.ts', 'keep.js', 'keep.ts']); + }); + + it('applies later negations to earlier positives and still supports re-inclusion', async () => { + await Promise.all([ + fse.outputFile(path.join(tmpDir, 'keep.js'), 'keep\n'), + fse.outputFile(path.join(tmpDir, 'ignored', 'drop.js'), 'drop\n'), + fse.outputFile(path.join(tmpDir, 'ignored', 'reinclude.js'), 'reinclude\n'), + ]); + + expect( + (await glob(['**/*.js', '!ignored/**/*', 'ignored/reinclude.js'], { cwd: tmpDir })).sort() + ).to.deep.equal(['ignored/reinclude.js', 'keep.js']); + expect( + glob.sync(['**/*.js', '!ignored/**/*', 'ignored/reinclude.js'], { cwd: tmpDir }).sort() + ).to.deep.equal(['ignored/reinclude.js', 'keep.js']); }); }); diff --git a/test/unit/lib/utils/serverless-utils/download.test.js b/test/unit/lib/utils/serverless-utils/download.test.js index 7d47405319..6e30e344d4 100644 --- a/test/unit/lib/utils/serverless-utils/download.test.js +++ b/test/unit/lib/utils/serverless-utils/download.test.js @@ -232,4 +232,62 @@ describe('serverless-utils/download', () => { ]); } }); + + it('cancels redirect bodies before following the next hop', async () => { + const originalFetch = globalThis.fetch; + const events = []; + + const redirectBody = { + cancel: async () => { + events.push('cancel-redirect'); + }, + }; + const finalBody = { + cancel: async () => { + events.push('cancel-final'); + }, + }; + + globalThis.fetch = async (url) => { + if (String(url) === 'http://example.com/start') { + events.push('fetch-redirect'); + return { + status: 302, + headers: new globalThis.Headers({ location: 'http://example.com/final' }), + body: redirectBody, + }; + } + + if (String(url) === 'http://example.com/final') { + events.push('fetch-final'); + return { + ok: true, + status: 200, + url: 'http://example.com/final', + headers: new globalThis.Headers({ 'content-type': 'text/plain' }), + body: finalBody, + arrayBuffer: async () => { + events.push('read-final'); + return Buffer.from('ok'); + }, + }; + } + + throw new Error(`Unexpected fetch URL: ${String(url)}`); + }; + + try { + const result = await download('http://example.com/start', { responseType: 'text' }); + + expect(result).to.equal('ok'); + expect(events).to.deep.equal([ + 'fetch-redirect', + 'cancel-redirect', + 'fetch-final', + 'read-final', + ]); + } finally { + globalThis.fetch = originalFetch; + } + }); }); From 4cdce5e3d95ee821b3bae32f03ce97a81e9caa34 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Thu, 23 Apr 2026 01:29:46 +0100 Subject: [PATCH 7/8] Completed rename of globby to glob --- .../aws/deploy/lib/check-for-changes.js | 4 +-- lib/plugins/package/lib/package-service.js | 4 +-- lib/plugins/package/lib/zip-service.js | 4 +-- .../aws/custom-resources/generate-zip.test.js | 4 +-- .../aws/deploy/lib/check-for-changes.test.js | 14 ++++---- .../plugins/package/lib/zip-service.test.js | 32 +++++++++---------- 6 files changed, 31 insertions(+), 31 deletions(-) diff --git a/lib/plugins/aws/deploy/lib/check-for-changes.js b/lib/plugins/aws/deploy/lib/check-for-changes.js index 0d89201792..0359e5428a 100644 --- a/lib/plugins/aws/deploy/lib/check-for-changes.js +++ b/lib/plugins/aws/deploy/lib/check-for-changes.js @@ -3,7 +3,7 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); -const globby = require('../../../../utils/glob'); +const glob = require('../../../../utils/glob'); const BbPromise = require('bluebird'); const _ = require('lodash'); const normalizeFiles = require('../../lib/normalize-files'); @@ -191,7 +191,7 @@ module.exports = { ]); // create hashes for all the zip files - const zipFiles = globby + const zipFiles = glob .sync(['**.zip'], { cwd: serverlessDirPath, dot: true, silent: true }) .filter((basename) => !isOtelExtensionName(basename)); if (this.serverless.service.package.artifact) { diff --git a/lib/plugins/package/lib/package-service.js b/lib/plugins/package/lib/package-service.js index f330c6e9b2..ef4570aafe 100644 --- a/lib/plugins/package/lib/package-service.js +++ b/lib/plugins/package/lib/package-service.js @@ -2,7 +2,7 @@ const path = require('path'); const fsp = require('fs').promises; -const globby = require('../../../utils/glob'); +const glob = require('../../../utils/glob'); const _ = require('lodash'); const micromatch = require('micromatch'); const ServerlessError = require('../../../serverless-error'); @@ -289,7 +289,7 @@ module.exports = { // NOTE: please keep this order of concatenating the include params // rather than doing it the other way round! // see https://github.com/serverless/serverless/pull/5825 for more information - return globby(['**'].concat(params.include), { + return glob(['**'].concat(params.include), { cwd: path.join(this.serverless.serviceDir, prefix || ''), dot: true, silent: true, diff --git a/lib/plugins/package/lib/zip-service.js b/lib/plugins/package/lib/zip-service.js index 092039a7a0..ea06a65122 100644 --- a/lib/plugins/package/lib/zip-service.js +++ b/lib/plugins/package/lib/zip-service.js @@ -7,7 +7,7 @@ const path = require('path'); const crypto = require('crypto'); const fs = BbPromise.promisifyAll(require('fs')); const childProcess = BbPromise.promisifyAll(require('child_process')); -const globby = require('../../../utils/glob'); +const glob = require('../../../utils/glob'); const _ = require('lodash'); const ServerlessError = require('../../../serverless-error'); const { log } = require('../../../utils/serverless-utils/log'); @@ -149,7 +149,7 @@ async function excludeNodeDevDependencies(serviceDir) { const nodeProdDepFile = path.join(tmpDir, `node-dependencies-${randHash}-prod`); try { - const packageJsonFilePaths = globby.sync( + const packageJsonFilePaths = glob.sync( [ '**/package.json', // TODO add glob for node_modules filtering diff --git a/test/unit/lib/plugins/aws/custom-resources/generate-zip.test.js b/test/unit/lib/plugins/aws/custom-resources/generate-zip.test.js index c51d17dfbe..cd8fb5724d 100644 --- a/test/unit/lib/plugins/aws/custom-resources/generate-zip.test.js +++ b/test/unit/lib/plugins/aws/custom-resources/generate-zip.test.js @@ -1,7 +1,7 @@ 'use strict'; const path = require('path'); -const globby = require('../../../../../../lib/utils/glob'); +const glob = require('../../../../../../lib/utils/glob'); const requireUncached = require('ncjsm/require-uncached'); const { listZipFiles } = require('../../../../../utils/fs'); const { expect } = require('chai'); @@ -22,7 +22,7 @@ describe('test/unit/lib/plugins/aws/customResources/generateZip.test.js', () => // List the files in the zip to make sure it is valid const filesInZip = await listZipFiles(zipFilePath); - const filesInResourceDir = await globby('**', { cwd: resourcesDir }); + const filesInResourceDir = await glob('**', { cwd: resourcesDir }); expect(filesInZip).to.have.all.members(filesInResourceDir); }); }); diff --git a/test/unit/lib/plugins/aws/deploy/lib/check-for-changes.test.js b/test/unit/lib/plugins/aws/deploy/lib/check-for-changes.test.js index ae70c769f8..4d46fd6870 100644 --- a/test/unit/lib/plugins/aws/deploy/lib/check-for-changes.test.js +++ b/test/unit/lib/plugins/aws/deploy/lib/check-for-changes.test.js @@ -5,7 +5,7 @@ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); -const globby = require('../../../../../../../lib/utils/glob'); +const glob = require('../../../../../../../lib/utils/glob'); const sandbox = require('sinon'); const proxyquire = require('proxyquire'); const normalizeFiles = require('../../../../../../../lib/plugins/aws/lib/normalize-files'); @@ -221,14 +221,14 @@ describe('checkForChanges', () => { describe('#checkIfDeploymentIsNecessary()', () => { let normalizeCloudFormationTemplateStub; - let globbySyncStub; + let globSyncStub; let readFileStub; beforeEach(async () => { normalizeCloudFormationTemplateStub = sandbox .stub(normalizeFiles, 'normalizeCloudFormationTemplate') .returns(); - globbySyncStub = sandbox.stub(globby, 'sync'); + globSyncStub = sandbox.stub(glob, 'sync'); readFileStub = sandbox .stub(fsp, 'readFile') .returns(Promise.resolve('{"service":{"provider":{}},"package":{}}')); @@ -236,14 +236,14 @@ describe('checkForChanges', () => { afterEach(() => { normalizeFiles.normalizeCloudFormationTemplate.restore(); - globby.sync.restore(); + glob.sync.restore(); fsp.readFile.restore(); }); it('should resolve if no input is provided', async () => expect(awsDeploy.checkIfDeploymentIsNecessary([])).to.be.fulfilled.then(() => { expect(normalizeCloudFormationTemplateStub).to.not.have.been.called; - expect(globbySyncStub).to.not.have.been.called; + expect(globSyncStub).to.not.have.been.called; expect(readFileStub).to.not.have.been.called; })); @@ -252,7 +252,7 @@ describe('checkForChanges', () => { return expect(awsDeploy.checkIfDeploymentIsNecessary(input)).to.be.fulfilled.then(() => { expect(normalizeCloudFormationTemplateStub).to.not.have.been.called; - expect(globbySyncStub).to.not.have.been.called; + expect(globSyncStub).to.not.have.been.called; expect(readFileStub).to.not.have.been.called; }); }); @@ -631,7 +631,7 @@ const commonAwsSdkMock = { const generateMatchingListObjectsResponse = async (serverless) => { const packagePath = path.resolve(serverless.serviceDir, '.serverless'); - const artifactNames = (await globby('*.zip', { cwd: packagePath })).map((filename) => + const artifactNames = (await glob('*.zip', { cwd: packagePath })).map((filename) => path.basename(filename) ); artifactNames.push('compiled-cloudformation-template.json', 'serverless-state.json'); diff --git a/test/unit/lib/plugins/package/lib/zip-service.test.js b/test/unit/lib/plugins/package/lib/zip-service.test.js index 4a3e521704..7137bfbb75 100644 --- a/test/unit/lib/plugins/package/lib/zip-service.test.js +++ b/test/unit/lib/plugins/package/lib/zip-service.test.js @@ -5,7 +5,7 @@ const os = require('os'); const path = require('path'); const JsZip = require('jszip'); -const globby = require('../../../../../../lib/utils/glob'); +const glob = require('../../../../../../lib/utils/glob'); const _ = require('lodash'); const BbPromise = require('bluebird'); const fs = BbPromise.promisifyAll(require('fs')); @@ -120,20 +120,20 @@ describe('zipService', () => { }); describe('when dealing with Node.js runtimes', () => { - let globbySyncStub; + let globSyncStub; let execAsyncStub; let readFileAsyncStub; let serviceDir; beforeEach(() => { serviceDir = packagePlugin.serverless.serviceDir; - globbySyncStub = sinon.stub(globby, 'sync'); + globSyncStub = sinon.stub(glob, 'sync'); execAsyncStub = sinon.stub(childProcess, 'execAsync'); readFileAsyncStub = sinon.stub(fs, 'readFileAsync'); }); afterEach(() => { - globby.sync.restore(); + glob.sync.restore(); childProcess.execAsync.restore(); fs.readFileAsync.restore(); }); @@ -141,14 +141,14 @@ describe('zipService', () => { it('should do nothing if no packages are used', async () => { const filePaths = []; - globbySyncStub.returns(filePaths); + globSyncStub.returns(filePaths); return expect(packagePlugin.excludeDevDependencies(params)).to.be.fulfilled.then( (updatedParams) => { - expect(globbySyncStub).to.have.been.calledOnce; + expect(globSyncStub).to.have.been.calledOnce; expect(execAsyncStub).to.not.have.been.called; expect(readFileAsyncStub).to.not.have.been.called; - expect(globbySyncStub).to.have.been.calledWithExactly(['**/package.json'], { + expect(globSyncStub).to.have.been.calledWithExactly(['**/package.json'], { cwd: packagePlugin.serverless.serviceDir, dot: true, silent: true, @@ -165,17 +165,17 @@ describe('zipService', () => { it('should do nothing if no dependencies are found', async () => { const filePaths = ['package.json', 'node_modules']; - globbySyncStub.returns(filePaths); + globSyncStub.returns(filePaths); execAsyncStub.resolves(); const depPaths = ''; readFileAsyncStub.resolves(depPaths); return expect(packagePlugin.excludeDevDependencies(params)).to.be.fulfilled.then( (updatedParams) => { - expect(globbySyncStub).to.have.been.calledOnce; + expect(globSyncStub).to.have.been.calledOnce; expect(execAsyncStub).to.have.been.calledTwice; expect(readFileAsyncStub).to.have.been.calledTwice; - expect(globbySyncStub).to.have.been.calledWithExactly(['**/package.json'], { + expect(globSyncStub).to.have.been.calledWithExactly(['**/package.json'], { cwd: packagePlugin.serverless.serviceDir, dot: true, silent: true, @@ -198,11 +198,11 @@ describe('zipService', () => { }); it('should return excludes and includes if an error is thrown in the global scope', () => { - globbySyncStub.throws(); + globSyncStub.throws(); return expect(packagePlugin.excludeDevDependencies(params)).to.be.fulfilled.then( (updatedParams) => { - expect(globbySyncStub).to.have.been.calledOnce; + expect(globSyncStub).to.have.been.calledOnce; expect(execAsyncStub).to.not.have.been.called; expect(readFileAsyncStub).to.not.have.been.called; expect(updatedParams.exclude).to.deep.equal(['user-defined-exclude-me']); @@ -215,14 +215,14 @@ describe('zipService', () => { it('should return excludes and includes if a exec Promise is rejected', async () => { const filePaths = ['package.json', 'node_modules']; - globbySyncStub.returns(filePaths); + globSyncStub.returns(filePaths); execAsyncStub.onCall(0).resolves(); execAsyncStub.onCall(1).rejects(); readFileAsyncStub.resolves(); return expect(packagePlugin.excludeDevDependencies(params)).to.be.fulfilled.then( (updatedParams) => { - expect(globbySyncStub).to.been.calledOnce; + expect(globSyncStub).to.been.calledOnce; expect(execAsyncStub).to.have.been.calledTwice; expect(readFileAsyncStub).to.have.been.calledTwice; expect(updatedParams.exclude).to.deep.equal(['user-defined-exclude-me']); @@ -235,7 +235,7 @@ describe('zipService', () => { it('should return excludes and includes if a readFile Promise is rejected', async () => { const filePaths = ['package.json', 'node_modules']; - globbySyncStub.returns(filePaths); + globSyncStub.returns(filePaths); execAsyncStub.resolves(); readFileAsyncStub.onCall(0).resolves(); @@ -243,7 +243,7 @@ describe('zipService', () => { return expect(packagePlugin.excludeDevDependencies(params)).to.be.fulfilled.then( (updatedParams) => { - expect(globbySyncStub).to.been.calledOnce; + expect(globSyncStub).to.been.calledOnce; expect(execAsyncStub).to.have.been.calledTwice; expect(readFileAsyncStub).to.have.been.calledTwice; expect(updatedParams.exclude).to.deep.equal(['user-defined-exclude-me']); From d4d60e73dd907921175e82ca0abd36903275c6e4 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Thu, 23 Apr 2026 01:30:07 +0100 Subject: [PATCH 8/8] Updated tests to match too --- .../aws/deploy/lib/check-for-changes.test.js | 54 +++++++++---------- .../plugins/package/lib/zip-service.test.js | 34 ++++++------ 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/test/unit/lib/plugins/aws/deploy/lib/check-for-changes.test.js b/test/unit/lib/plugins/aws/deploy/lib/check-for-changes.test.js index 4d46fd6870..bfc4adb958 100644 --- a/test/unit/lib/plugins/aws/deploy/lib/check-for-changes.test.js +++ b/test/unit/lib/plugins/aws/deploy/lib/check-for-changes.test.js @@ -258,18 +258,18 @@ describe('checkForChanges', () => { }); it('should resolve if objects are given, but no function last modified date', async () => { - globbySyncStub.returns(['my-service.zip']); + globSyncStub.returns(['my-service.zip']); cryptoStub.createHash().update().digest.onCall(0).returns('local-hash-cf-template'); const input = [{ Metadata: { filesha256: 'remote-hash-cf-template' } }]; await awsDeploy.checkIfDeploymentIsNecessary(input); expect(normalizeCloudFormationTemplateStub).to.have.been.calledOnce; - expect(globbySyncStub).to.have.been.calledOnce; + expect(globSyncStub).to.have.been.calledOnce; expect(normalizeCloudFormationTemplateStub).to.have.been.calledWithExactly( awsDeploy.serverless.service.provider.compiledCloudFormationTemplate ); - expect(globbySyncStub).to.have.been.calledWithExactly(['**.zip'], { + expect(globSyncStub).to.have.been.calledWithExactly(['**.zip'], { cwd: path.join(awsDeploy.serverless.serviceDir, '.serverless'), dot: true, silent: true, @@ -281,7 +281,7 @@ describe('checkForChanges', () => { }); it('should not set a flag if there are more remote hashes', async () => { - globbySyncStub.returns(['my-service.zip']); + globSyncStub.returns(['my-service.zip']); cryptoStub.createHash().update().digest.onCall(0).returns('local-hash-cf-template'); cryptoStub.createHash().update().digest.onCall(1).returns('local-hash-zip-file-1'); @@ -297,11 +297,11 @@ describe('checkForChanges', () => { return expect(awsDeploy.checkIfDeploymentIsNecessary(input)).to.be.fulfilled.then(() => { expect(normalizeCloudFormationTemplateStub).to.have.been.calledOnce; - expect(globbySyncStub).to.have.been.calledOnce; + expect(globSyncStub).to.have.been.calledOnce; expect(normalizeCloudFormationTemplateStub).to.have.been.calledWithExactly( awsDeploy.serverless.service.provider.compiledCloudFormationTemplate ); - expect(globbySyncStub).to.have.been.calledWithExactly(['**.zip'], { + expect(globSyncStub).to.have.been.calledWithExactly(['**.zip'], { cwd: path.join(awsDeploy.serverless.serviceDir, '.serverless'), dot: true, silent: true, @@ -314,7 +314,7 @@ describe('checkForChanges', () => { }); it('should not set a flag if remote and local hashes are different', async () => { - globbySyncStub.returns(['my-service.zip']); + globSyncStub.returns(['my-service.zip']); cryptoStub.createHash().update().digest.onCall(0).returns('local-hash-cf-template'); cryptoStub.createHash().update().digest.onCall(1).returns('local-hash-zip-file-1'); @@ -325,11 +325,11 @@ describe('checkForChanges', () => { return expect(awsDeploy.checkIfDeploymentIsNecessary(input)).to.be.fulfilled.then(() => { expect(normalizeCloudFormationTemplateStub).to.have.been.calledOnce; - expect(globbySyncStub).to.have.been.calledOnce; + expect(globSyncStub).to.have.been.calledOnce; expect(normalizeCloudFormationTemplateStub).to.have.been.calledWithExactly( awsDeploy.serverless.service.provider.compiledCloudFormationTemplate ); - expect(globbySyncStub).to.have.been.calledWithExactly(['**.zip'], { + expect(globSyncStub).to.have.been.calledWithExactly(['**.zip'], { cwd: path.join(awsDeploy.serverless.serviceDir, '.serverless'), dot: true, silent: true, @@ -342,7 +342,7 @@ describe('checkForChanges', () => { }); it('should not set a flag if remote and local hashes are the same but are duplicated', async () => { - globbySyncStub.returns(['func1.zip', 'func2.zip']); + globSyncStub.returns(['func1.zip', 'func2.zip']); cryptoStub.createHash().update().digest.onCall(0).returns('remote-hash-cf-template'); // happens when package.individually is used cryptoStub.createHash().update().digest.onCall(1).returns('remote-hash-zip-file-1'); @@ -355,11 +355,11 @@ describe('checkForChanges', () => { return expect(awsDeploy.checkIfDeploymentIsNecessary(input)).to.be.fulfilled.then(() => { expect(normalizeCloudFormationTemplateStub).to.have.been.calledOnce; - expect(globbySyncStub).to.have.been.calledOnce; + expect(globSyncStub).to.have.been.calledOnce; expect(normalizeCloudFormationTemplateStub).to.have.been.calledWithExactly( awsDeploy.serverless.service.provider.compiledCloudFormationTemplate ); - expect(globbySyncStub).to.have.been.calledWithExactly(['**.zip'], { + expect(globSyncStub).to.have.been.calledWithExactly(['**.zip'], { cwd: path.join(awsDeploy.serverless.serviceDir, '.serverless'), dot: true, silent: true, @@ -375,7 +375,7 @@ describe('checkForChanges', () => { }); it('should not set a flag if the hashes are equal, but the objects were modified after their functions', async () => { - globbySyncStub.returns(['my-service.zip']); + globSyncStub.returns(['my-service.zip']); cryptoStub.createHash().update().digest.onCall(0).returns('hash-cf-template'); cryptoStub.createHash().update().digest.onCall(1).returns('hash-zip-file-1'); @@ -390,11 +390,11 @@ describe('checkForChanges', () => { return expect(awsDeploy.checkIfDeploymentIsNecessary(input, now)).to.be.fulfilled.then(() => { expect(normalizeCloudFormationTemplateStub).to.have.been.calledOnce; - expect(globbySyncStub).to.have.been.calledOnce; + expect(globSyncStub).to.have.been.calledOnce; expect(normalizeCloudFormationTemplateStub).to.have.been.calledWithExactly( awsDeploy.serverless.service.provider.compiledCloudFormationTemplate ); - expect(globbySyncStub).to.have.been.calledWithExactly(['**.zip'], { + expect(globSyncStub).to.have.been.calledWithExactly(['**.zip'], { cwd: path.join(awsDeploy.serverless.serviceDir, '.serverless'), dot: true, silent: true, @@ -407,7 +407,7 @@ describe('checkForChanges', () => { }); it('should set a flag if the remote and local hashes are equal', async () => { - globbySyncStub.returns(['my-service.zip']); + globSyncStub.returns(['my-service.zip']); cryptoStub.createHash().update().digest.onCall(0).returns('hash-cf-template'); cryptoStub.createHash().update().digest.onCall(1).returns('hash-state'); cryptoStub.createHash().update().digest.onCall(2).returns('hash-zip-file-1'); @@ -421,11 +421,11 @@ describe('checkForChanges', () => { return expect(awsDeploy.checkIfDeploymentIsNecessary(input)).to.be.fulfilled.then(() => { expect(normalizeCloudFormationTemplateStub).to.have.been.calledOnce; - expect(globbySyncStub).to.have.been.calledOnce; + expect(globSyncStub).to.have.been.calledOnce; expect(normalizeCloudFormationTemplateStub).to.have.been.calledWithExactly( awsDeploy.serverless.service.provider.compiledCloudFormationTemplate ); - expect(globbySyncStub).to.have.been.calledWithExactly(['**.zip'], { + expect(globSyncStub).to.have.been.calledWithExactly(['**.zip'], { cwd: path.join(awsDeploy.serverless.serviceDir, '.serverless'), dot: true, silent: true, @@ -438,7 +438,7 @@ describe('checkForChanges', () => { }); it('should set a flag if the remote and local hashes are equal, and the edit times are ordered', async () => { - globbySyncStub.returns(['my-service.zip']); + globSyncStub.returns(['my-service.zip']); cryptoStub.createHash().update().digest.onCall(0).returns('hash-cf-template'); cryptoStub.createHash().update().digest.onCall(1).returns('hash-state'); cryptoStub.createHash().update().digest.onCall(2).returns('hash-zip-file-1'); @@ -468,11 +468,11 @@ describe('checkForChanges', () => { return expect(awsDeploy.checkIfDeploymentIsNecessary(input, longAgo)).to.be.fulfilled.then( () => { expect(normalizeCloudFormationTemplateStub).to.have.been.calledOnce; - expect(globbySyncStub).to.have.been.calledOnce; + expect(globSyncStub).to.have.been.calledOnce; expect(normalizeCloudFormationTemplateStub).to.have.been.calledWithExactly( awsDeploy.serverless.service.provider.compiledCloudFormationTemplate ); - expect(globbySyncStub).to.have.been.calledWithExactly(['**.zip'], { + expect(globSyncStub).to.have.been.calledWithExactly(['**.zip'], { cwd: path.join(awsDeploy.serverless.serviceDir, '.serverless'), dot: true, silent: true, @@ -486,7 +486,7 @@ describe('checkForChanges', () => { }); it('should set a flag if the remote and local hashes are duplicated and equal', async () => { - globbySyncStub.returns(['func1.zip', 'func2.zip']); + globSyncStub.returns(['func1.zip', 'func2.zip']); cryptoStub.createHash().update().digest.onCall(0).returns('hash-cf-template'); cryptoStub.createHash().update().digest.onCall(1).returns('hash-state'); // happens when package.individually is used @@ -503,11 +503,11 @@ describe('checkForChanges', () => { return expect(awsDeploy.checkIfDeploymentIsNecessary(input)).to.be.fulfilled.then(() => { expect(normalizeCloudFormationTemplateStub).to.have.been.calledOnce; - expect(globbySyncStub).to.have.been.calledOnce; + expect(globSyncStub).to.have.been.calledOnce; expect(normalizeCloudFormationTemplateStub).to.have.been.calledWithExactly( awsDeploy.serverless.service.provider.compiledCloudFormationTemplate ); - expect(globbySyncStub).to.have.been.calledWithExactly(['**.zip'], { + expect(globSyncStub).to.have.been.calledWithExactly(['**.zip'], { cwd: path.join(awsDeploy.serverless.serviceDir, '.serverless'), dot: true, silent: true, @@ -527,7 +527,7 @@ describe('checkForChanges', () => { artifact: 'foo/bar/my-own.zip', }; - globbySyncStub.returns([]); + globSyncStub.returns([]); cryptoStub.createHash().update().digest.onCall(0).returns('hash-cf-template'); cryptoStub.createHash().update().digest.onCall(1).returns('hash-state'); cryptoStub.createHash().update().digest.onCall(2).returns('local-my-own-hash'); @@ -541,11 +541,11 @@ describe('checkForChanges', () => { return expect(awsDeploy.checkIfDeploymentIsNecessary(input)).to.be.fulfilled.then(() => { expect(normalizeCloudFormationTemplateStub).to.have.been.calledOnce; - expect(globbySyncStub).to.have.been.calledOnce; + expect(globSyncStub).to.have.been.calledOnce; expect(normalizeCloudFormationTemplateStub).to.have.been.calledWithExactly( awsDeploy.serverless.service.provider.compiledCloudFormationTemplate ); - expect(globbySyncStub).to.have.been.calledWithExactly(['**.zip'], { + expect(globSyncStub).to.have.been.calledWithExactly(['**.zip'], { cwd: path.join(awsDeploy.serverless.serviceDir, '.serverless'), dot: true, silent: true, diff --git a/test/unit/lib/plugins/package/lib/zip-service.test.js b/test/unit/lib/plugins/package/lib/zip-service.test.js index 7137bfbb75..c17e2c90a9 100644 --- a/test/unit/lib/plugins/package/lib/zip-service.test.js +++ b/test/unit/lib/plugins/package/lib/zip-service.test.js @@ -267,7 +267,7 @@ describe('zipService', () => { path.join('1st', '2nd', 'node_modules'), ]; - globbySyncStub.returns(filePaths); + globSyncStub.returns(filePaths); execAsyncStub.onCall(0).resolves(); execAsyncStub.onCall(1).resolves(); execAsyncStub.onCall(2).rejects(); @@ -289,7 +289,7 @@ describe('zipService', () => { return expect(packagePlugin.excludeDevDependencies(params)).to.be.fulfilled.then( (updatedParams) => { - expect(globbySyncStub).to.have.been.calledOnce; + expect(globSyncStub).to.have.been.calledOnce; expect(execAsyncStub.callCount).to.equal(6); expect(readFileAsyncStub).to.have.callCount(6); expect(readFileAsyncStub).to.have.been.calledWith( @@ -304,7 +304,7 @@ describe('zipService', () => { expect(readFileAsyncStub).to.have.been.calledWith( path.join(serviceDir, '1st', '2nd', 'node_modules', 'module-1', 'package.json') ); - expect(globbySyncStub).to.have.been.calledWithExactly(['**/package.json'], { + expect(globSyncStub).to.have.been.calledWithExactly(['**/package.json'], { cwd: packagePlugin.serverless.serviceDir, dot: true, silent: true, @@ -351,7 +351,7 @@ describe('zipService', () => { it('should exclude dev dependencies in the services root directory', async () => { const filePaths = ['package.json', 'node_modules']; - globbySyncStub.returns(filePaths); + globSyncStub.returns(filePaths); execAsyncStub.resolves(); const depPaths = [ path.join(serviceDir, 'node_modules', 'module-1'), @@ -364,7 +364,7 @@ describe('zipService', () => { return expect(packagePlugin.excludeDevDependencies(params)).to.be.fulfilled.then( (updatedParams) => { - expect(globbySyncStub).to.have.been.calledOnce; + expect(globSyncStub).to.have.been.calledOnce; expect(execAsyncStub).to.have.been.calledTwice; expect(readFileAsyncStub).to.have.callCount(4); expect(readFileAsyncStub).to.have.been.calledWith( @@ -373,7 +373,7 @@ describe('zipService', () => { expect(readFileAsyncStub).to.have.been.calledWith( path.join(serviceDir, 'node_modules', 'module-2', 'package.json') ); - expect(globbySyncStub).to.have.been.calledWithExactly(['**/package.json'], { + expect(globSyncStub).to.have.been.calledWithExactly(['**/package.json'], { cwd: packagePlugin.serverless.serviceDir, dot: true, silent: true, @@ -412,7 +412,7 @@ describe('zipService', () => { path.join('1st', '2nd', 'node_modules'), ]; - globbySyncStub.returns(filePaths); + globSyncStub.returns(filePaths); execAsyncStub.resolves(); const depPaths = [ path.join(serviceDir, 'node_modules', 'module-1'), @@ -433,10 +433,10 @@ describe('zipService', () => { return expect(packagePlugin.excludeDevDependencies(params)).to.be.fulfilled.then( (updatedParams) => { - expect(globbySyncStub).to.have.been.calledOnce; + expect(globSyncStub).to.have.been.calledOnce; expect(execAsyncStub.callCount).to.equal(6); expect(readFileAsyncStub).to.have.callCount(8); - expect(globbySyncStub).to.have.been.calledWithExactly(['**/package.json'], { + expect(globSyncStub).to.have.been.calledWithExactly(['**/package.json'], { cwd: packagePlugin.serverless.serviceDir, dot: true, silent: true, @@ -485,7 +485,7 @@ describe('zipService', () => { it('should not include packages if in both dependencies and devDependencies', async () => { const filePaths = ['package.json', 'node_modules']; - globbySyncStub.returns(filePaths); + globSyncStub.returns(filePaths); execAsyncStub.resolves(); const devDepPaths = [ @@ -500,13 +500,13 @@ describe('zipService', () => { return expect(packagePlugin.excludeDevDependencies(params)).to.be.fulfilled.then( (updatedParams) => { - expect(globbySyncStub).to.have.been.calledOnce; + expect(globSyncStub).to.have.been.calledOnce; expect(execAsyncStub).to.have.been.calledTwice; expect(readFileAsyncStub).to.have.been.calledThrice; expect(readFileAsyncStub).to.have.been.calledWith( path.join(serviceDir, 'node_modules', 'module-1', 'package.json') ); - expect(globbySyncStub).to.have.been.calledWithExactly(['**/package.json'], { + expect(globSyncStub).to.have.been.calledWithExactly(['**/package.json'], { cwd: packagePlugin.serverless.serviceDir, dot: true, silent: true, @@ -543,7 +543,7 @@ describe('zipService', () => { const filePaths = ['node_modules/', 'package.json'].concat(devPaths).concat(prodPaths); - globbySyncStub.returns(filePaths); + globSyncStub.returns(filePaths); execAsyncStub.resolves(); const mapper = (depPath) => path.join(`${serviceDir}`, depPath); @@ -565,7 +565,7 @@ describe('zipService', () => { return expect(packagePlugin.excludeDevDependencies(params)).to.be.fulfilled.then( (updatedParams) => { - expect(globbySyncStub).to.been.calledOnce; + expect(globSyncStub).to.been.calledOnce; expect(execAsyncStub).to.have.been.calledTwice; expect(readFileAsyncStub).to.have.callCount(5); @@ -605,7 +605,7 @@ describe('zipService', () => { path.join('1st', '2nd', 'node_modules'), ]; - globbySyncStub.returns(filePaths); + globSyncStub.returns(filePaths); execAsyncStub.resolves(); const deps = [ 'node_modules/module-1', @@ -639,7 +639,7 @@ describe('zipService', () => { return expect(packagePlugin.excludeDevDependencies(params)).to.be.fulfilled.then( (updatedParams) => { - expect(globbySyncStub).to.have.been.calledOnce; + expect(globSyncStub).to.have.been.calledOnce; expect(execAsyncStub.callCount).to.equal(6); expect(readFileAsyncStub).to.have.callCount(8); for (const depPath of deps) { @@ -647,7 +647,7 @@ describe('zipService', () => { path.join(serviceDir, depPath, 'package.json') ); } - expect(globbySyncStub).to.have.been.calledWithExactly(['**/package.json'], { + expect(globSyncStub).to.have.been.calledWithExactly(['**/package.json'], { cwd: packagePlugin.serverless.serviceDir, dot: true, silent: true,