diff --git a/example/packages/bar/bar.js b/example/packages/bar/bar.js new file mode 100644 index 00000000..e69de29b diff --git a/src/Config.js b/src/Config.js index ada0bd86..683696b8 100644 --- a/src/Config.js +++ b/src/Config.js @@ -69,6 +69,7 @@ export default class Config { fileContents: string; json: JSONValue; indent: string; + invalid: boolean; constructor(filePath: string, fileContents: string) { this.filePath = filePath; @@ -125,6 +126,11 @@ export default class Config { } getConfig(): { [key: string]: JSONValue } { + if (this.invalid) { + throw new BoltError( + `You need to refresh the Config object for ${this.filePath}` + ); + } let config = this.json; if ( @@ -140,6 +146,10 @@ export default class Config { return config; } + invalidate() { + this.invalid = true; + } + getDescriptor(): string { if (this.json && typeof this.json.name === 'string') { return this.json.name; @@ -224,4 +234,16 @@ export default class Config { .filePath}"` ); } + + getBin() { + let config = this.getConfig(); + let bin = config.bin; + if (typeof bin === 'undefined') return; + if (typeof bin === 'string') return bin; + return toObjectOfStrings( + bin, + `package.json#bin must be an object of strings or a string. See "${this + .filePath}"` + ); + } } diff --git a/src/Package.js b/src/Package.js index b05255ba..a8c60ad1 100644 --- a/src/Package.js +++ b/src/Package.js @@ -108,8 +108,7 @@ export default class Package { cleaned[depName] = versionRange; } } - - this.config.write({ + await this.config.write({ ...this.config.getConfig(), [depType]: sortObject(cleaned) }); @@ -125,6 +124,16 @@ export default class Package { return null; } + getDependencyVersionRange(depName: string) { + for (let depType of DEPENDENCY_TYPES) { + const deps = this.config.getDeps(depType); + if (deps && deps[depName]) { + return deps[depName]; + } + } + return null; + } + // async maybeUpdateDependencyVersionRange(depName: string, current: string, version: string) { // let versionRange = '^' + version; // let updated = false; diff --git a/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/node_modules/.bin/external-dep-two-bins-1 b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/node_modules/.bin/external-dep-two-bins-1 new file mode 120000 index 00000000..95605340 --- /dev/null +++ b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/node_modules/.bin/external-dep-two-bins-1 @@ -0,0 +1 @@ +../external-dep-with-two-bins/external-bin-1.js \ No newline at end of file diff --git a/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/node_modules/.bin/external-dep-two-bins-2 b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/node_modules/.bin/external-dep-two-bins-2 new file mode 120000 index 00000000..84a50cd6 --- /dev/null +++ b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/node_modules/.bin/external-dep-two-bins-2 @@ -0,0 +1 @@ +../external-dep-with-two-bins/external-bin-2.js \ No newline at end of file diff --git a/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/node_modules/.bin/external-dep-with-bin b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/node_modules/.bin/external-dep-with-bin new file mode 120000 index 00000000..92b520a9 --- /dev/null +++ b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/node_modules/.bin/external-dep-with-bin @@ -0,0 +1 @@ +../external-dep-with-bin/external-bin.js \ No newline at end of file diff --git a/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/node_modules/external-dep-with-bin/external-bin.js b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/node_modules/external-dep-with-bin/external-bin.js new file mode 100644 index 00000000..e69de29b diff --git a/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/node_modules/external-dep-with-bin/package.json b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/node_modules/external-dep-with-bin/package.json new file mode 100644 index 00000000..d9e0e5bf --- /dev/null +++ b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/node_modules/external-dep-with-bin/package.json @@ -0,0 +1,5 @@ +{ + "name": "external-dep-with-bin", + "version": "1.0.0", + "bin": "./external-bin.js" +} diff --git a/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/node_modules/external-dep-with-two-bins/external-bin-1.js b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/node_modules/external-dep-with-two-bins/external-bin-1.js new file mode 100644 index 00000000..e69de29b diff --git a/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/node_modules/external-dep-with-two-bins/external-bin-2.js b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/node_modules/external-dep-with-two-bins/external-bin-2.js new file mode 100644 index 00000000..e69de29b diff --git a/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/node_modules/external-dep-with-two-bins/package.json b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/node_modules/external-dep-with-two-bins/package.json new file mode 100644 index 00000000..d2e89bfa --- /dev/null +++ b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/node_modules/external-dep-with-two-bins/package.json @@ -0,0 +1,8 @@ +{ + "name": "external-dep-with-two-bins", + "version": "1.0.0", + "bin": { + "external-dep-two-bins-1": "./external-bin-1.js", + "external-dep-two-bins-2": "./external-bin-2.js" + } +} diff --git a/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/node_modules/external-dep/package.json b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/node_modules/external-dep/package.json new file mode 100644 index 00000000..cbfaeb03 --- /dev/null +++ b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/node_modules/external-dep/package.json @@ -0,0 +1,4 @@ +{ + "name": "external-dep", + "version": "1.0.0" +} diff --git a/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/package.json b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/package.json new file mode 100644 index 00000000..ab8062a2 --- /dev/null +++ b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/package.json @@ -0,0 +1,19 @@ +{ + "private": true, + "version": "1.0.0", + "name": "fixture-project-nested-workspaces-with-root-dependencies-installed", + "description": "A project with only the root node_modules and symlinks already set up", + "description2": "Packages: foo, bar, baz, zee", + "description3": "bar has a single bin file, baz has two", + "description4": "foo and baz depend on bar, zee depends on baz", + "bolt": { + "workspaces": [ + "packages/*" + ] + }, + "dependencies": { + "external-dep": "^1.0.0", + "external-dep-with-bin": "^1.0.0", + "external-dep-with-two-bins": "^1.0.0" + } +} diff --git a/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/packages/bar/bar.js b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/packages/bar/bar.js new file mode 100644 index 00000000..e69de29b diff --git a/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/packages/bar/package.json b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/packages/bar/package.json new file mode 100644 index 00000000..965aef0c --- /dev/null +++ b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/packages/bar/package.json @@ -0,0 +1,8 @@ +{ + "name": "bar", + "version": "1.0.0", + "dependencies": { + "external-dep": "^1.0.0" + }, + "bin": "./bar.js" +} diff --git a/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/packages/foo/package.json b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/packages/foo/package.json new file mode 100644 index 00000000..e6fc7858 --- /dev/null +++ b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/packages/foo/package.json @@ -0,0 +1,12 @@ +{ + "name": "foo", + "version": "1.0.0", + "dependencies": { + "bar": "^1.0.0", + "external-dep": "^1.0.0", + "external-dep-with-bin": "^1.0.0" + }, + "bolt": { + "workspaces": ["packages/*"] + } +} diff --git a/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/packages/foo/packages/baz/baz-bin-1.js b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/packages/foo/packages/baz/baz-bin-1.js new file mode 100644 index 00000000..e69de29b diff --git a/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/packages/foo/packages/baz/baz-bin-2.js b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/packages/foo/packages/baz/baz-bin-2.js new file mode 100644 index 00000000..e69de29b diff --git a/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/packages/foo/packages/baz/package.json b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/packages/foo/packages/baz/package.json new file mode 100644 index 00000000..d1339e9b --- /dev/null +++ b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/packages/foo/packages/baz/package.json @@ -0,0 +1,11 @@ +{ + "name": "baz", + "version": "1.0.0", + "dependencies": { + "bar": "^1.0.0" + }, + "bin": { + "baz-1": "./baz-bin-1.js", + "baz-2": "./baz-bin-2.js" + } +} diff --git a/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/packages/foo/packages/zee/package.json b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/packages/foo/packages/zee/package.json new file mode 100644 index 00000000..e1cd1c60 --- /dev/null +++ b/src/__fixtures__/nested-workspaces-with-root-dependencies-installed/packages/foo/packages/zee/package.json @@ -0,0 +1,13 @@ +{ + "name": "zee", + "version": "1.0.0", + "description": "We use the zee package to test all bin symlinking", + "description2": "bar and external-dep-with-bin both have one bin", + "description3": "baz and external-dep... both have two", + "dependencies": { + "bar": "^1.0.0", + "baz": "^1.0.0", + "external-dep-with-bin": "^1.0.0", + "external-dep-with-two-bins": "^1.0.0" + } +} diff --git a/src/__fixtures__/nested-workspaces-with-scoped-package-names/packages/bar/node_modules/react b/src/__fixtures__/nested-workspaces-with-scoped-package-names/packages/bar/node_modules/react new file mode 120000 index 00000000..b29c4786 --- /dev/null +++ b/src/__fixtures__/nested-workspaces-with-scoped-package-names/packages/bar/node_modules/react @@ -0,0 +1 @@ +../../../node_modules/react \ No newline at end of file diff --git a/src/__fixtures__/nested-workspaces-with-scoped-package-names/packages/foo/node_modules/@scoped/bar b/src/__fixtures__/nested-workspaces-with-scoped-package-names/packages/foo/node_modules/@scoped/bar new file mode 120000 index 00000000..cb4cd09f --- /dev/null +++ b/src/__fixtures__/nested-workspaces-with-scoped-package-names/packages/foo/node_modules/@scoped/bar @@ -0,0 +1 @@ +../../../bar \ No newline at end of file diff --git a/src/__fixtures__/nested-workspaces-with-scoped-package-names/packages/foo/node_modules/react b/src/__fixtures__/nested-workspaces-with-scoped-package-names/packages/foo/node_modules/react new file mode 120000 index 00000000..b29c4786 --- /dev/null +++ b/src/__fixtures__/nested-workspaces-with-scoped-package-names/packages/foo/node_modules/react @@ -0,0 +1 @@ +../../../node_modules/react \ No newline at end of file diff --git a/src/__fixtures__/nested-workspaces-with-scoped-package-names/packages/foo/package.json b/src/__fixtures__/nested-workspaces-with-scoped-package-names/packages/foo/package.json index 9e55e6cd..28a37675 100644 --- a/src/__fixtures__/nested-workspaces-with-scoped-package-names/packages/foo/package.json +++ b/src/__fixtures__/nested-workspaces-with-scoped-package-names/packages/foo/package.json @@ -6,6 +6,6 @@ }, "dependencies": { "react": "^15.6.1", - "bar": "^1.0.0" + "@scoped/bar": "^1.0.0" } } diff --git a/src/__fixtures__/nested-workspaces-with-scoped-package-names/packages/foo/packages/baz/node_modules/@scoped/bar b/src/__fixtures__/nested-workspaces-with-scoped-package-names/packages/foo/packages/baz/node_modules/@scoped/bar new file mode 120000 index 00000000..d77d27eb --- /dev/null +++ b/src/__fixtures__/nested-workspaces-with-scoped-package-names/packages/foo/packages/baz/node_modules/@scoped/bar @@ -0,0 +1 @@ +../../../../../bar \ No newline at end of file diff --git a/src/__fixtures__/nested-workspaces-with-scoped-package-names/packages/foo/packages/baz/node_modules/react b/src/__fixtures__/nested-workspaces-with-scoped-package-names/packages/foo/packages/baz/node_modules/react new file mode 120000 index 00000000..0808930c --- /dev/null +++ b/src/__fixtures__/nested-workspaces-with-scoped-package-names/packages/foo/packages/baz/node_modules/react @@ -0,0 +1 @@ +../../../../../node_modules/react \ No newline at end of file diff --git a/src/__fixtures__/nested-workspaces-with-scoped-package-names/packages/foo/packages/baz/package.json b/src/__fixtures__/nested-workspaces-with-scoped-package-names/packages/foo/packages/baz/package.json index 18c6e967..acf055ae 100644 --- a/src/__fixtures__/nested-workspaces-with-scoped-package-names/packages/foo/packages/baz/package.json +++ b/src/__fixtures__/nested-workspaces-with-scoped-package-names/packages/foo/packages/baz/package.json @@ -3,6 +3,6 @@ "version": "1.0.0", "dependencies": { "react": "^15.6.1", - "bar": "^1.0.0" + "@scoped/bar": "^1.0.0" } } diff --git a/src/__fixtures__/nested-workspaces/package.json b/src/__fixtures__/nested-workspaces/package.json index 2059a138..cbd2cab3 100644 --- a/src/__fixtures__/nested-workspaces/package.json +++ b/src/__fixtures__/nested-workspaces/package.json @@ -6,6 +6,7 @@ "workspaces": ["packages/*"] }, "dependencies": { - "react": "^15.6.1" + "react": "^15.6.1", + "left-pad": "^1.1.3" } } diff --git a/src/__fixtures__/nested-workspaces/packages/foo/packages/baz/package.json b/src/__fixtures__/nested-workspaces/packages/foo/packages/baz/package.json index b941c80f..0a69ca46 100644 --- a/src/__fixtures__/nested-workspaces/packages/foo/packages/baz/package.json +++ b/src/__fixtures__/nested-workspaces/packages/foo/packages/baz/package.json @@ -1,6 +1,6 @@ { "name": "baz", - "version": "1.0.0", + "version": "1.0.1", "dependencies": { "react": "^15.6.1", "bar": "^1.0.0" diff --git a/src/__fixtures__/simple-project/packages/bar/package.json b/src/__fixtures__/simple-project/packages/bar/package.json index da86787a..06a8b8dc 100644 --- a/src/__fixtures__/simple-project/packages/bar/package.json +++ b/src/__fixtures__/simple-project/packages/bar/package.json @@ -1,4 +1,4 @@ { - "name": "foo", + "name": "bar", "version": "1.0.0" } diff --git a/src/__fixtures__/simple-project/packages/foo/package.json b/src/__fixtures__/simple-project/packages/foo/package.json index 06a8b8dc..da86787a 100644 --- a/src/__fixtures__/simple-project/packages/foo/package.json +++ b/src/__fixtures__/simple-project/packages/foo/package.json @@ -1,4 +1,4 @@ { - "name": "bar", + "name": "foo", "version": "1.0.0" } diff --git a/src/__tests__/Config.test.js b/src/__tests__/Config.test.js index b8df92f1..cdb1c4e5 100644 --- a/src/__tests__/Config.test.js +++ b/src/__tests__/Config.test.js @@ -113,17 +113,14 @@ describe('getProjectConfig()', () => { expect(found).toBe(null); }); - it.only( - 'should get the root if in nested package not included in a parent project', - async () => { - let fixturePath = await getFixturePath( - __dirname, - 'simple-project-with-excluded-package', - 'packages', - 'bar' - ); - let found = await Config.getProjectConfig(fixturePath); - expect(found).toBe(path.join(fixturePath, 'package.json')); - } - ); + it('should get the root if in nested package not included in a parent project', async () => { + let fixturePath = await getFixturePath( + __dirname, + 'simple-project-with-excluded-package', + 'packages', + 'bar' + ); + let found = await Config.getProjectConfig(fixturePath); + expect(found).toBe(path.join(fixturePath, 'package.json')); + }); }); diff --git a/src/commands/__tests__/add.test.js b/src/commands/__tests__/add.test.js index 845b23d5..f1984426 100644 --- a/src/commands/__tests__/add.test.js +++ b/src/commands/__tests__/add.test.js @@ -1,4 +1,237 @@ // @flow import { add, toAddOptions } from '../add'; +import { copyFixtureIntoTempDir } from 'jest-fixtures'; +import * as path from 'path'; +import * as processes from '../../utils/processes'; +import * as yarn from '../../utils/yarn'; +import * as fs from '../../utils/fs'; +import Package from '../../Package'; +import pathExists from 'path-exists'; -test('bolt add'); +jest.mock('../../utils/yarn'); +jest.mock('../../utils/logger'); + +const unsafeProcessses: any & typeof processes = processes; +const unsafeYarn: any & typeof yarn = yarn; + +// Helper method to check if a dependency is installed, both in the package.json and on the fs +async function depIsInstalled( + workspaceDir: string, + depName: string, + version?: string +) { + const pkg = await Package.init(path.join(workspaceDir, 'package.json')); + const dirExists = await pathExists( + path.join(workspaceDir, 'node_modules', depName) + ); + const depInPkgJson = pkg.getDependencyType(depName) !== null; + const correctVersion = + !version || pkg.getDependencyVersionRange(depName) === version; + + return dirExists && depInPkgJson && correctVersion; +} + +// a mock yarn add function to update the pakcages's config and also node_modules dir +async function fakeYarnAdd(pkg: Package, dependencies, type = 'dependencies') { + for (let dep of dependencies) { + await pkg.setDependencyVersionRange( + dep.name, + type, + dep.version || '^1.0.0' + ); + await fs.mkdirp(path.join(pkg.nodeModules, dep.name)); + } +} + +describe('bolt add', () => { + let projectDir; + let fooWorkspaceDir; + let barWorkspaceDir; + + beforeEach(async () => { + projectDir = await copyFixtureIntoTempDir( + __dirname, + 'package-with-external-deps-installed' + ); + fooWorkspaceDir = path.join(projectDir, 'packages', 'foo'); + barWorkspaceDir = path.join(projectDir, 'packages', 'bar'); + unsafeYarn.add.mockImplementation(fakeYarnAdd); + }); + + describe('from the project pkg', () => { + test('adding new package', async () => { + expect(await depIsInstalled(projectDir, 'new-dep')).toEqual(false); + await add( + toAddOptions(['new-dep'], { + cwd: projectDir + }) + ); + expect(yarn.add).toHaveBeenCalledTimes(1); + expect(await depIsInstalled(projectDir, 'new-dep')).toEqual(true); + }); + + test('adding existing package', async () => { + expect(await depIsInstalled(projectDir, 'project-only-dep')).toEqual( + true + ); + await add( + toAddOptions(['project-only-dep'], { + cwd: projectDir + }) + ); + expect(yarn.add).toHaveBeenCalledTimes(0); + }); + + test('adding new dependency with --dev flag', async () => { + expect(await depIsInstalled(projectDir, 'new-dep')).toEqual(false); + await add( + toAddOptions(['new-dep'], { + dev: true, // equivalent of passing --dev flag + cwd: projectDir + }) + ); + expect(yarn.add).toHaveBeenCalledTimes(1); + expect(yarn.add).toHaveBeenCalledWith( + expect.any(Package), + [{ name: 'new-dep' }], + 'devDependencies' + ); + expect(await depIsInstalled(projectDir, 'new-dep')).toEqual(true); + }); + + test('adding multiple new dependencies', async () => { + expect(await depIsInstalled(projectDir, 'new-dep')).toEqual(false); + expect(await depIsInstalled(projectDir, 'new-dep-2')).toEqual(false); + await add( + toAddOptions(['new-dep', 'new-dep-2'], { + cwd: projectDir + }) + ); + expect(yarn.add).toHaveBeenCalledTimes(1); + expect(yarn.add).toHaveBeenCalledWith( + expect.any(Package), + [{ name: 'new-dep' }, { name: 'new-dep-2' }], + 'dependencies' + ); + expect(await depIsInstalled(projectDir, 'new-dep')).toEqual(true); + expect(await depIsInstalled(projectDir, 'new-dep-2')).toEqual(true); + }); + + test('adding internal dep (should error)', async () => { + await expect( + add( + toAddOptions(['bar'], { + cwd: projectDir + }) + ) + ).rejects.toBeInstanceOf(Error); + + expect(yarn.add).toHaveBeenCalledTimes(0); + expect(await depIsInstalled(projectDir, 'bar')).toEqual(false); + }); + + test('adding new package at version', async () => { + expect(await depIsInstalled(projectDir, 'new-dep')).toEqual(false); + await add( + toAddOptions(['new-dep@^2.0.0'], { + cwd: projectDir + }) + ); + expect(yarn.add).toHaveBeenCalledTimes(1); + expect(await depIsInstalled(projectDir, 'new-dep', '^2.0.0')).toEqual( + true + ); + }); + }); + + describe('from a workspace', () => { + test('adding new package', async () => { + expect(await depIsInstalled(projectDir, 'new-dep')).toEqual(false); + expect(await depIsInstalled(fooWorkspaceDir, 'new-dep')).toEqual(false); + await add( + toAddOptions(['new-dep'], { + cwd: fooWorkspaceDir + }) + ); + expect(yarn.add).toHaveBeenCalledTimes(1); + expect(await depIsInstalled(projectDir, 'new-dep')).toEqual(true); + expect(await depIsInstalled(fooWorkspaceDir, 'new-dep')).toEqual(true); + }); + + test('adding existing package', async () => { + expect(await depIsInstalled(fooWorkspaceDir, 'project-only-dep')).toEqual( + false + ); + await add( + toAddOptions(['project-only-dep'], { + cwd: fooWorkspaceDir + }) + ); + expect(yarn.add).toHaveBeenCalledTimes(0); + expect(await depIsInstalled(fooWorkspaceDir, 'project-only-dep')).toEqual( + true + ); + }); + + test('adding new dependency with --dev flag', async () => { + expect(await depIsInstalled(fooWorkspaceDir, 'new-dep')).toEqual(false); + await add( + toAddOptions(['new-dep'], { + dev: true, // equivalent of passing --dev flag + cwd: fooWorkspaceDir + }) + ); + expect(yarn.add).toHaveBeenCalledTimes(1); + expect(yarn.add).toHaveBeenCalledWith( + expect.any(Package), + [{ name: 'new-dep' }], + 'devDependencies' + ); + expect(await depIsInstalled(fooWorkspaceDir, 'new-dep')).toEqual(true); + }); + + test('adding multiple new dependencies', async () => { + expect(await depIsInstalled(fooWorkspaceDir, 'new-dep')).toEqual(false); + expect(await depIsInstalled(fooWorkspaceDir, 'new-dep-2')).toEqual(false); + await add( + toAddOptions(['new-dep', 'new-dep-2'], { + cwd: fooWorkspaceDir + }) + ); + expect(yarn.add).toHaveBeenCalledTimes(1); + expect(yarn.add).toHaveBeenCalledWith( + expect.any(Package), + [{ name: 'new-dep' }, { name: 'new-dep-2' }], + 'dependencies' + ); + expect(await depIsInstalled(fooWorkspaceDir, 'new-dep')).toEqual(true); + expect(await depIsInstalled(fooWorkspaceDir, 'new-dep-2')).toEqual(true); + }); + + test('adding internal dep', async () => { + expect(await depIsInstalled(fooWorkspaceDir, 'bar')).toEqual(false); + + await add( + toAddOptions(['bar'], { + cwd: fooWorkspaceDir + }) + ); + + expect(yarn.add).toHaveBeenCalledTimes(0); + expect(await depIsInstalled(fooWorkspaceDir, 'bar')).toEqual(true); + }); + + test('adding new package at version', async () => { + expect(await depIsInstalled(fooWorkspaceDir, 'new-dep')).toEqual(false); + await add( + toAddOptions(['new-dep@^2.0.0'], { + cwd: fooWorkspaceDir + }) + ); + expect(yarn.add).toHaveBeenCalledTimes(1); + expect( + await depIsInstalled(fooWorkspaceDir, 'new-dep', '^2.0.0') + ).toEqual(true); + }); + }); +}); diff --git a/src/commands/__tests__/install.test.js b/src/commands/__tests__/install.test.js index 9b23aad7..5ea72c8c 100644 --- a/src/commands/__tests__/install.test.js +++ b/src/commands/__tests__/install.test.js @@ -1,34 +1,106 @@ // @flow -import {install, toInstallOptions} from '../install'; +import { install, toInstallOptions } from '../install'; import * as processes from '../../utils/processes'; +import * as yarn from '../../utils/yarn'; import * as path from 'path'; import * as fs from '../../utils/fs'; -import {getFixturePath} from 'jest-fixtures'; +import Project from '../../Project'; +import Workspace from '../../Workspace'; +import { getFixturePath, copyFixtureIntoTempDir } from 'jest-fixtures'; jest.mock('../../utils/processes'); +jest.mock('../../utils/yarn'); jest.mock('../../utils/logger'); jest.unmock('../install'); const unsafeProcesses: any & typeof processes = processes; +const unsafeYarn: any & typeof yarn = yarn; -describe('install', () => { - let readdirSpy; +async function assertNodeModulesExists(workspace: Workspace) { + const nodeModulesStat = await fs.stat(workspace.pkg.nodeModules); + const nodeModulesBinStat = await fs.stat(workspace.pkg.nodeModulesBin); + expect(nodeModulesStat.isDirectory()).toEqual(true); + expect(nodeModulesBinStat.isDirectory()).toEqual(true); +} - beforeEach(() => { - readdirSpy = jest.spyOn(fs, 'readdir').mockImplementation(() => Promise.resolve([])); - }); +async function assertDependenciesSymlinked(workspace: Workspace) { + const deps = workspace.pkg.getAllDependencies(); - afterEach(() => { - readdirSpy.mockRestore(); - }); + for (let dep of deps.keys()) { + const depStat = await fs.lstat(path.join(workspace.pkg.nodeModules, dep)); + expect(depStat.isSymbolicLink()).toBe(true); + } +} - test('simple-package', async () => { +describe('install', () => { + test('simple-package, should run yarn install at the root', async () => { let cwd = await getFixturePath(__dirname, 'simple-package'); await install(toInstallOptions([], { cwd })); + expect(unsafeProcesses.spawn).toHaveBeenCalledTimes(1); expect(unsafeProcesses.spawn).toHaveBeenCalledWith( 'yarn', ['install', '--non-interactive', '-s'], { cwd } ); }); + + test('should still run yarn install at the root when called from ws', async () => { + let rootDir = await getFixturePath(__dirname, 'simple-project'); + let cwd = path.join(path.join(rootDir, 'packages', 'foo')); + await install(toInstallOptions([], { cwd })); + expect(unsafeProcesses.spawn).toHaveBeenCalledTimes(1); + expect(unsafeProcesses.spawn).toHaveBeenCalledWith( + 'yarn', + ['install', '--non-interactive', '-s'], + { cwd: rootDir } + ); + }); + + test('should work in project with scoped packages', async () => { + let cwd = await getFixturePath( + __dirname, + 'nested-workspaces-with-scoped-package-names' + ); + const project = await Project.init(cwd); + const workspaces = await project.getWorkspaces(); + + await install(toInstallOptions([], { cwd })); + + for (let workspace of workspaces) { + assertNodeModulesExists(workspace); + assertDependenciesSymlinked(workspace); + } + }); + + test('should run preinstall, postinstall and prepublish in each ws', async () => { + let cwd = await getFixturePath(__dirname, 'simple-project'); + const project = await Project.init(cwd); + const workspaces = await project.getWorkspaces(); + + await install(toInstallOptions([], { cwd })); + + for (let workspace of workspaces) { + expect(unsafeYarn.run).toHaveBeenCalledWith(workspace.pkg, 'preinstall'); + expect(unsafeYarn.run).toHaveBeenCalledWith(workspace.pkg, 'postinstall'); + expect(unsafeYarn.run).toHaveBeenCalledWith(workspace.pkg, 'prepublish'); + } + }); + + // This is re-testing symlinkPackageDependencies, but we'd rather be explicit here + test('should install (symlink) all deps in workspaces', async () => { + const cwd = await copyFixtureIntoTempDir( + __dirname, + 'nested-workspaces-with-root-dependencies-installed' + ); + const project = await Project.init(cwd); + const workspaces = await project.getWorkspaces(); + + await install(toInstallOptions([], { cwd })); + + for (let workspace of workspaces) { + assertNodeModulesExists(workspace); + assertDependenciesSymlinked(workspace); + // TODO: assertBinfileSymlinked (currently tested partially in symlinkPackageDependencies) + } + }); }); diff --git a/src/commands/add.js b/src/commands/add.js index c56aacf5..8975eeee 100644 --- a/src/commands/add.js +++ b/src/commands/add.js @@ -1,16 +1,48 @@ // @flow +import Project from '../Project'; +import Package from '../Package'; import * as options from '../utils/options'; -import { BoltError } from '../utils/errors'; +import * as logger from '../utils/logger'; +import addDependenciesToPackage from '../utils/addDependenciesToPackages'; +import type { Dependency, configDependencyType } from '../types'; +import { DEPENDENCY_TYPE_FLAGS_MAP } from '../constants'; -export type AddOptions = {}; +export type AddOptions = { + cwd?: string, + deps: Array, + type: configDependencyType +}; export function toAddOptions( args: options.Args, flags: options.Flags ): AddOptions { - return {}; + const depsArgs = []; + let type = 'dependencies'; + + // args is each of our dependencies we are adding + args.forEach(dep => { + const [name, version] = dep.split('@'); + depsArgs.push(version ? { name, version } : { name }); + }); + + Object.keys(DEPENDENCY_TYPE_FLAGS_MAP).forEach(depTypeFlag => { + if (flags[depTypeFlag]) { + type = DEPENDENCY_TYPE_FLAGS_MAP[depTypeFlag]; + } + }); + + return { + cwd: options.string(flags.cwd, 'cwd'), + deps: depsArgs, + type + }; } export async function add(opts: AddOptions) { - throw new BoltError('Unimplemented command "add"'); + let cwd = opts.cwd || process.cwd(); + let project = await Project.init(cwd); + let pkg = await Package.closest(cwd); + + await addDependenciesToPackage(project, pkg, opts.deps, opts.type); } diff --git a/src/commands/install.js b/src/commands/install.js index a8ae994e..66b45c0b 100644 --- a/src/commands/install.js +++ b/src/commands/install.js @@ -6,6 +6,7 @@ import * as fs from '../utils/fs'; import * as path from 'path'; import * as logger from '../utils/logger'; import * as messages from '../utils/messages'; +import symlinkPackageDependencies from '../utils/symlinkPackageDependencies'; import * as yarn from '../utils/yarn'; import pathIsInside from 'path-is-inside'; import { BoltError } from '../utils/errors'; @@ -28,89 +29,6 @@ export async function install(opts: InstallOptions) { let project = await Project.init(cwd); let workspaces = await project.getWorkspaces(); - let { - graph: dependencyGraph, - valid: dependencyGraphValid - } = await project.getDependencyGraph(workspaces); - - let projectDependencies = project.pkg.getAllDependencies(); - - let directoriesToCreate = []; - let symlinksToCreate = []; - let valid = true; - - let workspacesToDependencies = {}; - - for (let workspace of workspaces) { - let dependencies = workspace.pkg.getAllDependencies(); - - workspacesToDependencies[workspace.pkg.config.getName()] = dependencies; - - directoriesToCreate.push(workspace.pkg.nodeModules); - directoriesToCreate.push(workspace.pkg.nodeModulesBin); - - for (let [name, version] of dependencies) { - let matched = projectDependencies.get(name); - - if (dependencyGraph.has(name)) { - continue; - } - - if (!matched) { - valid = false; - logger.error( - messages.depMustBeAddedToProject(workspace.pkg.config.getName(), name) - ); - continue; - } - - if (version !== matched) { - valid = false; - logger.error( - messages.depMustMatchProject( - workspace.pkg.config.getName(), - name, - matched, - version - ) - ); - continue; - } - - let src = path.join(project.pkg.nodeModules, name); - let dest = path.join(workspace.pkg.nodeModules, name); - - symlinksToCreate.push({ src, dest, type: 'junction' }); - } - } - - for (let [name, node] of dependencyGraph) { - let nodeModules = path.join(node.pkg.dir, 'node_modules'); - - for (let dependency of node.dependencies) { - let depWorkspace = dependencyGraph.get(dependency); - - if (!depWorkspace) { - throw new BoltError(`Missing workspace: "${dependency}"`); - } - - let src = depWorkspace.pkg.dir; - let dest = path.join(nodeModules, dependency); - - symlinksToCreate.push({ src, dest, type: 'junction' }); - } - } - - if (!dependencyGraphValid || !valid) { - throw new BoltError('Cannot symlink invalid set of dependencies.'); - } - - await Project.runWorkspaceTasks(workspaces, async workspace => { - if (await yarn.getScript(workspace.pkg, 'preinstall')) { - await yarn.run(workspace.pkg, 'preinstall'); - } - }); - logger.info(messages.installingProjectDependencies(), { emoji: '📦', prefix: false @@ -125,71 +43,10 @@ export async function install(opts: InstallOptions) { prefix: false }); - for (let binFile of await fs.readdirSafe(project.pkg.nodeModulesBin)) { - let binPath = path.join(project.pkg.nodeModulesBin, binFile); - let binName = path.basename(binPath); - - let linkFile = await fs.readlink(binPath); - - if (!linkFile) { - throw new BoltError(`${binName} is not a symlink`); - } - - let linkPath = path.join(project.pkg.nodeModulesBin, linkFile); - - if (!pathIsInside(linkPath, project.pkg.nodeModules)) { - throw new BoltError( - `${binName} is linked to a location outside of project node_modules: ${linkPath}` - ); - } - - let relativeLinkPath = path.relative(project.pkg.nodeModules, linkPath); - let pathParts = relativeLinkPath.split(path.sep); - let pkgName = pathParts[0]; - - if (pkgName.startsWith('@')) { - pkgName += '/' + pathParts[1]; - } - - for (let workspace of workspaces) { - let dependencies = - workspacesToDependencies[workspace.pkg.config.getName()]; - - if (!dependencies.has(pkgName)) { - continue; - } - - let workspaceBinPath = path.join(workspace.pkg.nodeModulesBin, binName); - - symlinksToCreate.push({ - src: binPath, - dest: workspaceBinPath, - type: 'exec' - }); - } + for (let workspace of workspaces) { + const dependencies = workspace.pkg.getAllDependencies().keys(); + await symlinkPackageDependencies(project, workspace.pkg, dependencies); } - await Promise.all( - directoriesToCreate.map(dirName => { - return fs.mkdirp(dirName); - }) - ); - - await Promise.all( - symlinksToCreate.map(async ({ src, dest, type }) => { - await fs.symlink(src, dest, type); - }) - ); - - await Project.runWorkspaceTasks(workspaces, async workspace => { - if (await yarn.getScript(workspace.pkg, 'postinstall')) { - await yarn.run(workspace.pkg, 'postinstall'); - } - - if (await yarn.getScript(workspace.pkg, 'prepublish')) { - await yarn.run(workspace.pkg, 'prepublish'); - } - }); - logger.success(messages.installedAndLinkedWorkspaces(), { emoji: '💥' }); } diff --git a/src/commands/workspace/__tests__/add.test.js b/src/commands/workspace/__tests__/add.test.js index d7034ee1..91af5cbc 100644 --- a/src/commands/workspace/__tests__/add.test.js +++ b/src/commands/workspace/__tests__/add.test.js @@ -1,4 +1,104 @@ // @flow import { workspaceAdd, toWorkspaceAddOptions } from '../add'; +import { copyFixtureIntoTempDir } from 'jest-fixtures'; +import * as path from 'path'; +import * as processes from '../../../utils/processes'; +import * as yarn from '../../../utils/yarn'; +import * as fs from '../../../utils/fs'; +import Package from '../../../Package'; +import pathExists from 'path-exists'; -test('bolt workspace add'); +jest.mock('../../../utils/yarn'); +jest.mock('../../../utils/logger'); + +const unsafeProcessses: any & typeof processes = processes; +const unsafeYarn: any & typeof yarn = yarn; + +async function depIsInstalled(workspaceDir: string, depName: string) { + const pkg = await Package.init(path.join(workspaceDir, 'package.json')); + const dirExists = await pathExists( + path.join(workspaceDir, 'node_modules', depName) + ); + const depInPkgJson = pkg.getDependencyType(depName) !== null; + + return dirExists && depInPkgJson; +} + +describe('bolt workspace add', () => { + let projectDir; + let fooWorkspaceDir; + let barWorkspaceDir; + + beforeEach(async () => { + projectDir = await copyFixtureIntoTempDir( + __dirname, + 'package-with-external-deps-installed' + ); + fooWorkspaceDir = path.join(projectDir, 'packages', 'foo'); + barWorkspaceDir = path.join(projectDir, 'packages', 'bar'); + }); + + test('running from project, installing dependency that is in project', async () => { + expect(await depIsInstalled(fooWorkspaceDir, 'project-only-dep')).toEqual( + false + ); + await workspaceAdd( + toWorkspaceAddOptions(['foo', 'project-only-dep'], { + cwd: projectDir + }) + ); + expect(yarn.add).toHaveBeenCalledTimes(0); + expect(await depIsInstalled(fooWorkspaceDir, 'project-only-dep')).toEqual( + true + ); + }); + + test('running from a different workspace, installing dependency that is in project', async () => { + expect(await depIsInstalled(fooWorkspaceDir, 'project-only-dep')).toEqual( + false + ); + await workspaceAdd( + toWorkspaceAddOptions(['foo', 'project-only-dep'], { + cwd: barWorkspaceDir + }) + ); + expect(yarn.add).toHaveBeenCalledTimes(0); + expect(await depIsInstalled(fooWorkspaceDir, 'project-only-dep')).toEqual( + true + ); + }); + + test('running from project, installing dep not in project', async () => { + // yarn add is mocked, so we need to update the package json and create a dir in node_modules + // ourselves (the tasks `yarn add` would normally do) + unsafeYarn.add.mockImplementationOnce(async (pkg: Package) => { + await pkg.setDependencyVersionRange('new-dep', 'dependencies', '^1.0.0'); + await fs.mkdirp(path.join(pkg.nodeModules, 'new-dep')); + }); + expect(await depIsInstalled(fooWorkspaceDir, 'new-dep')).toEqual(false); + await workspaceAdd( + toWorkspaceAddOptions(['foo', 'new-dep'], { + cwd: projectDir + }) + ); + expect(yarn.add).toHaveBeenCalledTimes(1); + expect(await depIsInstalled(fooWorkspaceDir, 'new-dep')).toEqual(true); + }); + + test('running from a different workspace, installing dep not in project', async () => { + // yarn add is mocked, so we need to update the package json and create a dir in node_modules + // ourselves (the tasks `yarn add` would normally do) + unsafeYarn.add.mockImplementationOnce(async (pkg: Package) => { + await pkg.setDependencyVersionRange('new-dep', 'dependencies', '^1.0.0'); + await fs.mkdirp(path.join(pkg.nodeModules, 'new-dep')); + }); + expect(await depIsInstalled(fooWorkspaceDir, 'new-dep')).toEqual(false); + await workspaceAdd( + toWorkspaceAddOptions(['foo', 'new-dep'], { + cwd: barWorkspaceDir + }) + ); + expect(yarn.add).toHaveBeenCalledTimes(1); + expect(await depIsInstalled(fooWorkspaceDir, 'new-dep')).toEqual(true); + }); +}); diff --git a/src/commands/workspace/add.js b/src/commands/workspace/add.js index 10c84d49..2a37fffc 100644 --- a/src/commands/workspace/add.js +++ b/src/commands/workspace/add.js @@ -1,20 +1,61 @@ // @flow +import Project from '../../Project'; +import Package from '../../Package'; import * as options from '../../utils/options'; +import * as logger from '../../utils/logger'; +import addDependenciesToPackage from '../../utils/addDependenciesToPackages'; import { BoltError } from '../../utils/errors'; +import type { Dependency, configDependencyType } from '../../types'; +import { DEPENDENCY_TYPE_FLAGS_MAP } from '../../constants'; export type WorkspaceAddOptions = { - cwd?: string + cwd?: string, + workspaceName: string, + deps: Array, + type: configDependencyType }; export function toWorkspaceAddOptions( args: options.Args, flags: options.Flags ): WorkspaceAddOptions { + let [workspaceName, ...deps] = args; + const depsArgs = []; + let type = 'dependencies'; + + deps.forEach(dep => { + const [name, version] = dep.split('@'); + depsArgs.push(version ? { name, version } : { name }); + }); + + Object.keys(DEPENDENCY_TYPE_FLAGS_MAP).forEach(depTypeFlag => { + if (flags[depTypeFlag]) { + type = DEPENDENCY_TYPE_FLAGS_MAP[depTypeFlag]; + } + }); + return { - cwd: options.string(flags.cwd, 'cwd') + cwd: options.string(flags.cwd, 'cwd'), + workspaceName, + deps: depsArgs, + type }; } export async function workspaceAdd(opts: WorkspaceAddOptions) { - throw new BoltError('Unimplemented command "workspace add"'); + let cwd = opts.cwd || process.cwd(); + let project = await Project.init(cwd); + let workspaces = await project.getWorkspaces(); + let workspace = await project.getWorkspaceByName( + workspaces, + opts.workspaceName + ); + + if (!workspace) { + throw new BoltError( + `Could not find a workspace named "${opts.workspaceName}" from "${cwd}"` + ); + } + + await addDependenciesToPackage(project, workspace.pkg, opts.deps, opts.type); } diff --git a/src/constants.js b/src/constants.js index 7ce4f652..9aae3b5a 100644 --- a/src/constants.js +++ b/src/constants.js @@ -7,3 +7,12 @@ export const DEPENDENCY_TYPES = [ 'bundledDependencies', 'optionalDependencies' ]; + +export const DEPENDENCY_TYPE_FLAGS_MAP = { + dev: 'devDependencies', + peer: 'peerDependencies', + optional: 'optionalDependencies', + D: 'devDependencies', + P: 'peerDependencies', + O: 'optionalDependencies' +}; diff --git a/src/types.js b/src/types.js index b20bf4f4..578208f4 100644 --- a/src/types.js +++ b/src/types.js @@ -22,3 +22,14 @@ export type FilterOpts = { onlyFs?: string, ignoreFs?: string }; + +export type Dependency = { + name: string, + version?: string +}; + +export type configDependencyType = + | 'dependencies' + | 'devDependencies' + | 'peerDependencies' + | 'optionalDependencies'; diff --git a/src/utils/__tests__/addDependenciesToPackages.test.js b/src/utils/__tests__/addDependenciesToPackages.test.js new file mode 100644 index 00000000..c6199773 --- /dev/null +++ b/src/utils/__tests__/addDependenciesToPackages.test.js @@ -0,0 +1,180 @@ +// @flow + +import { copyFixtureIntoTempDir } from 'jest-fixtures'; +import path from 'path'; + +import addDependenciesToPackage from '../addDependenciesToPackages'; +import * as symlinkPackageDependencies from '../symlinkPackageDependencies'; +import * as yarn from '../yarn'; +import Project from '../../Project'; +import Package from '../../Package'; +import Config from '../../Config'; + +jest.mock('../yarn'); +jest.mock('../logger'); + +const unsafeYarn: any & typeof yarn = yarn; + +// Mock yarn.add to make it update the packages config when called +function fakeYarnAdd(pkg, dependencies, type = 'dependencies') { + pkg.config.json[type] = pkg.config.json[type] || {}; + + dependencies.forEach(dep => { + pkg.config.json[type][dep.name] = dep.version || '^1.0.0'; + }); +} + +function assertSingleYarnAddCall(expectedPkg, expectedDeps) { + const yarnAddCalls = unsafeYarn.add.mock.calls; + + expect(yarnAddCalls.length).toEqual(1); + expect(yarnAddCalls[0][0]).toEqual(expectedPkg); + expect(yarnAddCalls[0][1]).toEqual(expectedDeps); +} + +describe('utils/addDependenciesToPackages', () => { + let symlinkSpy; + + beforeEach(() => { + unsafeYarn.add.mockImplementation(fakeYarnAdd); + symlinkSpy = jest.spyOn(symlinkPackageDependencies, 'default'); + }); + + test('it should just yarn add at the root when run from the root of a project', async () => { + const cwd = await copyFixtureIntoTempDir(__dirname, 'nested-workspaces'); + const project = await Project.init(cwd); + + await addDependenciesToPackage(project, project.pkg, [{ name: 'chalk' }]); + + expect(unsafeYarn.add).toHaveBeenCalledTimes(1); + expect(unsafeYarn.add).toHaveBeenCalledWith( + project.pkg, + [{ name: 'chalk' }], + 'dependencies' + ); + }); + + test('it should still run yarn add at the root when run from another package', async () => { + const rootDir = await copyFixtureIntoTempDir( + __dirname, + 'nested-workspaces' + ); + const project = await Project.init(rootDir); + const workspaces = await project.getWorkspaces(); + const wsToRunIn = project.getWorkspaceByName(workspaces, 'foo') || {}; + + await addDependenciesToPackage(project, wsToRunIn.pkg, [{ name: 'chalk' }]); + + expect(unsafeYarn.add).toHaveBeenCalledTimes(1); + expect(unsafeYarn.add).toHaveBeenCalledWith( + project.pkg, + [{ name: 'chalk' }], + 'dependencies' + ); + }); + + test('it should not yarn install packages that are already installed', async () => { + const cwd = await copyFixtureIntoTempDir(__dirname, 'nested-workspaces'); + const project = await Project.init(cwd); + + await addDependenciesToPackage(project, project.pkg, [{ name: 'react' }]); + + expect(unsafeYarn.add).toHaveBeenCalledTimes(0); + }); + + test('it should throw if version does not match version in project config', async () => { + const cwd = await copyFixtureIntoTempDir(__dirname, 'nested-workspaces'); + const project = await Project.init(cwd); + const workspaces = await project.getWorkspaces(); + const wsToAddTo = project.getWorkspaceByName(workspaces, 'foo') || {}; + + await expect( + addDependenciesToPackage(project, wsToAddTo.pkg, [ + { name: 'left-pad', version: '2.0.0' } + ]) + ).rejects.toBeInstanceOf(Error); + }); + + test('should call symlinkPackageDependencies to symlink dependencies in workspace', async () => { + const cwd = await copyFixtureIntoTempDir( + __dirname, + 'package-with-external-deps-installed' + ); + const project = await Project.init(cwd); + const workspaces = await project.getWorkspaces(); + const wsToAddTo = project.getWorkspaceByName(workspaces, 'foo') || {}; + + await addDependenciesToPackage(project, wsToAddTo.pkg, [ + { name: 'project-only-dep' } + ]); + + expect(symlinkSpy).toHaveBeenCalledWith(project, wsToAddTo.pkg, [ + 'project-only-dep' + ]); + }); + + test('should update packages dependencies in package config', async () => { + const cwd = await copyFixtureIntoTempDir( + __dirname, + 'package-with-external-deps-installed' + ); + const project = await Project.init(cwd); + const workspaces = await project.getWorkspaces(); + const wsToAddTo = project.getWorkspaceByName(workspaces, 'foo') || {}; + + expect(wsToAddTo.pkg.getDependencyVersionRange('project-only-dep')).toEqual( + null + ); + await addDependenciesToPackage(project, wsToAddTo.pkg, [ + { name: 'project-only-dep' } + ]); + + expect(symlinkSpy).toHaveBeenCalledWith(project, wsToAddTo.pkg, [ + 'project-only-dep' + ]); + expect(wsToAddTo.pkg.getDependencyVersionRange('project-only-dep')).toEqual( + '^1.0.0' + ); + }); + + describe('when adding internal package', () => { + test('should set version as current version of internal package', async () => { + // i.e if we have bar 1.0.0 installed locally addDependenciesToPackages should add ^1.0.0 + const cwd = await copyFixtureIntoTempDir(__dirname, 'nested-workspaces'); + const project = await Project.init(cwd); + const workspaces = await project.getWorkspaces(); + const wsToAddTo = project.getWorkspaceByName(workspaces, 'bar') || {}; + + expect(wsToAddTo.pkg.getDependencyVersionRange('baz')).toEqual(null); + await addDependenciesToPackage(project, wsToAddTo.pkg, [{ name: 'baz' }]); + + expect(symlinkSpy).toHaveBeenCalledWith(project, wsToAddTo.pkg, ['baz']); + expect(wsToAddTo.pkg.getDependencyVersionRange('baz')).toEqual('^1.0.1'); + }); + + test('should throw if attempting to set to version that doesnt match local', async () => { + const cwd = await copyFixtureIntoTempDir(__dirname, 'nested-workspaces'); + const project = await Project.init(cwd); + const workspaces = await project.getWorkspaces(); + const wsToAddTo = project.getWorkspaceByName(workspaces, 'bar') || {}; + + await expect( + addDependenciesToPackage(project, wsToAddTo.pkg, [ + { name: 'baz', version: '^1.0.0' } + ]) + ).rejects.toBeInstanceOf(Error); + }); + + test('should not add internal to root package', async () => { + const cwd = await copyFixtureIntoTempDir(__dirname, 'nested-workspaces'); + const project = await Project.init(cwd); + const workspaces = await project.getWorkspaces(); + const wsToAddTo = project.getWorkspaceByName(workspaces, 'bar') || {}; + + expect(project.pkg.getDependencyVersionRange('baz')).toEqual(null); + await addDependenciesToPackage(project, wsToAddTo.pkg, [{ name: 'baz' }]); + expect(project.pkg.getDependencyVersionRange('baz')).toEqual(null); + expect(unsafeYarn.add).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/src/utils/__tests__/symlinkPackageDependencies.test.js b/src/utils/__tests__/symlinkPackageDependencies.test.js new file mode 100644 index 00000000..facd1c2f --- /dev/null +++ b/src/utils/__tests__/symlinkPackageDependencies.test.js @@ -0,0 +1,163 @@ +// @flow +import path from 'path'; +import { copyFixtureIntoTempDir } from 'jest-fixtures'; + +import symlinkPackageDependencies from '../symlinkPackageDependencies'; +import * as fs from '../fs'; +import * as yarn from '../yarn'; +import Project from '../../Project'; + +jest.mock('../yarn'); + +const unsafeFs: any & typeof fs = fs; +const unsafeYarn: any & typeof yarn = yarn; + +async function dirExists(dir: string) { + try { + const stat = await fs.stat(dir); + return stat.isDirectory(); + } catch (err) { + return false; + } +} + +async function symlinkExists(dir: string, symlink: string) { + try { + const stat = await fs.lstat(path.join(dir, symlink)); + return stat.isSymbolicLink(); + } catch (err) { + return false; + } +} + +describe('utils/symlinkPackageDependencies()', () => { + let project; + let workspaces; + let pkgToSymlink; + let nodeModules; + let nodeModulesBin; + + beforeEach(async () => { + const tempDir = await copyFixtureIntoTempDir( + __dirname, + 'nested-workspaces-with-root-dependencies-installed' + ); + project = await Project.init(tempDir); + workspaces = await project.getWorkspaces(); + }); + + /******************** + * Linking packages * + ********************/ + + describe('linking packages', () => { + beforeEach(() => { + // We use the foo package as it has internal and external dependencies + const workspaceToSymlink = + project.getWorkspaceByName(workspaces, 'foo') || {}; + pkgToSymlink = workspaceToSymlink.pkg; + nodeModules = pkgToSymlink.nodeModules; + nodeModulesBin = pkgToSymlink.nodeModulesBin; + }); + + it('should create node modules and node_modules/.bin if not existing', async () => { + expect(await dirExists(pkgToSymlink.nodeModules)).toEqual(false); + expect(await dirExists(pkgToSymlink.nodeModulesBin)).toEqual(false); + + await symlinkPackageDependencies(project, pkgToSymlink, ['bar']); + + expect(await dirExists(pkgToSymlink.nodeModules)).toEqual(true); + expect(await dirExists(pkgToSymlink.nodeModulesBin)).toEqual(true); + }); + + it('should symlink external dependencies (only ones passed in)', async () => { + expect(await dirExists(path.join(nodeModules, 'external-dep'))).toEqual( + false + ); + + await symlinkPackageDependencies(project, pkgToSymlink, ['external-dep']); + + expect(await dirExists(path.join(nodeModules, 'external-dep'))).toEqual( + true + ); + }); + + it('should symlink internal dependencies', async () => { + expect(await dirExists(path.join(nodeModules, 'bar'))).toEqual(false); + + await symlinkPackageDependencies(project, pkgToSymlink, ['bar']); + + expect(await dirExists(path.join(nodeModules, 'bar'))).toEqual(true); + expect(await symlinkExists(nodeModules, 'bar')).toEqual(true); + }); + + it('should run correct lifecycle scripts', async () => { + await symlinkPackageDependencies(project, pkgToSymlink, ['bar']); + + expect(yarn.run).toHaveBeenCalledTimes(4); + const yarnCalls = unsafeYarn.run.mock.calls; + expect(yarnCalls[0][1]).toEqual('preinstall'); + expect(yarnCalls[1][1]).toEqual('postinstall'); + expect(yarnCalls[2][1]).toEqual('prepublish'); + expect(yarnCalls[3][1]).toEqual('prepare'); + }); + }); + + describe('linking binaries', () => { + beforeEach(() => { + // We use the zee package as it has internal and external dependencies that have various + // kinds of bin set ups + const workspaceToSymlink = + project.getWorkspaceByName(workspaces, 'zee') || {}; + pkgToSymlink = workspaceToSymlink.pkg; + nodeModules = pkgToSymlink.nodeModules; + nodeModulesBin = pkgToSymlink.nodeModulesBin; + }); + + it('should symlink external dependencies bin files (when declared using strings)', async () => { + expect( + await symlinkExists(nodeModulesBin, 'external-dep-with-bins') + ).toEqual(false); + + await symlinkPackageDependencies(project, pkgToSymlink, [ + 'external-dep-with-bin' + ]); + + expect( + await symlinkExists(nodeModulesBin, 'external-dep-with-bin') + ).toEqual(true); + }); + + it('should symlink external dependencies bin files (when declared using object)', async () => { + expect( + await symlinkExists(nodeModulesBin, 'external-dep-two-bins-1') + ).toEqual(false); + + await symlinkPackageDependencies(project, pkgToSymlink, [ + 'external-dep-with-two-bins' + ]); + + expect( + await symlinkExists(nodeModulesBin, 'external-dep-two-bins-1') + ).toEqual(true); + }); + }); + + it('should symlink internal dependencies bin files (when declared using string)', async () => { + expect(await symlinkExists(nodeModulesBin, 'bar')).toEqual(false); + + await symlinkPackageDependencies(project, pkgToSymlink, ['bar']); + + expect(await symlinkExists(nodeModulesBin, 'bar')).toEqual(true); + }); + + it('should symlink internal dependencies bin files (when declared using object)', async () => { + expect(await symlinkExists(nodeModulesBin, 'baz-1')).toEqual(false); + expect(await symlinkExists(nodeModulesBin, 'baz-2')).toEqual(false); + + await symlinkPackageDependencies(project, pkgToSymlink, ['baz']); + + expect(await symlinkExists(nodeModulesBin, 'baz-1')).toEqual(true); + expect(await symlinkExists(nodeModulesBin, 'baz-2')).toEqual(true); + }); +}); diff --git a/src/utils/__tests__/yarn.test.js b/src/utils/__tests__/yarn.test.js index 85a6b2e2..c2e59a5a 100644 --- a/src/utils/__tests__/yarn.test.js +++ b/src/utils/__tests__/yarn.test.js @@ -1,7 +1,64 @@ // @flow + +import { getFixturePath } from 'jest-fixtures'; + import * as yarn from '../yarn'; +import * as processes from '../processes'; +import Project from '../../Project'; + +jest.mock('../processes'); + +const unsafeProcesses: any & typeof processes = processes; + +function assertSpawnCalls(expectedArgs, expectedCwd) { + const spawnCalls = unsafeProcesses.spawn.mock.calls; + + expect(spawnCalls.length).toEqual(1); + expect(spawnCalls[0][0]).toEqual('yarn'); + expect(spawnCalls[0][1]).toEqual(expectedArgs); + expect(spawnCalls[0][2].cwd).toEqual(expectedCwd); +} + +describe('utils/yarn', () => { + describe('add()', () => { + let cwd; + let project; + + beforeEach(async () => { + cwd = await getFixturePath(__dirname, 'simple-project'); + project = await Project.init(cwd); + }); + + it('should be able to add a dependency', async () => { + await yarn.add(project.pkg, [{ name: 'chalk' }]); + assertSpawnCalls(['add', 'chalk'], cwd); + }); + + it('should be able to add a dev dependency', async () => { + await yarn.add(project.pkg, [{ name: 'chalk' }], 'devDependencies'); + assertSpawnCalls(['add', 'chalk', '--dev'], cwd); + }); + + it('should be able to add a peer dependency', async () => { + await yarn.add(project.pkg, [{ name: 'chalk' }], 'peerDependencies'); + assertSpawnCalls(['add', 'chalk', '--peer'], cwd); + }); + + it('should be able to add an optional dependency', async () => { + await yarn.add(project.pkg, [{ name: 'chalk' }], 'optionalDependencies'); + assertSpawnCalls(['add', 'chalk', '--optional'], cwd); + }); + + it('should be able to add dependency with versions', async () => { + await yarn.add(project.pkg, [{ name: 'chalk', version: '^1.0.0' }]); + assertSpawnCalls(['add', 'chalk@^1.0.0'], cwd); + }); -describe('yarn', () => { - test('run()'); - test('init()'); + it('should be able to add multiple dependencies', async () => { + await yarn.add(project.pkg, [{ name: 'chalk' }, { name: 'left-pad' }]); + assertSpawnCalls(['add', 'chalk', 'left-pad'], cwd); + }); + }); + describe('run()', () => {}); + describe('init()', () => {}); }); diff --git a/src/utils/addDependenciesToPackages.js b/src/utils/addDependenciesToPackages.js new file mode 100644 index 00000000..285a0e76 --- /dev/null +++ b/src/utils/addDependenciesToPackages.js @@ -0,0 +1,86 @@ +// @flow + +import Project from '../Project'; +import type Workspace from '../Workspace'; +import type Package from '../Package'; +import type { Dependency, configDependencyType } from '../types'; +import * as messages from './messages'; +import { BoltError } from './errors'; +import * as logger from './logger'; +import * as yarn from './yarn'; +import symlinkPackageDependencies from './symlinkPackageDependencies'; + +export default async function addDependenciesToPackage( + project: Project, + pkg: Package, + dependencies: Array, + type?: configDependencyType = 'dependencies' +) { + const workspaces = await project.getWorkspaces(); + const projectDependencies = project.pkg.getAllDependencies(); + const pkgDependencies = pkg.getAllDependencies(); + const { graph: depGraph } = await project.getDependencyGraph(workspaces); + + const dependencyNames = dependencies.map(dep => dep.name); + const externalDeps = dependencies.filter(dep => !depGraph.has(dep.name)); + const internalDeps = dependencies.filter(dep => depGraph.has(dep.name)); + + const externalDepsToInstallForProject = externalDeps.filter( + dep => !projectDependencies.has(dep.name) + ); + if (externalDepsToInstallForProject.length !== 0) { + await yarn.add(project.pkg, externalDepsToInstallForProject, type); + // we reinitialise the project config because it will be modified externally by yarn + project = await Project.init(project.pkg.dir); + } + + if (pkg.isSamePackage(project.pkg)) { + if (internalDeps.length > 0) { + throw new BoltError( + messages.cannotInstallWorkspaceInProject(internalDeps[0].name) + ); + } + return true; + } + + const installedVersions = {}; + + for (let dep of externalDeps) { + const installed = project.pkg.getDependencyVersionRange(dep.name); + const depVersion = dep.version; + if (depVersion && depVersion !== installed) { + throw new BoltError( + messages.depMustMatchProject( + pkg.config.getName(), + dep.name, + installed, + depVersion + ) + ); + } + installedVersions[dep.name] = String(installed); + } + + for (let dep of internalDeps) { + const dependencyPkg = (depGraph.get(dep.name) || {}).pkg; + const requestedVersion = dep.version; + const internalVersion = dependencyPkg.config.getVersion(); + if (requestedVersion && requestedVersion !== internalVersion) { + throw new BoltError( + messages.packageMustDependOnCurrentVersion( + pkg.config.getName(), + dep.name, + internalVersion, + requestedVersion + ) + ); + } + installedVersions[dep.name] = `^${internalVersion}`; + } + + for (let [depName, depVersion] of Object.entries(installedVersions)) { + await pkg.setDependencyVersionRange(depName, type, String(depVersion)); + } + + await symlinkPackageDependencies(project, pkg, dependencyNames); +} diff --git a/src/utils/messages.js b/src/utils/messages.js index 8e2f1408..c578505f 100644 --- a/src/utils/messages.js +++ b/src/utils/messages.js @@ -1,6 +1,7 @@ // @flow import chalk from 'chalk'; import type Workspace from '../Workspace'; +import type Package from '../Package'; /*:: export opaque type Message = string; @@ -141,6 +142,13 @@ export function couldntRemoveDependencies(deps: Array): Message { .join('\n')}`; } +export function couldntSymlinkDependencyNotExists( + pkgName: string, + depName: string +): Message { + return `Could not symlink ${depName} in ${pkgName} as no dependency exists`; +} + export function doneInSeconds(rounded: number): Message { return `Done in ${rounded}s.`; } @@ -247,3 +255,7 @@ export function couldNotBeNormalized(): Message { export function installedAndLinkedWorkspaces(): Message { return 'Installed and linked workspaces.'; } + +export function cannotInstallWorkspaceInProject(pkgName: string): Message { + return `Cannot install workspace "${pkgName}" as a dependency of a project`; +} diff --git a/src/utils/processes.js b/src/utils/processes.js index a8cc159a..7ac1032b 100644 --- a/src/utils/processes.js +++ b/src/utils/processes.js @@ -38,13 +38,13 @@ type SpawnOptions = { pkg?: Package, silent?: boolean, tty?: boolean, - env?: {[key: string]: ?string}, + env?: { [key: string]: ?string } }; export function spawn( cmd: string, args: Array, - opts: SpawnOptions = {}, + opts: SpawnOptions = {} ) { return limit( () => @@ -56,7 +56,7 @@ export function spawn( let spawnOpts: child_process$spawnOpts = { cwd: opts.cwd, - env: opts.env || process.env, + env: opts.env || process.env }; if (opts.tty) { @@ -96,11 +96,11 @@ export function spawn( processes.delete(child); if (code === 0) { - resolve({code, stdout, stderr}); + resolve({ code, stdout, stderr }); } else { reject(new ChildProcessError(code, stdout, stderr)); } }); - }), + }) ); } diff --git a/src/utils/symlinkPackageDependencies.js b/src/utils/symlinkPackageDependencies.js new file mode 100644 index 00000000..39ac829f --- /dev/null +++ b/src/utils/symlinkPackageDependencies.js @@ -0,0 +1,220 @@ +// @flow +import path from 'path'; +import pathIsInside from 'path-is-inside'; +import includes from 'array-includes'; + +import Project from '../Project'; +import type Workspace from '../Workspace'; +import type Package from '../Package'; +import { BoltError } from './errors'; +import * as fs from './fs'; +import * as logger from './logger'; +import * as messages from './messages'; +import * as yarn from './yarn'; + +export default async function symlinkPackageDependencies( + project: Project, + pkg: Package, + dependencies: Array +) { + const projectDeps = project.pkg.getAllDependencies(); + const pkgDependencies = project.pkg.getAllDependencies(); + const workspaces = await project.getWorkspaces(); + const { + graph: dependencyGraph, + valid: dependencyGraphValid + } = await project.getDependencyGraph(workspaces); + const pkgName = pkg.config.getName(); + // get all the dependencies that are internal workspaces in this project + const internalDeps = (dependencyGraph.get(pkgName) || {}).dependencies || []; + + const directoriesToCreate = []; + const symlinksToCreate = []; + + let valid = true; + + /********************************************************************* + * Calculate all the external dependencies that need to be symlinked * + **********************************************************************/ + + directoriesToCreate.push(pkg.nodeModules, pkg.nodeModulesBin); + + for (let depName of dependencies) { + const versionInProject = project.pkg.getDependencyVersionRange(depName); + const versionInPkg = pkg.getDependencyVersionRange(depName); + + // If dependency is internal we can ignore it (we symlink below) + if (dependencyGraph.has(depName)) { + continue; + } + + if (!versionInProject) { + valid = false; + logger.error( + messages.depMustBeAddedToProject(pkg.config.getName(), depName) + ); + continue; + } + + if (!versionInPkg) { + valid = false; + logger.error( + messages.couldntSymlinkDependencyNotExists( + pkg.config.getName(), + depName + ) + ); + continue; + } + + if (versionInProject !== versionInPkg) { + valid = false; + logger.error( + messages.depMustMatchProject( + pkg.config.getName(), + depName, + versionInProject, + versionInPkg + ) + ); + continue; + } + + let src = path.join(project.pkg.nodeModules, depName); + let dest = path.join(pkg.nodeModules, depName); + + symlinksToCreate.push({ src, dest, type: 'junction' }); + } + + /********************************************************************* + * Calculate all the internal dependencies that need to be symlinked * + **********************************************************************/ + + for (let dependency of internalDeps) { + const depWorkspace = dependencyGraph.get(dependency) || {}; + const src = depWorkspace.pkg.dir; + const dest = path.join(pkg.nodeModules, dependency); + + symlinksToCreate.push({ src, dest, type: 'junction' }); + } + + if (!dependencyGraphValid || !valid) { + throw new BoltError('Cannot symlink invalid set of dependencies.'); + } + + /******************************************************** + * Calculate all the bin files that need to be symlinked * + *********************************************************/ + const projectBinFiles = await fs.readdirSafe(project.pkg.nodeModulesBin); + + // TODO: For now, we'll search through each of the bin files in the Project and find which ones are + // dependencies we are symlinking. In the future, we should really be going through each dependency + // and all of its dependencies and checking which ones expose bins so that all the transitive ones + // are included too + + for (let binFile of projectBinFiles) { + const binPath = path.join(project.pkg.nodeModulesBin, binFile); + const binName = path.basename(binPath); + + // read the symlink to find the actual bin file (path will be relative to the symlink) + const actualBinFileRelative = await fs.readlink(binPath); + + if (!actualBinFileRelative) { + throw new BoltError(`${binName} is not a symlink`); + } + + const actualBinFile = path.join( + project.pkg.nodeModulesBin, + actualBinFileRelative + ); + + if (!pathIsInside(actualBinFile, project.pkg.nodeModules)) { + throw new BoltError( + `${binName} is linked to a location outside of project node_modules: ${actualBinFileRelative}` + ); + } + + // To find the name of the dep that created the bin we'll get its path from node_modules and + // use the first one or two parts (two if the package is scoped) + const binFileRelativeToNodeModules = path.relative( + project.pkg.nodeModules, + actualBinFile + ); + const pathParts = binFileRelativeToNodeModules.split(path.sep); + let pkgName = pathParts[0]; + + if (pkgName.startsWith('@')) { + pkgName += '/' + pathParts[1]; + } + + let workspaceBinPath = path.join(pkg.nodeModulesBin, binName); + + symlinksToCreate.push({ + src: binPath, + dest: workspaceBinPath, + type: 'exec' + }); + } + + /***************************************************************** + * Calculate all the internal bin files that need to be symlinked * + ******************************************************************/ + + // TODO: Same as above, we should really be making sure we get all the transitive bins as well + + for (let dependency of internalDeps) { + const depWorkspace = dependencyGraph.get(dependency) || {}; + const depBinFiles = + depWorkspace.pkg && + depWorkspace.pkg.config && + depWorkspace.pkg.config.getBin(); + + if (!depBinFiles) { + continue; + } + + if (!includes(dependencies, dependency)) { + // dependency is not one we are supposed to symlink right now + continue; + } + + if (typeof depBinFiles === 'string') { + // package may be scoped, name will only be the second part + const binName = dependency.split('/').pop(); + const src = path.join(depWorkspace.pkg.dir, depBinFiles); + const dest = path.join(pkg.nodeModulesBin, binName); + + symlinksToCreate.push({ src, dest, type: 'exec' }); + continue; + } + + for (let [binName, binPath] of Object.entries(depBinFiles)) { + const src = path.join(depWorkspace.pkg.dir, String(binPath)); + const dest = path.join(pkg.nodeModulesBin, binName); + + symlinksToCreate.push({ src, dest, type: 'exec' }); + } + } + + /********************************** + * Create directories and symlinks * + ***********************************/ + + await yarn.run(pkg, 'preinstall'); + + await Promise.all( + directoriesToCreate.map(dirName => { + return fs.mkdirp(dirName); + }) + ); + + await Promise.all( + symlinksToCreate.map(async ({ src, dest, type }) => { + await fs.symlink(src, dest, type); + }) + ); + + await yarn.run(pkg, 'postinstall'); + await yarn.run(pkg, 'prepublish'); + await yarn.run(pkg, 'prepare'); +} diff --git a/src/utils/yarn.js b/src/utils/yarn.js index be390dfe..93df4849 100644 --- a/src/utils/yarn.js +++ b/src/utils/yarn.js @@ -1,28 +1,47 @@ // @flow import includes from 'array-includes'; +import type { Dependency, configDependencyType } from '../types'; import type Package from '../Package'; import * as processes from './processes'; import * as fs from '../utils/fs'; import * as logger from '../utils/fs'; +import { DEPENDENCY_TYPE_FLAGS_MAP } from '../constants'; -export async function getScript(pkg: Package, script: string) { - let result = null; - let scripts = pkg.config.getScripts(); +function depTypeToFlag(depType) { + const flag = Object.keys(DEPENDENCY_TYPE_FLAGS_MAP).find( + key => DEPENDENCY_TYPE_FLAGS_MAP[key] === depType + ); - if (scripts && scripts[script]) { - result = scripts[script]; - } + return flag ? `--${flag}` : flag; +} - if (!result) { - let bins = await fs.readdirSafe(pkg.nodeModulesBin); +export async function add( + pkg: Package, + dependencies: Array, + type?: configDependencyType +) { + const spawnArgs = ['add']; + if (!dependencies.length) return; - if (includes(bins, script)) { - result = script; + dependencies.forEach(dep => { + if (dep.version) { + spawnArgs.push(`${dep.name}@${dep.version}`); + } else { + spawnArgs.push(dep.name); } + }); + + if (type) { + const flag = depTypeToFlag(type); + if (flag) spawnArgs.push(flag); } - return result; + await processes.spawn('yarn', spawnArgs, { + cwd: pkg.dir, + pkg: pkg, + tty: true + }); } export async function run( @@ -43,6 +62,25 @@ export async function run( }); } +export async function getScript(pkg: Package, script: string) { + let result = null; + let scripts = pkg.config.getScripts(); + + if (scripts && scripts[script]) { + result = scripts[script]; + } + + if (!result) { + let bins = await fs.readdirSafe(pkg.nodeModulesBin); + + if (includes(bins, script)) { + result = script; + } + } + + return result; +} + export async function init(cwd: string) { await processes.spawn('yarn', ['init', '-s'], { cwd: cwd,