From c2e80dac8e8426b02f59b56ecb40453ccfccf17b Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 4 Nov 2021 01:19:16 +0100 Subject: [PATCH] Implement `pnpm` adapter --- lib/dependency-manager-adapters/pnpm.js | 170 +++++++++ .../dependency-manager-adapter-factory.js | 13 + .../pnpm-adapter-test.js | 335 ++++++++++++++++++ 3 files changed, 518 insertions(+) create mode 100644 lib/dependency-manager-adapters/pnpm.js create mode 100644 test/dependency-manager-adapters/pnpm-adapter-test.js diff --git a/lib/dependency-manager-adapters/pnpm.js b/lib/dependency-manager-adapters/pnpm.js new file mode 100644 index 00000000..959ffa7b --- /dev/null +++ b/lib/dependency-manager-adapters/pnpm.js @@ -0,0 +1,170 @@ +'use strict'; + +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 PACKAGE_JSON = 'package.json'; +const PACKAGE_JSON_BACKUP = 'package.json.ember-try'; +const PNPM_LOCKFILE = 'pnpm-lock.yaml'; + +// Note: the upstream convention is to append `.ember-try` _after_ the file +// extension, however this breaks syntax highlighting, so I've chosen to +// insert it right before the file extension. +const PNPM_LOCKFILE_BACKUP = 'pnpm-lock.ember-try.yaml'; + +module.exports = CoreObject.extend({ + // This still needs to be `npm` because we're still reading the dependencies + // from the `npm` key of the ember-try config. + configKey: 'npm', + + init() { + this._super.apply(this, arguments); + this.run = this.run || require('../utils/run'); + }, + + async setup() { + let pkg = path.join(this.cwd, PACKAGE_JSON); + let pkgBackup = path.join(this.cwd, PACKAGE_JSON_BACKUP); + debug(`Copying ${PACKAGE_JSON}`); + await fs.copy(pkg, pkgBackup); + + let lockFile = path.join(this.cwd, PNPM_LOCKFILE); + let lockFileBackup = path.join(this.cwd, PNPM_LOCKFILE_BACKUP); + if (fs.existsSync(lockFile)) { + debug(`Copying ${PNPM_LOCKFILE}`); + await fs.copy(lockFile, lockFileBackup); + } + }, + + async changeToDependencySet(depSet) { + await this.applyDependencySet(depSet); + + await this._install(depSet); + + let deps = Object.assign({}, depSet.dependencies, depSet.devDependencies); + let currentDeps = Object.keys(deps).map((dep) => { + return { + name: dep, + versionExpected: deps[dep], + versionSeen: this._findCurrentVersionOf(dep), + packageManager: 'pnpm', + }; + }); + + debug('Switched to dependencies: \n', currentDeps); + + return currentDeps; + }, + + async cleanup() { + try { + debug(`Restoring original ${PACKAGE_JSON}`); + let pkg = path.join(this.cwd, PACKAGE_JSON); + let pkgBackup = path.join(this.cwd, PACKAGE_JSON_BACKUP); + await fs.copy(pkgBackup, pkg); + await fs.remove(pkgBackup); + + debug(`Restoring original ${PNPM_LOCKFILE}`); + let lockFile = path.join(this.cwd, PNPM_LOCKFILE); + let lockFileBackup = path.join(this.cwd, PNPM_LOCKFILE_BACKUP); + await fs.copy(lockFileBackup, lockFile); + await fs.remove(lockFileBackup); + + await this._install(); + } catch (e) { + console.log('Error cleaning up scenario:', e); // eslint-disable-line no-console + } + }, + + _findCurrentVersionOf(packageName) { + let filename = path.join(this.cwd, 'node_modules', packageName, PACKAGE_JSON); + if (fs.existsSync(filename)) { + return JSON.parse(fs.readFileSync(filename)).version; + } else { + return null; + } + }, + + async _install(depSet) { + let mgrOptions = this.managerOptions || []; + + // buildManagerOptions overrides all default + if (typeof this.buildManagerOptions === 'function') { + mgrOptions = this.buildManagerOptions(depSet); + + if (!Array.isArray(mgrOptions)) { + throw new Error('buildManagerOptions must return an array of options'); + } + } else if (!mgrOptions.includes('--frozen-lockfile=false')) { + mgrOptions.push('--frozen-lockfile=false'); + } + + // Note: We are explicitly *not* using `--no-lockfile` here, so that we + // only have to resolve the dependencies that have actually changed. + + debug('Run pnpm install with options %s', mgrOptions); + + await this.run('pnpm', [].concat(['install'], mgrOptions), { cwd: this.cwd }); + }, + + async applyDependencySet(depSet) { + debug('Changing to dependency set: %s', JSON.stringify(depSet)); + + if (!depSet) { + return; + } + + let backupPackageJSON = path.join(this.cwd, PACKAGE_JSON_BACKUP); + let packageJSONFile = path.join(this.cwd, PACKAGE_JSON); + let packageJSON = JSON.parse(fs.readFileSync(backupPackageJSON)); + let newPackageJSON = this._packageJSONForDependencySet(packageJSON, depSet); + + debug('Write package.json with: \n', JSON.stringify(newPackageJSON)); + fs.writeFileSync(packageJSONFile, JSON.stringify(newPackageJSON, null, 2)); + + // We restore the original lockfile here, so that we always create a minimal + // diff compared to the original locked dependency set. + + let lockFile = path.join(this.cwd, PNPM_LOCKFILE); + let lockFileBackup = path.join(this.cwd, PNPM_LOCKFILE_BACKUP); + if (fs.existsSync(lockFileBackup)) { + debug(`Restoring original ${PNPM_LOCKFILE}`); + await fs.copy(lockFileBackup, lockFile); + } + }, + + _packageJSONForDependencySet(packageJSON, depSet) { + this._overridePackageJSONDependencies(packageJSON, depSet, 'dependencies'); + this._overridePackageJSONDependencies(packageJSON, depSet, 'devDependencies'); + this._overridePackageJSONDependencies(packageJSON, depSet, 'peerDependencies'); + this._overridePackageJSONDependencies(packageJSON, depSet, 'ember'); + + // see https://pnpm.io/package_json#pnpmoverrides + this._overridePackageJSONDependencies(packageJSON, depSet, 'overrides'); + + return packageJSON; + }, + + _overridePackageJSONDependencies(packageJSON, depSet, kindOfDependency) { + if (!depSet[kindOfDependency]) { + return; + } + + let packageNames = Object.keys(depSet[kindOfDependency]); + + for (let packageName of packageNames) { + if (!packageJSON[kindOfDependency]) { + packageJSON[kindOfDependency] = {}; + } + + let version = depSet[kindOfDependency][packageName]; + if (version === null) { + delete packageJSON[kindOfDependency][packageName]; + } else { + packageJSON[kindOfDependency][packageName] = version; + } + } + }, +}); diff --git a/lib/utils/dependency-manager-adapter-factory.js b/lib/utils/dependency-manager-adapter-factory.js index 8687d219..e1c29a6e 100644 --- a/lib/utils/dependency-manager-adapter-factory.js +++ b/lib/utils/dependency-manager-adapter-factory.js @@ -1,6 +1,7 @@ 'use strict'; const NpmAdapter = require('../dependency-manager-adapters/npm'); +const PnpmAdapter = require('../dependency-manager-adapters/pnpm'); const WorkspaceAdapter = require('../dependency-manager-adapters/workspace'); module.exports = { @@ -34,6 +35,18 @@ module.exports = { buildManagerOptions: config.buildManagerOptions, }) ); + } else if (config.usePnpm) { + console.warn( + 'pnpm support is experimental for now. if you notice any problems please open an issue.' + ); + + adapters.push( + new PnpmAdapter({ + cwd: root, + managerOptions: config.npmOptions, + buildManagerOptions: config.buildManagerOptions, + }) + ); } else if (hasNpm) { adapters.push( new NpmAdapter({ diff --git a/test/dependency-manager-adapters/pnpm-adapter-test.js b/test/dependency-manager-adapters/pnpm-adapter-test.js new file mode 100644 index 00000000..655a088f --- /dev/null +++ b/test/dependency-manager-adapters/pnpm-adapter-test.js @@ -0,0 +1,335 @@ +'use strict'; + +let expect = require('chai').expect; +let fs = require('fs-extra'); +let path = require('path'); +let tmp = require('tmp-sync'); +let PnpmAdapter = require('../../lib/dependency-manager-adapters/pnpm'); +let generateMockRun = require('../helpers/generate-mock-run'); + +let root = process.cwd(); +let tmproot = path.join(root, 'tmp'); +let tmpdir; + +describe('pnpm Adapter', () => { + beforeEach(() => { + tmpdir = tmp.in(tmproot); + process.chdir(tmpdir); + }); + + afterEach(async () => { + process.chdir(root); + await fs.remove(tmproot); + }); + + describe('#setup', () => { + it('backs up the `package.json` and `pnpm-lock.yaml` files', async () => { + await fs.outputJson('package.json', { originalPackageJSON: true }); + await fs.outputFile('pnpm-lock.yaml', 'originalYAML: true\n'); + await fs.outputJson('node_modules/prove-it.json', { originalNodeModules: true }); + + let adapter = new PnpmAdapter({ cwd: tmpdir }); + await adapter.setup(); + + expect(await fs.readJson('package.json.ember-try')).to.deep.equal({ + originalPackageJSON: true, + }); + expect(await fs.readFile('pnpm-lock.ember-try.yaml', 'utf-8')).to.equal( + 'originalYAML: true\n' + ); + expect(fs.existsSync('.node_modules.ember-try')).to.be.false; + }); + + it('ignores missing `pnpm-lock.yaml` files', async () => { + await fs.outputJson('package.json', { originalPackageJSON: true }); + await fs.outputJson('node_modules/prove-it.json', { originalNodeModules: true }); + + let adapter = new PnpmAdapter({ cwd: tmpdir }); + await adapter.setup(); + + expect(await fs.readJson('package.json.ember-try')).to.deep.equal({ + originalPackageJSON: true, + }); + expect(fs.existsSync('pnpm-lock.ember-try.yaml')).to.be.false; + expect(fs.existsSync('.node_modules.ember-try')).to.be.false; + }); + }); + + describe('#changeToDependencySet', () => { + it('updates the `package.json` and runs `pnpm install`', async () => { + await fs.outputJson('package.json', { + devDependencies: { + 'ember-try-test-suite-helper': '0.1.0', + }, + }); + + let runCount = 0; + let stubbedRun = generateMockRun( + [ + { + command: 'pnpm install --frozen-lockfile=false', + async callback(command, args, opts) { + runCount++; + expect(opts).to.have.property('cwd', tmpdir); + }, + }, + ], + { allowPassthrough: false } + ); + + let adapter = new PnpmAdapter({ + cwd: tmpdir, + run: stubbedRun, + }); + + await adapter.setup(); + let result = await adapter.changeToDependencySet({ + devDependencies: { + 'ember-try-test-suite-helper': '1.0.0', + }, + }); + + expect(result).to.deep.equal([ + { + name: 'ember-try-test-suite-helper', + packageManager: 'pnpm', + versionExpected: '1.0.0', + versionSeen: null, + }, + ]); + + expect(await fs.readJson('package.json')).to.deep.equal({ + devDependencies: { + 'ember-try-test-suite-helper': '1.0.0', + }, + }); + + expect(await fs.readJson('package.json.ember-try')).to.deep.equal({ + devDependencies: { + 'ember-try-test-suite-helper': '0.1.0', + }, + }); + + expect(runCount).to.equal(1); + }); + }); + + describe('#cleanup', () => { + it('restores the `package.json` and `yarn-lock.yaml` files, and then runs `pnpm 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 runCount = 0; + let stubbedRun = generateMockRun( + [ + { + command: 'pnpm install --frozen-lockfile=false', + async callback(command, args, opts) { + runCount++; + expect(opts).to.have.property('cwd', tmpdir); + }, + }, + ], + { allowPassthrough: false } + ); + + let adapter = new PnpmAdapter({ + cwd: tmpdir, + run: stubbedRun, + }); + await adapter.cleanup(); + + expect(await fs.readJson('package.json')).to.deep.equal({ originalPackageJSON: true }); + expect(await fs.readFile('pnpm-lock.yaml', 'utf-8')).to.equal('originalYAML: true\n'); + expect(fs.existsSync('package.json.ember-try')).to.be.false; + expect(fs.existsSync('pnpm-lock.ember-try.yaml')).to.be.false; + expect(fs.existsSync('.node_modules.ember-try')).to.be.false; + + expect(runCount).to.equal(1); + }); + }); + + describe('#_packageJSONForDependencySet', () => { + it('changes specified dependency versions', () => { + let npmAdapter = new PnpmAdapter({ cwd: tmpdir }); + let packageJSON = { + devDependencies: { 'ember-feature-flags': '1.0.0' }, + dependencies: { 'ember-cli-babel': '5.0.0' }, + }; + let depSet = { dependencies: { 'ember-cli-babel': '6.0.0' } }; + + let resultJSON = npmAdapter._packageJSONForDependencySet(packageJSON, depSet); + + expect(resultJSON.dependencies['ember-cli-babel']).to.equal('6.0.0'); + }); + + describe('ember property', () => { + it('adds the ember property to project package.json', () => { + let npmAdapter = new PnpmAdapter({ + cwd: tmpdir, + useYarnCommand: true, + }); + let packageJSON = {}; + let depSet = { + ember: { edition: 'octane' }, + }; + + let resultJSON = npmAdapter._packageJSONForDependencySet(packageJSON, depSet); + + expect(resultJSON).to.deep.equal({ ember: { edition: 'octane' } }); + }); + + it('merges the ember property to project package.json', () => { + let npmAdapter = new PnpmAdapter({ + cwd: tmpdir, + useYarnCommand: true, + }); + let packageJSON = { ember: { foo: 'bar' } }; + let depSet = { + ember: { edition: 'octane' }, + }; + + let resultJSON = npmAdapter._packageJSONForDependencySet(packageJSON, depSet); + + expect(resultJSON).to.deep.equal({ ember: { foo: 'bar', edition: 'octane' } }); + }); + + it('overrides existing fields inside the ember property to project package.json', () => { + let npmAdapter = new PnpmAdapter({ + cwd: tmpdir, + useYarnCommand: true, + }); + let packageJSON = { ember: { edition: 'classic' } }; + let depSet = { + ember: { edition: 'octane' }, + }; + + let resultJSON = npmAdapter._packageJSONForDependencySet(packageJSON, depSet); + + expect(resultJSON).to.deep.equal({ ember: { edition: 'octane' } }); + }); + + it('removes any items with a null value', () => { + let npmAdapter = new PnpmAdapter({ + cwd: tmpdir, + useYarnCommand: true, + }); + let packageJSON = { ember: { edition: 'octane' } }; + let depSet = { + ember: { edition: null }, + }; + + let resultJSON = npmAdapter._packageJSONForDependencySet(packageJSON, depSet); + + expect(resultJSON).to.deep.equal({ ember: {} }); + }); + }); + + it('adds an override for the specified dependency version', () => { + let npmAdapter = new PnpmAdapter({ + cwd: tmpdir, + useYarnCommand: true, + }); + let packageJSON = { + dependencies: { 'ember-cli-babel': '5.0.0' }, + }; + let depSet = { + dependencies: { 'ember-cli-babel': '6.0.0' }, + overrides: { 'ember-cli-babel': '6.0.0' }, + }; + + let resultJSON = npmAdapter._packageJSONForDependencySet(packageJSON, depSet); + + expect(resultJSON.overrides['ember-cli-babel']).to.equal('6.0.0'); + }); + + it('removes a dependency from overrides if its version is null', () => { + let npmAdapter = new PnpmAdapter({ + cwd: tmpdir, + useYarnCommand: true, + }); + let packageJSON = { + dependencies: { 'ember-cli-babel': '5.0.0' }, + overrides: { 'ember-cli-babel': '5.0.0' }, + }; + let depSet = { + dependencies: { 'ember-cli-babel': '6.0.0' }, + overrides: { 'ember-cli-babel': null }, + }; + + let resultJSON = npmAdapter._packageJSONForDependencySet(packageJSON, depSet); + + expect(resultJSON.overrides['ember-cli-babel']).to.be.undefined; + }); + + it('doesnt add resolutions if there are none specified', () => { + let npmAdapter = new PnpmAdapter({ + cwd: tmpdir, + useYarnCommand: true, + }); + let packageJSON = { + dependencies: { 'ember-cli-babel': '5.0.0' }, + }; + let depSet = { + dependencies: { 'ember-cli-babel': '6.0.0' }, + }; + + let resultJSON = npmAdapter._packageJSONForDependencySet(packageJSON, depSet); + + expect(resultJSON.resolutions).to.be.undefined; + }); + + it('doesnt add resolutions when not using yarn', () => { + let npmAdapter = new PnpmAdapter({ + cwd: tmpdir, + useYarnCommand: false, + }); + let packageJSON = { + dependencies: { 'ember-cli-babel': '5.0.0' }, + }; + let depSet = { + dependencies: { 'ember-cli-babel': '6.0.0' }, + resolutions: { 'ember-cli-babel': '6.0.0' }, + }; + + let resultJSON = npmAdapter._packageJSONForDependencySet(packageJSON, depSet); + + expect(resultJSON.resolutions).to.be.undefined; + }); + + it('changes specified npm dev dependency versions', () => { + let npmAdapter = new PnpmAdapter({ cwd: tmpdir }); + let packageJSON = { + devDependencies: { 'ember-feature-flags': '1.0.0' }, + dependencies: { 'ember-cli-babel': '5.0.0' }, + }; + let depSet = { devDependencies: { 'ember-feature-flags': '2.0.1' } }; + + let resultJSON = npmAdapter._packageJSONForDependencySet(packageJSON, depSet); + + expect(resultJSON.devDependencies['ember-feature-flags']).to.equal('2.0.1'); + }); + + it('changes specified npm peer dependency versions', () => { + let npmAdapter = new PnpmAdapter({ cwd: tmpdir }); + let packageJSON = { peerDependencies: { 'ember-cli-babel': '5.0.0' } }; + let depSet = { peerDependencies: { 'ember-cli-babel': '4.0.0' } }; + + let resultJSON = npmAdapter._packageJSONForDependencySet(packageJSON, depSet); + + expect(resultJSON.peerDependencies['ember-cli-babel']).to.equal('4.0.0'); + }); + + it('can remove a package', () => { + let npmAdapter = new PnpmAdapter({ cwd: tmpdir }); + let packageJSON = { devDependencies: { 'ember-feature-flags': '1.0.0' } }; + let depSet = { devDependencies: { 'ember-feature-flags': null } }; + + let resultJSON = npmAdapter._packageJSONForDependencySet(packageJSON, depSet); + + expect(resultJSON.devDependencies).to.not.have.property('ember-feature-flags'); + }); + }); +});