diff --git a/package.json b/package.json index 8b9ba5c..a4d315f 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "axios": "^0.18.0", "chalk": "^2.4.1", "detect-indent": "^5.0.0", + "glob": "^7.1.3", "ora": "^3.0.0", "tslib": "^1.9.3" }, diff --git a/src/__tests__/globber.test.ts b/src/__tests__/globber.test.ts new file mode 100644 index 0000000..66ab01a --- /dev/null +++ b/src/__tests__/globber.test.ts @@ -0,0 +1,7 @@ +import { createGlobber } from '../globber' + +test('returns the current directory as a match', async () => { + const result = await createGlobber().globPackageFiles(process.cwd()) + expect(result).toHaveLength(1) + expect(result[0]).toBe(process.cwd() + '/package.json') +}) diff --git a/src/__tests__/type-syncer.test.ts b/src/__tests__/type-syncer.test.ts index 160bc70..ca0cefb 100644 --- a/src/__tests__/type-syncer.test.ts +++ b/src/__tests__/type-syncer.test.ts @@ -5,6 +5,7 @@ import { IPackageFile } from '../types' import { createTypeSyncer } from '../type-syncer' +import { IGlobber } from '../globber' const typedefs: ITypeDefinition[] = [ { @@ -35,7 +36,7 @@ const typedefs: ITypeDefinition[] = [ ] function buildSyncer() { - const packageFile: IPackageFile = { + const rootPackageFile: IPackageFile = { name: 'consumer', dependencies: { package1: '^1.0.0', @@ -53,6 +54,22 @@ function buildSyncer() { '@myorg/package7': '^1.0.0', package8: '~1.0.0', package9: '1.0.0' + }, + packages: ['packages/*'], + workspaces: ['packages/*'] + } + + const package1File: IPackageFile = { + name: 'package-1', + dependencies: { + package1: '^1.0.0' + } + } + + const package2File: IPackageFile = { + name: 'package-1', + dependencies: { + package3: '^1.0.0' } } @@ -61,15 +78,40 @@ function buildSyncer() { getLatestTypingsVersion: jest.fn(() => Promise.resolve('1.0.0')) } const packageService: IPackageJSONService = { - readPackageFile: jest.fn(() => Promise.resolve(packageFile)), + readPackageFile: jest.fn(async (filepath: string) => { + switch (filepath) { + case 'package.json': + return rootPackageFile + case 'packages/package-1/package.json': + return package1File + case 'packages/package-2/package.json': + return package2File + default: + throw new Error('What?!') + } + }), writePackageFile: jest.fn(() => Promise.resolve()) } + const globber: IGlobber = { + globPackageFiles: jest.fn(async pattern => { + switch (pattern) { + case 'packages/*': + return [ + 'packages/package-1/package.json', + 'packages/package-2/package.json' + ] + default: + return [] + } + }) + } + return { typedefSource, packageService, - packageFile, - syncer: createTypeSyncer(packageService, typedefSource) + rootPackageFile, + syncer: createTypeSyncer(packageService, typedefSource, globber) } } @@ -77,8 +119,9 @@ describe('type syncer', () => { it('adds new packages to the package.json', async () => { const { syncer, packageService } = buildSyncer() const result = await syncer.sync('package.json') - const writtenPackage = (packageService.writePackageFile as jest.Mock) - .mock.calls[0][1] as IPackageFile + const writtenPackage = (packageService.writePackageFile as jest.Mock< + any + >).mock.calls.find(c => c[0] === 'package.json')![1] as IPackageFile expect(writtenPackage.devDependencies).toEqual({ '@types/package1': '^1.0.0', '@types/package3': '^1.0.0', @@ -90,7 +133,12 @@ describe('type syncer', () => { package4: '^1.0.0', package5: '^1.0.0' }) - expect(result.newTypings.map(x => x.typingsName).sort()).toEqual([ + expect(result.syncedFiles).toHaveLength(3) + + expect(result.syncedFiles[0].filePath).toEqual('package.json') + expect( + result.syncedFiles[0].newTypings.map(x => x.typingsName).sort() + ).toEqual([ 'myorg__package7', 'package1', 'package3', @@ -98,6 +146,20 @@ describe('type syncer', () => { 'package8', 'package9' ]) + + expect(result.syncedFiles[1].filePath).toEqual( + 'packages/package-1/package.json' + ) + expect( + result.syncedFiles[1].newTypings.map(x => x.typingsName).sort() + ).toEqual(['package1']) + + expect(result.syncedFiles[2].filePath).toEqual( + 'packages/package-2/package.json' + ) + expect( + result.syncedFiles[2].newTypings.map(x => x.typingsName).sort() + ).toEqual(['package3']) }) it('does not write packages if options.dry is specified', async () => { diff --git a/src/__tests__/util.test.ts b/src/__tests__/util.test.ts index 61ecbf6..700f9b7 100644 --- a/src/__tests__/util.test.ts +++ b/src/__tests__/util.test.ts @@ -1,4 +1,11 @@ -import { uniq, filterMap, mergeObjects, orderObject, promisify } from '../util' +import { + uniq, + filterMap, + mergeObjects, + orderObject, + promisify, + memoizeAsync +} from '../util' describe('util', () => { describe('uniq', () => { @@ -66,4 +73,31 @@ describe('util', () => { expect(err.message).toBe('oh shit') }) }) + + describe('memoizeAsync', () => { + it('memoizes promises', async () => { + let i = 0 + + const m = memoizeAsync((k: string) => + Promise.resolve(k + (++i).toString()) + ) + expect([await m('hello'), await m('hello')]).toEqual(['hello1', 'hello1']) + expect([await m('goodbye'), await m('goodbye')]).toEqual([ + 'goodbye2', + 'goodbye2' + ]) + }) + + it('removes entry on fail', async () => { + let i = 0 + + const m = memoizeAsync((k: string) => + Promise.reject(new Error(k + (++i).toString())) + ) + expect([ + await m('hello').catch(err => err.message), + await m('hello').catch(err => err.message) + ]).toEqual(['hello1', 'hello2']) + }) + }) }) diff --git a/src/cli.ts b/src/cli.ts index 85629be..30709aa 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,10 +1,12 @@ -import { createContainer, InjectionMode, asFunction } from 'awilix' +import { createContainer, InjectionMode, asFunction, asValue } from 'awilix' import { createTypeSyncer } from './type-syncer' import { ITypeSyncer, ITypeDefinition } from './types' import chalk from 'chalk' import * as C from './cli-util' +import glob from 'glob' import { createTypeDefinitionSource } from './type-definition-source' import { createPackageJSONFileService } from './package-json-file-service' +import { createGlobber } from './globber'; /** * Starts the TypeSync CLI. @@ -15,9 +17,10 @@ export async function startCli() { const container = createContainer({ injectionMode: InjectionMode.CLASSIC }).register({ - typeDefinitionSource: asFunction(createTypeDefinitionSource), - packageJSONService: asFunction(createPackageJSONFileService), - typeSyncer: asFunction(createTypeSyncer) + typeDefinitionSource: asFunction(createTypeDefinitionSource).singleton(), + packageJSONService: asFunction(createPackageJSONFileService).singleton(), + globber: asFunction(createGlobber).singleton(), + typeSyncer: asFunction(createTypeSyncer), }) await _runCli(container.resolve('typeSyncer')) } catch (err) { diff --git a/src/globber.ts b/src/globber.ts new file mode 100644 index 0000000..8862c87 --- /dev/null +++ b/src/globber.ts @@ -0,0 +1,31 @@ +import glob from 'glob' +import * as path from 'path' +import { uniq } from './util' + +/** + * Globber interface. + */ +export interface IGlobber { + /** + * Globs for package.json files. + * + * @param pattern + */ + globPackageFiles(pattern: string): Promise> +} + +/** + * Creates a globber. + */ +export function createGlobber() { + return { + globPackageFiles(pattern: string) { + return new Promise>((resolve, reject) => + glob( + path.join(pattern, 'package.json'), + (err, matches) => (err ? reject(err) : resolve(uniq(matches))) + ) + ) + } + } +} diff --git a/src/type-syncer.ts b/src/type-syncer.ts index 3bb49e8..8913089 100644 --- a/src/type-syncer.ts +++ b/src/type-syncer.ts @@ -6,9 +6,20 @@ import { IPackageFile, ISyncOptions, IDependenciesSection, - IPackageVersion + IPackageVersion, + ISyncResult, + ISyncedFile } from './types' -import { filterMap, mergeObjects, typed, orderObject, uniq } from './util' +import { + filterMap, + mergeObjects, + typed, + orderObject, + uniq, + flatten, + memoizeAsync +} from './util' +import { IGlobber } from './globber' /** * Creates a type syncer. @@ -18,57 +29,96 @@ import { filterMap, mergeObjects, typed, orderObject, uniq } from './util' */ export function createTypeSyncer( packageJSONService: IPackageJSONService, - typeDefinitionSource: ITypeDefinitionSource + typeDefinitionSource: ITypeDefinitionSource, + globber: IGlobber ): ITypeSyncer { + const getLatestTypingsVersion = memoizeAsync( + typeDefinitionSource.getLatestTypingsVersion + ) return { - /** - * Syncs typings in the specified package.json. - */ - sync: async (filePath, opts: ISyncOptions = { dry: false }) => { - const [file, allTypings] = await Promise.all([ - packageJSONService.readPackageFile(filePath), - typeDefinitionSource.fetch() - ]) - - const allPackages = [ - ...getPackagesFromSection(file.dependencies), - ...getPackagesFromSection(file.devDependencies), - ...getPackagesFromSection(file.optionalDependencies), - ...getPackagesFromSection(file.peerDependencies) - ] - const allPackageNames = uniq(allPackages.map(p => p.name)) - - const newTypings = filterNewTypings(allPackageNames, allTypings) - const devDepsToAdd = await Promise.all( - newTypings.map(async t => { - const latestVersion = await typeDefinitionSource.getLatestTypingsVersion( - t.typingsName - ) - const codePackage = allPackages.find( - p => p.name === t.codePackageName - ) - const semverRangeSpecifier = codePackage - ? getSemverRangeSpecifier(codePackage.version) - : '^' - return { - [typed(t.typingsName)]: semverRangeSpecifier + latestVersion - } + sync + } + + /** + * Syncs typings in the specified package.json. + */ + async function sync( + filePath: string, + opts: ISyncOptions = { dry: false } + ): Promise { + const [file, allTypings] = await Promise.all([ + packageJSONService.readPackageFile(filePath), + typeDefinitionSource.fetch() + ]) + + const subPackages = await Promise.all( + [...(file.packages || []), ...(file.workspaces || [])].map( + globber.globPackageFiles + ) + ) + .then(flatten) + .then(uniq) + const syncedFiles: Array = await Promise.all([ + syncFile(filePath, file, allTypings, opts), + ...subPackages.map(p => syncFile(p, null, allTypings, opts)) + ]) + + return { + syncedFiles + } + } + + /** + * Syncs a single file. + * + * @param filePath + * @param file + * @param allTypings + * @param opts + */ + async function syncFile( + filePath: string, + file: IPackageFile | null, + allTypings: Array, + opts: ISyncOptions + ): Promise { + file = file || (await packageJSONService.readPackageFile(filePath)) + const allPackages = [ + ...getPackagesFromSection(file.dependencies), + ...getPackagesFromSection(file.devDependencies), + ...getPackagesFromSection(file.optionalDependencies), + ...getPackagesFromSection(file.peerDependencies) + ] + const allPackageNames = uniq(allPackages.map(p => p.name)) + + const newTypings = filterNewTypings(allPackageNames, allTypings) + const devDepsToAdd = await Promise.all( + newTypings.map(async t => { + const latestVersion = await getLatestTypingsVersion(t.typingsName) + const codePackage = allPackages.find(p => p.name === t.codePackageName) + const semverRangeSpecifier = codePackage + ? getSemverRangeSpecifier(codePackage.version) + : '^' + return { + [typed(t.typingsName)]: semverRangeSpecifier + latestVersion + } + }) + ).then(mergeObjects) + + if (!opts.dry) { + await packageJSONService.writePackageFile(filePath, { + ...file, + devDependencies: orderObject({ + ...devDepsToAdd, + ...file.devDependencies }) - ).then(mergeObjects) - - if (!opts.dry) { - await packageJSONService.writePackageFile(filePath, { - ...file, - devDependencies: orderObject({ - ...devDepsToAdd, - ...file.devDependencies - }) - } as IPackageFile) - } - - return { - newTypings - } + } as IPackageFile) + } + + return { + filePath, + newTypings, + package: file } } } diff --git a/src/types.ts b/src/types.ts index 69c1c52..83d8465 100644 --- a/src/types.ts +++ b/src/types.ts @@ -52,6 +52,8 @@ export interface IPackageFile { devDependencies?: IDependenciesSection peerDependencies?: IDependenciesSection optionalDependencies?: IDependenciesSection + packages?: IWorkspacesSection + workspaces?: IWorkspacesSection [key: string]: any } @@ -62,6 +64,11 @@ export interface IDependenciesSection { [packageName: string]: string } +/** + * Section in package.json representing workspaces (yarn/lerna). + */ +export type IWorkspacesSection = Array + /** * Package + version. */ @@ -81,10 +88,28 @@ export interface ITypeDefinition { * Sync result. */ export interface ISyncResult { + /** + * The files that were synced. + */ + syncedFiles: Array +} + +/** + * A file that was synced. + */ +export interface ISyncedFile { + /** + * The cwd-relative path to the synced file. + */ + filePath: string + /** + * The package file that was synced. + */ + package: IPackageFile /** * How many new typings were added. */ - newTypings: Array + newTypings: Array } /** diff --git a/src/util.ts b/src/util.ts index e40fe81..51f3857 100644 --- a/src/util.ts +++ b/src/util.ts @@ -85,3 +85,49 @@ export function promisify(fn: Function) { }) } } + +/** + * Flattens a 2-dimensional array. + * + * @param source + */ +export function flatten(source: Array>): Array { + const result: Array = [] + for (const items of source) { + for (const item of items) { + result.push(item) + } + } + return result +} + +/** + * Async memoize. + * + * @param fn + */ +export function memoizeAsync( + fn: (...args: U) => Promise +) { + const cache = new Map>() + + async function run(...args: U): Promise { + try { + return await fn(...args) + } catch (err) { + cache.delete(args[0]) + throw err + } + } + + return async function(...args: U): Promise { + const key = args[0] + if (cache.has(key)) { + return cache.get(key)! + } + + const p = run(...args) + cache.set(key, p) + return p + } +} diff --git a/yarn.lock b/yarn.lock index b9e834e..8be55f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -31,10 +31,6 @@ version "23.3.2" resolved "https://registry.yarnpkg.com/@types/jest/-/jest-23.3.2.tgz#07b90f6adf75d42c34230c026a2529e56c249dbb" -"@types/lodash@^4.14.116": - version "4.14.116" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.116.tgz#5ccf215653e3e8c786a58390751033a9adca0eb9" - "@types/minimatch@*": version "3.0.1" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.1.tgz#b683eb60be358304ef146f5775db4c0e3696a550" @@ -1432,6 +1428,17 @@ glob@^7.0.3, glob@^7.0.5, glob@^7.1.1, glob@^7.1.2: once "^1.3.0" path-is-absolute "^1.0.0" +glob@^7.1.3: + version "7.1.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1" + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + global-dirs@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445"