diff --git a/lib/dependency-manager-adapters/pnpm.js b/lib/dependency-manager-adapters/pnpm.js index df64d64b..6b27d0f9 100644 --- a/lib/dependency-manager-adapters/pnpm.js +++ b/lib/dependency-manager-adapters/pnpm.js @@ -4,10 +4,16 @@ const CoreObject = require('core-object'); const fs = require('fs-extra'); const path = require('path'); const debug = require('debug')('ember-try:dependency-manager-adapter:pnpm'); +const { promisify } = require('util'); +const exec = promisify(require('child_process').exec); +const semverLt = require('semver/functions/lt'); +const semverGte = require('semver/functions/gte'); const PACKAGE_JSON = 'package.json'; const PACKAGE_JSON_BACKUP = 'package.json.ember-try'; const PNPM_LOCKFILE = 'pnpm-lock.yaml'; +const NPMRC_CONFIG = '.npmrc'; +const NPMRC_CONFIG_BACKUP = '.npmrc.ember-try'; // Note: the upstream convention is to append `.ember-try` _after_ the file // extension, however this breaks syntax highlighting, so I've chosen to @@ -41,6 +47,7 @@ module.exports = CoreObject.extend({ async changeToDependencySet(depSet) { await this.applyDependencySet(depSet); + await this._updateNpmRc(); await this._install(depSet); let deps = Object.assign({}, depSet.dependencies, depSet.devDependencies); @@ -73,6 +80,7 @@ module.exports = CoreObject.extend({ await fs.remove(lockFileBackup); await this._install(); + await this._revertNpmRc(); } catch (e) { console.log('Error cleaning up scenario:', e); // eslint-disable-line no-console } @@ -87,6 +95,89 @@ module.exports = CoreObject.extend({ } }, + /** + * pnpm versions 8.0.0 through 8.6.* have the `resolution-mode` setting inverted to + * `lowest-direct`, which breaks ember-try. This method conditionally adds the necessary config to + * .npmrc to fix this. + * + * It covers the following cases: + * - pnpm version is out of dangerious range or cannot be retrieved — do not do anything + * - pnpm version is within dangerous range and .npmrc does not exist — create .npmrc with + * `resolution-mode = highest` + * - pnpm version is within dangerous range and .npmrc exists — backup the .npmrc file and + * append` resolution-mode = highest` to .npmrc + * + * @param {undefined | string} version — this is only used in testing. Call this fucntion without + * arguments + * @returns Promise + */ + async _updateNpmRc(version) { + if (!version) { + version = await this._getPnpmVersion(); + } + + if (!this._doesPnpmRequireResolutionModeFix(version)) { + return; + } + + let npmrcPath = path.join(this.cwd, NPMRC_CONFIG); + + if (fs.existsSync(npmrcPath)) { + // Backup + let npmrcBackupPath = path.join(this.cwd, NPMRC_CONFIG_BACKUP); + debug(`Copying ${NPMRC_CONFIG}`); + await fs.copy(npmrcPath, npmrcBackupPath); + + await fs.appendFileSync(npmrcPath, '\nresolution-mode = highest\n'); + } else { + fs.writeFileSync(npmrcPath, 'resolution-mode = highest\n'); + } + }, + + /** + * pnpm versions 8.0.0 through 8.6.* have the `resolution-mode` setting inverted to + * `lowest-direct`, which breaks ember-try. This method conditionally adds the necessary config to + * .npmrc to fix this. + * + * It covers the following cases: + * - pnpm version is out of dangerious range or cannot be retrieved — do not do anything + * - pnpm version is within dangerous range and the backup does not exist — delete the .npmrc + * - pnpm version is within dangerous range and the backup exists — rename the backup to .npmrc, + * overwriting the current .npmrc + * + * @param {undefined | string} version — this is only used in testing. Call this fucntion without + * arguments + * @returns Promise + */ + async _revertNpmRc(version) { + if (!version) { + version = await this._getPnpmVersion(); + } + + if (!this._doesPnpmRequireResolutionModeFix(version)) { + return; + } + + if (fs.existsSync(NPMRC_CONFIG_BACKUP)) { + fs.renameSync(NPMRC_CONFIG_BACKUP, NPMRC_CONFIG); + } else if (fs.existsSync(NPMRC_CONFIG)) { + fs.removeSync(NPMRC_CONFIG); + } + }, + + async _getPnpmVersion(command = 'pnpm --version') { + try { + let result = await exec(command); + return result.stdout.split('\n')[0]; + } catch (error) { + return null; + } + }, + + _doesPnpmRequireResolutionModeFix(versionStr) { + return versionStr ? semverGte(versionStr, '8.0.0') && semverLt(versionStr, '8.7.0') : false; + }, + async _install(depSet) { let mgrOptions = this.managerOptions || []; diff --git a/package.json b/package.json index 91c1bd25..58ffa14b 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "release-it": "^14.11.6", "release-it-lerna-changelog": "^3.1.0", "rsvp": "^4.7.0", + "sinon": "^15.2.0", "tmp-sync": "^1.1.0" }, "engines": { diff --git a/test/dependency-manager-adapters/pnpm-adapter-test.js b/test/dependency-manager-adapters/pnpm-adapter-test.js index 0a2f59d8..59351586 100644 --- a/test/dependency-manager-adapters/pnpm-adapter-test.js +++ b/test/dependency-manager-adapters/pnpm-adapter-test.js @@ -4,6 +4,7 @@ let expect = require('chai').expect; let fs = require('fs-extra'); let path = require('path'); let tmp = require('tmp-sync'); +const sinon = require('sinon'); let PnpmAdapter = require('../../lib/dependency-manager-adapters/pnpm'); let generateMockRun = require('../helpers/generate-mock-run'); @@ -18,6 +19,7 @@ describe('pnpm Adapter', () => { }); afterEach(async () => { + sinon.restore(); process.chdir(root); await fs.remove(tmproot); }); @@ -112,6 +114,39 @@ describe('pnpm Adapter', () => { expect(runCount).to.equal(1); }); + + it('runs _updateNpmRc before _install', async () => { + await fs.outputJson('package.json', { + devDependencies: { + 'ember-try-test-suite-helper': '0.1.0', + }, + }); + + let adapter = new PnpmAdapter({ + cwd: tmpdir, + }); + + const updateStub = sinon.replace( + adapter, + '_updateNpmRc', + sinon.fake(() => {}) + ); + + const installStub = sinon.replace( + adapter, + '_install', + sinon.fake(() => {}) + ); + + await adapter.setup(); + await adapter.changeToDependencySet({ + devDependencies: { + 'ember-try-test-suite-helper': '1.0.0', + }, + }); + + expect(updateStub.calledBefore(installStub)).to.be.true; + }); }); describe('#cleanup', () => { @@ -149,6 +184,33 @@ describe('pnpm Adapter', () => { expect(runCount).to.equal(1); }); + + it('runs _revertNpmRc after _install', async () => { + await fs.outputJson('package.json', { modifiedPackageJSON: true }); + await fs.outputJson('package.json.ember-try', { originalPackageJSON: true }); + await fs.outputFile('pnpm-lock.yaml', 'modifiedYAML: true\n'); + await fs.outputFile('pnpm-lock.ember-try.yaml', 'originalYAML: true\n'); + + let adapter = new PnpmAdapter({ + cwd: tmpdir, + }); + + const revertStub = sinon.replace( + adapter, + '_revertNpmRc', + sinon.fake(() => {}) + ); + + const installStub = sinon.replace( + adapter, + '_install', + sinon.fake(() => {}) + ); + + await adapter.cleanup(); + + expect(revertStub.calledAfter(installStub)).to.be.true; + }); }); describe('#_packageJSONForDependencySet', () => { @@ -332,4 +394,141 @@ describe('pnpm Adapter', () => { expect(resultJSON.devDependencies).to.not.have.property('ember-feature-flags'); }); }); + + describe('#_doesPnpmRequireResolutionModeFix', () => { + [ + { given: null, expected: false }, + { given: '1.0.0', expected: false }, + { given: '7.9.9999', expected: false }, + { given: '8.0.0', expected: true }, + { given: '8.1.2', expected: true }, + { given: '8.6.9999', expected: true }, + { given: '8.7.0', expected: false }, + { given: '8.7.1', expected: false }, + { given: '9.0.0', expected: false }, + ].forEach(({ given, expected }) => { + it(`works with given version "${given}"`, () => { + let npmAdapter = new PnpmAdapter({ cwd: tmpdir }); + let result = npmAdapter._doesPnpmRequireResolutionModeFix(given); + expect(result).equal(expected); + }); + }); + }); + + describe('#_getPnpmVersion', () => { + // prettier-ignore + [ + { version: '1.0.0' }, + { version: '8.6.2' }, + { version: 'how the turntables' }, + ].forEach(({ version }) => { + it(`works with given version "${version}"`, async () => { + let npmAdapter = new PnpmAdapter({ cwd: tmpdir }); + let result = await npmAdapter._getPnpmVersion(`echo ${version}`); + expect(result).equal(version); + }); + }); + }); + + describe('#_updateNpmRc', () => { + describe('when pnpm version requires the resolution-mode fix', () => { + it(`should create a new .npmrc file when none exists`, async () => { + let npmAdapter = new PnpmAdapter({ cwd: tmpdir }); + let npmrcPath = path.join(tmpdir, '.npmrc'); + let npmrcBackupPath = path.join(tmpdir, '.npmrc.ember-try'); + + await npmAdapter._updateNpmRc('8.6.0'); + + let actualFileContent = fs.readFileSync(npmrcPath, 'utf8'); + let expectedFileContent = 'resolution-mode = highest\n'; + + expect(actualFileContent, '.npmrc content').to.equal(expectedFileContent); + expect(fs.existsSync(npmrcBackupPath), '.npmrc.ember-try does not exist').to.be.false; + }); + + it(`should update an npmrc file when it already exists`, async () => { + let npmAdapter = new PnpmAdapter({ cwd: tmpdir }); + let npmrcPath = path.join(tmpdir, '.npmrc'); + let npmrcBackupPath = path.join(tmpdir, '.npmrc.ember-try'); + + fs.writeFileSync(npmrcPath, 'foo = bar\n'); + + await npmAdapter._updateNpmRc('8.6.0'); + + let actualFileContent = fs.readFileSync(npmrcPath, 'utf8'); + let expectedFileContent = 'foo = bar\n\nresolution-mode = highest\n'; + expect(actualFileContent, '.npmrc content').to.equal(expectedFileContent); + + let actualBackupFileContent = fs.readFileSync(npmrcBackupPath, 'utf8'); + let expectedBackupFileContent = 'foo = bar\n'; + expect(actualBackupFileContent, '.npmrc-backup content').to.equal( + expectedBackupFileContent + ); + }); + }); + + describe('when pnpm version does not the resolution-mode fix', () => { + it(`should not create a new .npmrc file`, async () => { + let npmAdapter = new PnpmAdapter({ cwd: tmpdir }); + let npmrcPath = path.join(tmpdir, '.npmrc'); + let npmrcBackupPath = path.join(tmpdir, '.npmrc.ember-try'); + + await npmAdapter._updateNpmRc('7.6.0'); + + expect(fs.existsSync(npmrcPath), '.npmrc does not exist').to.be.false; + expect(fs.existsSync(npmrcBackupPath), '.npmrc.ember-try does not exist').to.be.false; + }); + }); + }); + + describe('#_revertNpmRc', () => { + describe('when pnpm version requires the resolution-mode fix', () => { + it(`when backup does not exist, it should delete the .npmrc file`, async () => { + let npmAdapter = new PnpmAdapter({ cwd: tmpdir }); + let npmrcPath = path.join(tmpdir, '.npmrc'); + let npmrcBackupPath = path.join(tmpdir, '.npmrc.ember-try'); + + fs.writeFileSync(npmrcPath, 'resolution-mode = highest\n'); + + await npmAdapter._revertNpmRc('8.6.0'); + + expect(fs.existsSync(npmrcPath), '.npmrc.ember-try does not exist').to.be.false; + expect(fs.existsSync(npmrcBackupPath), '.npmrc.ember-try does not exist').to.be.false; + }); + + it(`when backup exists, it should replace the original file with backup and delete the backup`, async () => { + let npmAdapter = new PnpmAdapter({ cwd: tmpdir }); + let npmrcPath = path.join(tmpdir, '.npmrc'); + let npmrcBackupPath = path.join(tmpdir, '.npmrc.ember-try'); + + fs.writeFileSync(npmrcPath, 'foo = bar\n\nresolution-mode = highest\n'); + fs.writeFileSync(npmrcBackupPath, 'foo = bar\n'); + + await npmAdapter._revertNpmRc('8.6.0'); + + let actualFileContent = fs.readFileSync(npmrcPath, 'utf8'); + let expectedFileContent = 'foo = bar\n'; + + expect(actualFileContent, '.npmrc content').to.equal(expectedFileContent); + expect(fs.existsSync(npmrcBackupPath), '.npmrc.ember-try existence').to.be.false; + }); + }); + + describe('when pnpm version does not the resolution-mode fix', () => { + it(`should not touch the existing .npmrc file`, async () => { + let npmAdapter = new PnpmAdapter({ cwd: tmpdir }); + let npmrcPath = path.join(tmpdir, '.npmrc'); + let npmrcBackupPath = path.join(tmpdir, '.npmrc.ember-try'); + + fs.writeFileSync(npmrcPath, 'foo = bar\n'); + + await npmAdapter._revertNpmRc('7.6.0'); + + let actualFileContent = fs.readFileSync(npmrcPath, 'utf8'); + + expect(actualFileContent, '.npmrc content').to.equal('foo = bar\n'); + expect(fs.existsSync(npmrcBackupPath), '.npmrc.ember-try does not exist').to.be.false; + }); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 82e73723..51756365 100644 --- a/yarn.lock +++ b/yarn.lock @@ -415,6 +415,41 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.0.0.tgz#2ff674e9611b45b528896d820d3d7a812de2f0e4" integrity sha512-FyD2meJpDPjyNQejSjvnhpgI/azsQkA4lGbuu5BQZfjvJ9cbRZXzeWL2HceCekW4lixO9JPesIIQkSoLjeJHNQ== +"@sinonjs/commons@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-2.0.0.tgz#fd4ca5b063554307e8327b4564bd56d3b73924a3" + integrity sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg== + dependencies: + type-detect "4.0.8" + +"@sinonjs/commons@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.0.tgz#beb434fe875d965265e04722ccfc21df7f755d72" + integrity sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@^10.0.2", "@sinonjs/fake-timers@^10.3.0": + version "10.3.0" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz#55fdff1ecab9f354019129daf4df0dd4d923ea66" + integrity sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA== + dependencies: + "@sinonjs/commons" "^3.0.0" + +"@sinonjs/samsam@^8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-8.0.0.tgz#0d488c91efb3fa1442e26abea81759dfc8b5ac60" + integrity sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew== + dependencies: + "@sinonjs/commons" "^2.0.0" + lodash.get "^4.4.2" + type-detect "^4.0.8" + +"@sinonjs/text-encoding@^0.7.1": + version "0.7.2" + resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz#5981a8db18b56ba38ef0efb7d995b12aa7b51918" + integrity sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ== + "@szmarczak/http-timer@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421" @@ -2432,6 +2467,11 @@ diff@4.0.2, diff@^4.0.2: resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== +diff@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40" + integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw== + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -4931,6 +4971,11 @@ jsonify@~0.0.0: resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" integrity sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM= +just-extend@^4.0.2: + version "4.2.1" + resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.2.1.tgz#ef5e589afb61e5d66b24eca749409a8939a8c744" + integrity sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg== + keyv@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.0.0.tgz#44923ba39e68b12a7cec7df6c3268c031f2ef373" @@ -5164,6 +5209,11 @@ lodash.foreach@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz#1a6a35eace401280c7f06dddec35165ab27e3e53" integrity sha1-Gmo16s5AEoDH8G3d7DUWWrJ+PlM= +lodash.get@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" + integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== + lodash.isarguments@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" @@ -5777,6 +5827,17 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +nise@^5.1.4: + version "5.1.4" + resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.4.tgz#491ce7e7307d4ec546f5a659b2efe94a18b4bbc0" + integrity sha512-8+Ib8rRJ4L0o3kfmyVCL7gzrohyDe0cMFTBa2d364yIrEGMEoetznKJx899YxjybU6bL9SQkYPSBBs1gyYs8Xg== + dependencies: + "@sinonjs/commons" "^2.0.0" + "@sinonjs/fake-timers" "^10.0.2" + "@sinonjs/text-encoding" "^0.7.1" + just-extend "^4.0.2" + path-to-regexp "^1.7.0" + node-fetch@^2.6.0, node-fetch@^2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" @@ -6349,6 +6410,13 @@ path-to-regexp@0.1.7: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= +path-to-regexp@^1.7.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" + integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA== + dependencies: + isarray "0.0.1" + path-type@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" @@ -7159,6 +7227,18 @@ silent-error@^1.0.0, silent-error@^1.0.1, silent-error@^1.1.1: dependencies: debug "^2.2.0" +sinon@^15.2.0: + version "15.2.0" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-15.2.0.tgz#5e44d4bc5a9b5d993871137fd3560bebfac27565" + integrity sha512-nPS85arNqwBXaIsFCkolHjGIkFo+Oxu9vbgmBJizLAhqe6P2o3Qmj3KCUoRkfhHtvgDhZdWD3risLHAUJ8npjw== + dependencies: + "@sinonjs/commons" "^3.0.0" + "@sinonjs/fake-timers" "^10.3.0" + "@sinonjs/samsam" "^8.0.0" + diff "^5.1.0" + nise "^5.1.4" + supports-color "^7.2.0" + slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" @@ -7595,7 +7675,7 @@ sum-up@^1.0.1: dependencies: chalk "^1.0.0" -supports-color@7.2.0, supports-color@^7.1.0: +supports-color@7.2.0, supports-color@^7.1.0, supports-color@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== @@ -7911,7 +7991,7 @@ type-check@^0.4.0, type-check@~0.4.0: dependencies: prelude-ls "^1.2.1" -type-detect@^4.0.0, type-detect@^4.0.5: +type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5, type-detect@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==