├── .github └── FUNDING.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── bin └── yarn-compose ├── package.json ├── readme.md ├── src ├── Command.ts ├── __tests__ │ ├── Command.spec.ts │ ├── fixtures │ │ ├── projects-invalid.yml │ │ └── projects.yml │ └── lib.spec.ts ├── cli │ ├── __tests__ │ │ ├── lib.int.ts │ │ └── lib.spec.ts │ ├── index.ts │ ├── lib.ts │ └── util.ts ├── commands │ ├── Rebuild.ts │ ├── Relink.ts │ ├── Setup.ts │ └── __tests__ │ │ ├── Rebuild.spec.ts │ │ ├── Relink.spec.ts │ │ └── Setup.spec.ts ├── index.ts ├── lib.ts ├── schema.ts ├── types │ ├── index.d.ts │ └── pkginfo.d.ts └── util.ts ├── tsconfig.json └── yarn.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [acao] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | node_modules 3 | coverage 4 | yarn-error.log 5 | package-lock.json 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | git: 2 | depth: 5 3 | language: node_js 4 | node_js: 5 | - '10' 6 | cache: 7 | yarn: true 8 | install: 9 | - yarn install --ignore-scripts 10 | after_success: 11 | - codecov 12 | jobs: 13 | include: 14 | - stage: test 15 | script: yarn test:all 16 | node_js: lts/* 17 | - stage: deploy 18 | if: branch = master OR tag IS present 19 | script: echo "Deploying..." 20 | node_js: lts/* 21 | deploy: 22 | - provider: npm 23 | skip_cleanup: true 24 | email: rikki.schulte@gmail.com 25 | api_key: 26 | secure: b5TwtN2qklFP2IyOxGdZlFhTpZwvRp/8l9ap7g9imxrSp9DeRKyv1r4tT14lfzfXEE0f/7nmP0z4dS2b7guXpdH+Qm43pRkQVRW+SUfr0m6hHhJlFEMe6487U25/bhtbMhmmh/0rob9ffNazE+hLbsRBflDUdEMT3gAFpwNo0PwrzHAc4Mq601u9WRSMmk0jnOc5Q3hHyCQYu7/BDX61cKFLruOR0j05dWss/XBd4PD3puYqRaVDtsEPmJ9RN+yJrm5rzHgyPXCurKhP0Kh4pp8Z7NstWjCE2/Utv7QUe/VwMHbe0utMQ0Ef9xKNVk9RosNVJOfIp5N0Bf3vND7Eo1qYa0A97GoHL50F66NRvmj9UJYw/WitM1Mf2iL/oVy9jB21UV7XEATd/LvX2GRzVlWzuzwm/upN0lWaG84/z44bcGc9HCO380Ub228MV2+p6wipOpFe0w2lu+ZiS/noowUQZOIvxsIcq6e1OgVRdMqs4qJMljJdyHF460U8MHtl6GlU9Ah3+9ae7P+WSxWnxHS+VrFZCJ3pzcHMrjOdHPVFVIynINhlNu/++miMPXXCkIatyvmt4+BOre36aCnsqRQ78GVH/terhzOwjCeCkkp1OOJzRqsRCuDn+Jtgp++wF3vipv1frUlMF3spdcAWwvcbmmFl4gHAEsOI9tjXUWI= 27 | on: 28 | branch: master 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Rikki Schulte 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /bin/yarn-compose: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('../lib/cli') 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yarn-compose", 3 | "version": "0.0.12", 4 | "main": "lib", 5 | "types": "./lib/index.d.ts", 6 | "repository": { 7 | "url": "https://github.com/acao/yarn-compose" 8 | }, 9 | "prettier": { 10 | "trailingComma": "es5", 11 | "tabWidth": 2, 12 | "semi": false, 13 | "singleQuote": true 14 | }, 15 | "description": "orchestrate node projects and linkages without a monorepo, using git", 16 | "license": "MIT", 17 | "keywords": [ 18 | "yarn", 19 | "git", 20 | "multi-package", 21 | "polyrepo" 22 | ], 23 | "files": [ 24 | "lib", 25 | "src", 26 | "cli" 27 | ], 28 | "bin": { 29 | "yarn-compose": "bin/yarn-compose" 30 | }, 31 | "scripts": { 32 | "build": "rimraf lib && tsc", 33 | "dev": "ts-node src/cli", 34 | "prepublish": "yarn test && yarn test:int && yarn build", 35 | "test": "jest spec --coverage", 36 | "test:int": "jest int", 37 | "test:all": "yarn test && yarn test:int", 38 | "test:watch": "yarn test --watch", 39 | "pretty": "prettier src/**/*.{ts,json} --write" 40 | }, 41 | "jest": { 42 | "collectCoverageFrom": [ 43 | "src/**/*.ts", 44 | "!src/index.ts" 45 | ], 46 | "transform": { 47 | "^.+\\.tsx?$": "ts-jest" 48 | }, 49 | "testEnvironment": "node", 50 | "modulePathIgnorePatterns": [ 51 | "__tests__/fixtures/", 52 | "dist/" 53 | ], 54 | "testPathIgnorePatterns": [ 55 | "__tests__/(fixtures|__mocks__)/" 56 | ] 57 | }, 58 | "dependencies": { 59 | "ajv": "^6.10.0", 60 | "chalk": "^2.4.2", 61 | "execa": "^3.2.0", 62 | "js-yaml": "^3.13.1", 63 | "meow": "^5.0.0", 64 | "mkdirp": "^0.5.1", 65 | "pkginfo": "^0.4.1", 66 | "rimraf": "^3.0.0" 67 | }, 68 | "devDependencies": { 69 | "@types/chai": "^4.1.7", 70 | "@types/execa": "^0.9.0", 71 | "@types/jest": "^24.0.13", 72 | "@types/js-yaml": "^3.12.1", 73 | "@types/meow": "^5.0.0", 74 | "@types/minimist-options": "^3.0.0", 75 | "@types/mkdirp": "^0.5.2", 76 | "@types/mocha": "^5.2.6", 77 | "@types/node": "^12.0.2", 78 | "@types/rimraf": "^2.0.2", 79 | "chai": "^4.2.0", 80 | "codecov": "^3.5.0", 81 | "jest": "^24.8.0", 82 | "prettier": "^1.17.1", 83 | "ts-jest": "^24.0.2", 84 | "ts-node": "^8.1.0", 85 | "typescript": "^3.4.5" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | [![npm version](https://badge.fury.io/js/yarn-compose.svg)](https://badge.fury.io/js/yarn-compose) 2 | [![Build Status](https://travis-ci.com/acao/yarn-compose.svg?branch=master)](https://travis-ci.com/acao/yarn-compose?branch=master) 3 | [![Coverage Status](https://codecov.io/gh/acao/yarn-compose/branch/master/graph/badge.svg)](https://codecov.io/gh/acao/yarn-compose) 4 | 5 | # 🦑 polyrepo development for the rest of us 🦑 6 | 7 | A simple utility to orchestrate node projects using git and yarn link. 8 | 9 | Handy for multi-repo projects that require development with disparate branches. 10 | 11 | For orchestrating npm/yarn monorepos, see lerna or yarn workspaces. 12 | 13 | Many more features coming soon. This takes care of the annoying parts for me. 14 | 15 | This was primarily created to make it easier for me to iterate on language features for the graphql ecosystem. I hope it serves you well. Be sure to create a github issue if you need anything! 16 | 17 | # System Requirements 18 | 19 | - git (windows git bash will probably work, create an issue if it doesnt please!) 20 | - yarn (eventually will support a mix of npm/yarn projects) 21 | - node 10 or higher 22 | # Setup 23 | 24 | `yarn global add yarn-compose` 25 | 26 | # Usage 27 | 28 | 1. create a projects.yml file 29 | 1. run `yarn-compose setup` in the same directory 30 | 1. or, run `yarn-compose setup -c path/to/config.yml` 31 | 1. also, you can override `baseDir` with `--target` or `-t` 32 | 1. other commands are `rebuild` and `relink` with the same arguments (see below) 33 | 1. `--help` works for each command 34 | 35 | # Config File 36 | You'll want to provide a yml file with at least some basic configuration for linking your projects. 37 | 38 | - `baseDir`: (optional, string) this can be provided via CLI or in the config file. you must provide it one way or the other. 39 | 40 | - `typeDefs`: (optional) is an object with string keys that are used by the `types` array for each project. used for DefinatelyTyped but could also be used for flow-typed. if you have a one off repo with types, that might be easier to use below. 41 | - `remote`: (required, string) git or https url to the remote that contains typings 42 | - `branch`: (required, string) the branch or ref of the repo you want 43 | - `typesPath`: (required, string) path to the types you want. `types/` for DefinatelyTyped, for example 44 | - `depth`: (optional, integer) - default: `1` - the clone depth you want. because DefinatelyTyped is YUGE 45 | 46 | - `projects` (required) is an array of: 47 | - `package`: (required, string) the name of the npm package 48 | - `remote`: (required, string) the git or https url to the remote (https://github.com/acao/graphql-import) 49 | - `branch`: (required, string) the name of the branch (or other ref, `tag/v0.x`, commit hashes etc should work here too) 50 | - `types`: (optional, string[]) - matches the typeDef keys, for creating symlinks 51 | - `links`: (optional, string[]) - an array of packages that this project should be linked to. you will probably need this for most projects 52 | - `lerna`: (optional, boolean) - default: false - whether this is a lerna project. if so, `lerna build` will be run instead of `yarn build`, and linkages will be handled for all subprojects using `lerna exec -- yarn link ` 53 | - `buildScript`: (optional, string) - default: `build` custom value for the script used before linking. 54 | - `npmClient`: (optional, default: `yarn`) - specify a different npmClient (`yarn`, `npm` or `cnpm`) for this project's script execution. 55 | - Note: in my experience, linking between npm/yarn projects is messy. good luck! 56 | - `linkFrom:`: (optional, string) - path to link from, if not the project root. this was needed for graphql-js 57 | 58 | NOTE: the order of the projects array determines execution for building/linkages/etc. 59 | 60 | The example below demonstrates the descending order of dependencies. The lowest level dependents should go first, with their dependees following. 61 | 62 | Eventually these will work like docker-compose services, where the order of operations will be determined by the `links` array. Until then, this CLI is intended to be really unintelligent on purpose. 63 | 64 | # Commands 65 | 66 | ## Global Options 67 | 68 | These are available to all commands 69 | 70 | `--config-path`, `-c` 71 | 72 | the explicit path to the config file 73 | 74 | `--base-dir`, `-t` 75 | 76 | the path to the base directory 77 | 78 | ## `setup` 79 | sets up project workspace, clones and installs projects, type definitions, builds and links dependencies 80 | 81 | ### Usage 82 | 83 | expects projects.yml by default 84 | 85 | ```$ yarn-compose setup``` 86 | 87 | or, specify a path to a config file 88 | 89 | ```$ yarn-compose setup -c path/to/config.yml``` 90 | 91 | ### Options 92 | `--force`, `-f` 93 | 94 | force install 95 | 96 | ## `rebuild` 97 | re-builds all projects in order 98 | 99 | ### Usage 100 | ```$ yarn-compose rebuild -c path/to/config.yml``` 101 | 102 | ## `relink` 103 | re-links all projects in order, assuming symlinks have already been built 104 | 105 | ### Usage 106 | ```$ yarn-compose relink -c path/to/config.yml``` 107 | 108 | # Config Example 109 | 110 | ```yml 111 | baseDir: './lab' 112 | 113 | typedefs: 114 | graphql: 115 | remote: git@github.com:acao/DefinitelyTyped.git 116 | branch: graphql-inputUnion 117 | typesPath: types/graphql 118 | 119 | projects: 120 | - package: graphql 121 | remote: https://github.com/tgriesser/graphql-js 122 | branch: inputUnion 123 | 124 | - package: graphql-import 125 | remote: https://github.com/acao/graphql-import 126 | branch: inputUnion 127 | buildScript: build 128 | types: 129 | - graphql 130 | links: 131 | - graphql 132 | 133 | - package: graphql-config 134 | remote: https://github.com/prisma/graphql-config 135 | branch: master 136 | types: 137 | - graphql 138 | links: 139 | - graphql 140 | - graphql-import 141 | 142 | - package: graphql-language-service 143 | lerna: true 144 | remote: https://github.com/acao/graphql-language-service 145 | branch: inputUnion 146 | links: 147 | - graphql 148 | - graphql-config 149 | - graphql-import 150 | - graphql-language-service-parser 151 | - graphql-language-service-interface 152 | - graphql-language-service-types 153 | - graphql-language-service-utils 154 | 155 | - package: codemirror-graphql 156 | branch: inputUnion 157 | remote: https://github.com/acao/codemirror-graphql 158 | links: 159 | - graphql 160 | - graphql-language-service-parser 161 | - graphql-language-service-interface 162 | - graphql-language-service-types 163 | - graphql-language-service-utils 164 | - graphql-config 165 | - graphql-import 166 | 167 | - package: graphiql 168 | branch: inputUnion 169 | remote: https://github.com/acao/graphiql 170 | links: 171 | - graphql 172 | - codemirror-graphql 173 | - graphql-language-service-parser 174 | - graphql-language-service-interface 175 | - graphql-language-service-types 176 | - graphql-language-service-utils 177 | - graphql-config 178 | - graphql-import 179 | ``` 180 | 181 | # FAQ 182 | 183 | ### Why not use lerna or yarn workspaces? 184 | 185 | This is more for orchestrating development environments than anything. Not for building permanent ecosystems like a monorepo would be. That said, lerna could still be used for ephemeral demos, etc, however using npm or yarn for managing fully working git repositories is almost impossible! This takes care of setting up a development environment with the assumption that you might need to work from git forks and feature branches across an entire ecosystem of projects. 186 | 187 | ### Some of the errors look messy 188 | 189 | Yes, currently I'm just throwing/logging out the formatted stderr from `execa`. Thinking maybe a --verbose flag could provide more detail if needed. 190 | 191 | ### Should this be used in production? 192 | 193 | No! Symlinking in production environments can be very insecure with node. This should be used for local development only. It could also be used in CI to, say, build a series of complex feature branches before the dist is deployed. This is not designed for setting up a production environment. 194 | 195 | ### Is this project related to `docker-compose`? 196 | 197 | Yes, the config file format is inspired by docker compose, as well as the obsession with linkages. It's not nearly as smart as docker compose though. 198 | 199 | # TODO 200 | - [ ] more examples! (please contribute in gh issues if you can!) 201 | - [X] support npm, cnpm, etc? 202 | - [ ] meteor, bower even? if folks want? 203 | - [ ] allow a `--projects` flag to target specific project(s) for each command 204 | - [ ] support configuring multiple remotes per project 205 | - [ ] support changing remotes, branches, etc more readily/passively 206 | - [ ] other features users ask for? 207 | - [ ] "discover" lerna, npmClient, etc 208 | - [ ] support other config formats 209 | -------------------------------------------------------------------------------- /src/Command.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import * as fs from 'fs' 3 | 4 | import * as Ajv from 'ajv' 5 | 6 | import { logger, getConfig } from './util' 7 | import { CommandConfig, NodeProject, CommandInstanceOptions } from './types' 8 | import { configSchema } from './schema' 9 | 10 | export interface EachProjectOptions { 11 | filter?: (value: NodeProject) => boolean 12 | } 13 | export class Command { 14 | options: CommandInstanceOptions 15 | config: CommandConfig 16 | configPath: string 17 | baseDir: string 18 | 19 | constructor(options: CommandInstanceOptions) { 20 | this.options = options 21 | this.configPath = 22 | options.configPath || path.join(process.cwd(), 'projects.yml') 23 | this.config = this.getCommandConfig() 24 | this.baseDir = this.config.baseDir = options.baseDir || options.targetDir || this.config.baseDir 25 | } 26 | 27 | public eachProject(fn: Function, options: EachProjectOptions = {}) { 28 | let value = 0 29 | const { projects } = this.config 30 | const filteredProjects = options.filter 31 | ? projects.filter(options.filter) 32 | : projects 33 | 34 | for (const project of filteredProjects) { 35 | value++ 36 | const projectDir = path.join(this.baseDir, project.package) 37 | fn(projectDir, project, { 38 | ...this.options, 39 | countOf: [value, filteredProjects.length], 40 | }) 41 | } 42 | } 43 | 44 | private getCommandConfig(): CommandConfig { 45 | if (!fs.existsSync(this.configPath)) { 46 | throw Error( 47 | `config file doesnt exist:\n ${ 48 | this.configPath 49 | }.\n\nPlease use -c or --config-path to specify a path to a config file, or create one.` 50 | ) 51 | } 52 | const config = getConfig(this.configPath) 53 | return this.validateConfig(config) 54 | } 55 | 56 | private validateConfig(config: CommandConfig) { 57 | const ajv = new Ajv({ useDefaults: true }) 58 | const ajvValidator = ajv.compile(configSchema) 59 | const valid = ajvValidator(config) 60 | if (!valid) { 61 | logger.error(JSON.stringify(ajvValidator.errors, null, 2)) 62 | throw Error(`Invalid configuration file:`) 63 | } 64 | // return the config with defaults added 65 | return config 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/__tests__/Command.spec.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '../Command' 2 | import { expect } from 'chai' 3 | import * as rimraf from 'rimraf' 4 | import { CommandInstanceOptions } from '../types' 5 | 6 | describe('Command', () => { 7 | afterEach(() => { 8 | rimraf.sync('/tmp/yarn-compose') 9 | }) 10 | it('should instantiate with expected variables', () => { 11 | const options: CommandInstanceOptions = { 12 | configPath: __dirname + '/fixtures/projects.yml', 13 | } 14 | const command = new Command(options) 15 | expect(command.options).to.deep.equal(options) 16 | expect(command.configPath).to.equal(__dirname + '/fixtures/projects.yml') 17 | }) 18 | 19 | it('should override base dir with CLI args', () => { 20 | const options: CommandInstanceOptions = { 21 | configPath: __dirname + '/fixtures/projects.yml', 22 | baseDir: '/tmp/new-path', 23 | } 24 | const command = new Command(options) 25 | expect(command.config.baseDir).to.equal('/tmp/new-path') 26 | rimraf.sync('/tmp/new-path') 27 | }) 28 | 29 | it('should use default configPath when config-path is missing from cli args', () => { 30 | const options: CommandInstanceOptions = {} 31 | expect(() => new Command(options)).to.throw() 32 | }) 33 | 34 | it('should throw on invalid config file', () => { 35 | const options: CommandInstanceOptions = { 36 | configPath: __dirname + '/fixtures/projects-invalid.yml', 37 | } 38 | expect(() => new Command(options)).to.throw() 39 | }) 40 | 41 | it('should execute commands and pass arguments for eachProject', () => { 42 | const options: CommandInstanceOptions = { 43 | configPath: __dirname + '/fixtures/projects.yml', 44 | } 45 | const eachProjectStub = jest.fn() 46 | const command = new Command(options) 47 | command.eachProject(eachProjectStub) 48 | expect(eachProjectStub.mock.calls.length).to.equal(6) 49 | expect(eachProjectStub.mock.calls[0]).to.deep.equal([ 50 | '/tmp/yarn-compose/graphql-js', 51 | { 52 | package: 'graphql-js', 53 | remote: 'https://github.com/tgriesser/graphql-js.git', 54 | branch: 'inputUnion', 55 | linkFrom: 'dist', 56 | buildScript: 'build', 57 | npmClient: 'yarn', 58 | lerna: false, 59 | }, 60 | { 61 | configPath: __dirname + '/fixtures/projects.yml', 62 | countOf: [1, 6], 63 | }, 64 | ]) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/projects-invalid.yml: -------------------------------------------------------------------------------- 1 | baseDir: '/tmp/yarn-compose' # path relative to cwd 2 | 3 | typeDefs: # handy for forked DefinatelyTyped repos 4 | graphql: 5 | remote: git@github.com:acao/DefinitelyTyped.git 6 | branc: graphql-inputUnion 7 | typesPath: types/graphql 8 | 9 | projects: 10 | # the order of these is always honored 11 | # so that downstream dependencies can be 12 | # built before they are linked 13 | 14 | - package: graphql-js # required 15 | remote: https://github.com/tgriesser/graphql-js # required 16 | branch: inputUnion # required 17 | linkFrom: dist # optional: for when you need to link a project from its subdirectory 18 | 19 | - package: graphql-import 20 | remote: https://github.com/acao/graphql-import 21 | branch: inputUnion 22 | buildScript: build # optional: custom value for the script used before linking. build by default 23 | types: # optional: ensures that @types/{name} will symlink to the matching entry above 24 | - graphql 25 | links: # optional: maintain links to these repositories 26 | - graphql 27 | 28 | - package: graphql-config 29 | remote: https://github.com/prisma/graphql-config 30 | branch: master 31 | types: 32 | - graphql 33 | links: 34 | - graphql 35 | - graphql-import 36 | 37 | - package: graphql-language-service 38 | lerna: true # optional, runs `lerna exec` 39 | remote: https://github.com/acao/graphql-language-service 40 | branch: inputUnion 41 | links: 42 | - graphql 43 | - graphql-config 44 | - graphql-import 45 | - graphql-language-service-parser 46 | - graphql-language-service-interface 47 | - graphql-language-service-types 48 | - graphql-language-service-utils 49 | 50 | - package: codemirror-graphql 51 | branch: inputUnion 52 | remote: https://github.com/acao/codemirror-graphql 53 | links: 54 | - graphql 55 | - graphql-language-service-parser 56 | - graphql-language-service-interface 57 | - graphql-language-service-types 58 | - graphql-language-service-utils 59 | - graphql-config 60 | - graphql-import 61 | 62 | - package: graphiql 63 | branch: inputUnion 64 | remote: https://github.com/acao/graphiql 65 | links: 66 | - graphql 67 | - codemirror-graphql 68 | - graphql-language-service-parser 69 | - graphql-language-service-interface 70 | - graphql-language-service-types 71 | - graphql-language-service-utils 72 | - graphql-config 73 | - graphql-import 74 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/projects.yml: -------------------------------------------------------------------------------- 1 | baseDir: '/tmp/yarn-compose' # path relative to cwd 2 | 3 | typeDefs: # handy for forked DefinatelyTyped repos 4 | graphql: 5 | remote: https://github.com/acao/DefinitelyTyped.git 6 | branch: graphql-inputUnion 7 | typesPath: types/graphql 8 | depth: 1 9 | 10 | projects: 11 | # the order of these is always honored 12 | # so that downstream dependencies can be 13 | # built before they are linked 14 | 15 | - package: graphql-js # required 16 | remote: https://github.com/tgriesser/graphql-js.git # required 17 | branch: inputUnion # required 18 | linkFrom: dist # optional: for when you need to link a project from its subdirectory 19 | 20 | - package: graphql-import 21 | remote: https://github.com/acao/graphql-import.git 22 | branch: inputUnion 23 | buildScript: build # optional: custom value for the script used before linking. build by default 24 | types: # optional: ensures that @types/{name} will symlink to the matching entry above 25 | - graphql 26 | links: # optional: maintain links to these repositories 27 | - graphql 28 | 29 | - package: graphql-config 30 | remote: https://github.com/prisma/graphql-config.git 31 | branch: master 32 | types: 33 | - graphql 34 | links: 35 | - graphql 36 | - graphql-import 37 | 38 | - package: graphql-language-service 39 | lerna: true # optional, runs `lerna exec` 40 | remote: https://github.com/acao/graphql-language-service.git 41 | branch: inputUnion 42 | links: 43 | - graphql 44 | - graphql-config 45 | - graphql-import 46 | 47 | - package: codemirror-graphql 48 | branch: inputUnion 49 | remote: https://github.com/acao/codemirror-graphql.git 50 | links: 51 | - graphql 52 | - graphql-language-service-parser 53 | - graphql-language-service-interface 54 | - graphql-language-service-types 55 | - graphql-language-service-utils 56 | - graphql-config 57 | - graphql-import 58 | 59 | - package: graphiql 60 | branch: inputUnion 61 | remote: https://github.com/acao/graphiql.git 62 | links: 63 | - graphql 64 | - codemirror-graphql 65 | - graphql-language-service-parser 66 | - graphql-language-service-interface 67 | - graphql-language-service-types 68 | - graphql-language-service-utils 69 | - graphql-config 70 | - graphql-import 71 | -------------------------------------------------------------------------------- /src/__tests__/lib.spec.ts: -------------------------------------------------------------------------------- 1 | import * as execa from 'execa' 2 | import * as mkdirp from 'mkdirp' 3 | import * as rimraf from 'rimraf' 4 | import * as path from 'path' 5 | jest.mock('execa') 6 | 7 | import { 8 | cloneProject, 9 | checkoutBranch, 10 | installDependencies, 11 | buildProject, 12 | linkSelf, 13 | linkDependencies, 14 | cloneTypeDefinition, 15 | cloneTypeDefinitions, 16 | linkTypes, 17 | } from '../lib' 18 | 19 | import { NodeProject } from '../types' 20 | 21 | const project: NodeProject = { 22 | package: 'example', 23 | branch: 'master', 24 | remote: 'git@github.com:example/example.git', 25 | links: ['another-example', 'another-example-1'], 26 | lerna: false, 27 | types: ['example-type'], 28 | npmClient: 'yarn', 29 | buildScript: 'build', 30 | } 31 | 32 | const lernaProject: NodeProject = { 33 | package: 'lerna-example', 34 | branch: 'master', 35 | remote: 'git@github.com:example/lerna-example.git', 36 | lerna: true, 37 | links: ['another-example', 'another-example-1'], 38 | npmClient: 'yarn', 39 | buildScript: 'build', 40 | } 41 | 42 | const DIR = '/tmp/example' 43 | 44 | describe('cloneProject', () => { 45 | afterEach(() => { 46 | jest.clearAllMocks() 47 | rimraf.sync(DIR) 48 | }) 49 | 50 | it('should clone a project', () => { 51 | cloneProject('https://github.com/acao/yarn-compose', DIR) 52 | expect(execa.sync).toHaveBeenCalledWith('git', [ 53 | 'clone', 54 | 'https://github.com/acao/yarn-compose', 55 | DIR, 56 | ]) 57 | }) 58 | 59 | it('should not clone a project when it already exists', () => { 60 | mkdirp.sync('/tmp/example/.git') 61 | cloneProject('https://github.com/acao/yarn-compose', DIR) 62 | expect(execa.sync).toHaveBeenCalledTimes(0) 63 | }) 64 | }) 65 | 66 | describe('checkoutBranch', () => { 67 | afterEach(() => { 68 | jest.clearAllMocks() 69 | rimraf.sync(DIR) 70 | }) 71 | 72 | it('should checkout a branch in an existing repo', () => { 73 | mkdirp.sync(DIR + '/.git') 74 | checkoutBranch(DIR, 'd7674130d80c9396c8195101c69596a217c7cad9') 75 | expect(execa.sync).toHaveBeenLastCalledWith( 76 | 'git', 77 | ['checkout', 'd7674130d80c9396c8195101c69596a217c7cad9'], 78 | { 79 | cwd: DIR, 80 | } 81 | ) 82 | }) 83 | 84 | it('should fail when a repository doesnt exist', () => { 85 | expect(() => 86 | checkoutBranch(DIR, 'd7674130d80c9396c8195101c69596a217c7cad9') 87 | ).toThrowError( 88 | 'Cannot checkout d7674130d80c9396c8195101c69596a217c7cad9, /tmp/example is not a git repository' 89 | ) 90 | }) 91 | }) 92 | 93 | describe('installDependencies', () => { 94 | afterEach(() => { 95 | jest.clearAllMocks() 96 | }) 97 | 98 | it('should install packages for a normal repository', () => { 99 | installDependencies(DIR, project, { countOf: [1, 1] }) 100 | expect(execa.sync).toHaveBeenLastCalledWith( 101 | 'yarn', 102 | ['install', '--ignore-scripts'], 103 | { cwd: DIR } 104 | ) 105 | }) 106 | 107 | it('should install packages with --force flag', () => { 108 | installDependencies(DIR, lernaProject, { countOf: [1, 1], force: true }) 109 | expect(execa.sync).toHaveBeenLastCalledWith( 110 | 'yarn', 111 | ['install', '--ignore-scripts', '--force'], 112 | { 113 | cwd: DIR, 114 | } 115 | ) 116 | }) 117 | }) 118 | 119 | describe('buildProject', () => { 120 | afterEach(() => { 121 | jest.clearAllMocks() 122 | }) 123 | 124 | it('should build project for a normal repository', () => { 125 | buildProject(DIR, project, { countOf: [1, 1] }) 126 | expect(execa.sync).toHaveBeenLastCalledWith('yarn', ['build'], { cwd: DIR }) 127 | }) 128 | 129 | it('should build project for a lerna repository', () => { 130 | buildProject(DIR, lernaProject, { countOf: [1, 1] }) 131 | expect(execa.sync).toHaveBeenLastCalledWith('lerna', ['run', 'build'], { 132 | cwd: DIR, 133 | }) 134 | }) 135 | }) 136 | 137 | describe('linkSelf', () => { 138 | afterEach(() => { 139 | jest.clearAllMocks() 140 | }) 141 | 142 | it('should link normal project', () => { 143 | linkSelf(DIR, project, { countOf: [1, 1] }) 144 | expect(execa.sync).toHaveBeenCalledWith('yarn', ['unlink'], { cwd: DIR }) 145 | expect(execa.sync).toHaveBeenLastCalledWith('yarn', ['link'], { cwd: DIR }) 146 | }) 147 | 148 | it('should link all projects in a lerna repository', () => { 149 | linkSelf(DIR, lernaProject, { countOf: [1, 1] }) 150 | expect(execa.sync).toHaveBeenCalledWith( 151 | 'lerna', 152 | ['exec', 'yarn', 'unlink'], 153 | { 154 | cwd: DIR, 155 | } 156 | ) 157 | expect(execa.sync).toHaveBeenLastCalledWith( 158 | 'lerna', 159 | ['exec', 'yarn', 'link'], 160 | { 161 | cwd: DIR, 162 | } 163 | ) 164 | }) 165 | 166 | it('should build project and honor opts.project.linkFrom', () => { 167 | linkSelf(DIR, { ...project, linkFrom: 'path' }, { countOf: [1, 1] }) 168 | expect(execa.sync).toHaveBeenLastCalledWith('yarn', ['link'], { 169 | cwd: DIR + '/path', 170 | }) 171 | }) 172 | }) 173 | 174 | describe('linkDependencies', () => { 175 | afterEach(() => { 176 | jest.clearAllMocks() 177 | }) 178 | 179 | it('should link dependencies for a normal repository', () => { 180 | linkDependencies(DIR, project, { countOf: [1, 1] }) 181 | expect(execa.sync).toHaveBeenLastCalledWith( 182 | 'yarn', 183 | ['link', 'another-example', 'another-example-1'], 184 | { cwd: DIR } 185 | ) 186 | }) 187 | 188 | it('should link dependencies for a lerna repository', () => { 189 | linkDependencies(DIR, lernaProject, { countOf: [1, 1] }) 190 | expect(execa.sync).toHaveBeenLastCalledWith( 191 | 'lerna', 192 | ['exec', '--', 'yarn', 'link', 'another-example', 'another-example-1'], 193 | { 194 | cwd: DIR, 195 | } 196 | ) 197 | }) 198 | it('should do nothing if there are no links', () => { 199 | linkDependencies(DIR, { ...project, links: undefined }, { countOf: [1, 1] }) 200 | expect(execa.sync).toHaveBeenCalledTimes(0) 201 | }) 202 | }) 203 | 204 | describe('cloneTypeDefinition', () => { 205 | afterEach(() => { 206 | jest.clearAllMocks() 207 | rimraf.sync(DIR) 208 | }) 209 | 210 | it('should clone the type repo', () => { 211 | cloneTypeDefinition(DIR, 'example-type', { 212 | branch: 'master', 213 | remote: 'git://', 214 | typesPath: 'types', 215 | depth: 1, 216 | }) 217 | expect(execa.sync).toHaveBeenCalledWith('git', [ 218 | 'clone', 219 | 'git://', 220 | '/tmp/example/@types/example-type', 221 | '--branch', 222 | 'master', 223 | '--depth', 224 | '1', 225 | ]) 226 | }) 227 | 228 | it('should clone the type repo with depth', () => { 229 | cloneTypeDefinition(DIR, 'example-type', { 230 | branch: 'master', 231 | remote: 'git://', 232 | typesPath: 'types', 233 | depth: 3, 234 | }) 235 | expect(execa.sync).toHaveBeenCalledWith('git', [ 236 | 'clone', 237 | 'git://', 238 | '/tmp/example/@types/example-type', 239 | '--branch', 240 | 'master', 241 | '--depth', 242 | '3', 243 | ]) 244 | }) 245 | 246 | it('should checkout a different branch if it already exists', () => { 247 | mkdirp.sync('/tmp/example/@types/example-type/.git') 248 | cloneTypeDefinition(DIR, 'example-type', { 249 | branch: 'master', 250 | remote: 'git://', 251 | typesPath: 'types', 252 | depth: 3, 253 | }) 254 | expect(execa.sync).toHaveBeenCalledWith('git', ['checkout', 'master'], { 255 | cwd: '/tmp/example/@types/example-type', 256 | }) 257 | }) 258 | }) 259 | 260 | describe('cloneTypeDefinitions', () => { 261 | afterEach(() => { 262 | jest.clearAllMocks() 263 | rimraf.sync(DIR) 264 | }) 265 | 266 | it('should clone the type repo', () => { 267 | cloneTypeDefinitions(DIR, { 268 | 'example-type': { 269 | branch: 'master', 270 | remote: 'git://', 271 | typesPath: 'types', 272 | depth: 1, 273 | }, 274 | }) 275 | expect(execa.sync).toHaveBeenCalledWith('git', [ 276 | 'clone', 277 | 'git://', 278 | '/tmp/example/@types/example-type', 279 | '--branch', 280 | 'master', 281 | '--depth', 282 | '1', 283 | ]) 284 | }) 285 | }) 286 | 287 | describe('linkTypes', () => { 288 | afterEach(() => { 289 | jest.clearAllMocks() 290 | rimraf.sync(DIR) 291 | }) 292 | it('should link directories', () => { 293 | mkdirp.sync(path.join(DIR, 'example/@types/example-type')) 294 | mkdirp.sync(path.join(DIR, 'example/node_modules/@types')) 295 | linkTypes( 296 | path.join(DIR, 'example'), 297 | project, 298 | { 299 | baseDir: DIR, 300 | projects: [project], 301 | typeDefs: { 302 | 'example-type': { 303 | branch: 'master', 304 | remote: 'git://', 305 | typesPath: 'example-type', 306 | depth: 1, 307 | }, 308 | }, 309 | } 310 | ) 311 | }) 312 | }) 313 | -------------------------------------------------------------------------------- /src/cli/__tests__/lib.int.ts: -------------------------------------------------------------------------------- 1 | import { runCLI } from '../lib' 2 | import rimraf = require('rimraf') 3 | 4 | const BUILD_DIR = '/home/travis/example' 5 | // just a gutcheck 6 | describe('run', () => { 7 | it( 8 | 'runs without failure', 9 | () => { 10 | rimraf.sync(BUILD_DIR) 11 | runCLI([ 12 | '', 13 | '', 14 | 'setup', 15 | '-c', 16 | __dirname + '/../../__tests__/fixtures/projects.yml', 17 | '-t', 18 | BUILD_DIR, 19 | ]) 20 | }, 21 | 10 * 60 * 60 22 | ) 23 | }) 24 | -------------------------------------------------------------------------------- /src/cli/__tests__/lib.spec.ts: -------------------------------------------------------------------------------- 1 | import { runCLI } from '../lib' 2 | import * as rimraf from 'rimraf' 3 | import * as util from '../util' 4 | 5 | const BUILD_DIR = '/home/travis/example' 6 | 7 | jest.mock('../util') 8 | 9 | jest.spyOn(util.commandMap.setup, 'constructor') 10 | jest.mock('execa') 11 | 12 | describe('runCLI', () => { 13 | beforeEach(() => { 14 | rimraf.sync(BUILD_DIR) 15 | }) 16 | afterEach(() => { 17 | jest.resetAllMocks() 18 | }) 19 | 20 | it('runs the CLI interface', () => { 21 | const argv = [ 22 | '', 23 | '', 24 | 'setup', 25 | '-c', 26 | __dirname + '/../../__tests__/fixtures/projects.yml', 27 | '-t', 28 | BUILD_DIR, 29 | ] 30 | runCLI(argv) 31 | expect(util.getMeowConfig).toBeCalledTimes(1) 32 | expect(util.getMeowConfig).toBeCalledWith(argv) 33 | expect(util.commandMap.setup).toBeCalledTimes(1) 34 | }) 35 | 36 | it('shows command help when passing --help', () => { 37 | const argv = ['', '', 'setup', '--help'] 38 | runCLI(argv) 39 | expect(util.getMeowConfig).toBeCalledTimes(1) 40 | expect(util.getMeowConfig).toBeCalledWith(argv) 41 | }) 42 | 43 | it('shows help on invalid command', () => { 44 | const argv = ['', '', 'invalid'] 45 | expect(() => runCLI(argv)).toThrowError() 46 | expect(util.getMeowConfig).toBeCalledTimes(0) 47 | expect(util.listAvailableCommands).toBeCalledTimes(1) 48 | }) 49 | 50 | it('shows help on missing command', () => { 51 | const argv = ['', ''] 52 | expect(() => runCLI(argv)).toThrowError() 53 | expect(util.getMeowConfig).toBeCalledTimes(0) 54 | expect(util.listAvailableCommands).toBeCalledTimes(1) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /src/cli/index.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "../util"; 2 | 3 | try { 4 | require('./lib').runCLI(process.argv) 5 | } catch (err) { 6 | logger.error(err) 7 | process.exit(1) 8 | } 9 | -------------------------------------------------------------------------------- /src/cli/lib.ts: -------------------------------------------------------------------------------- 1 | import * as meow from 'meow' 2 | 3 | import chalk from 'chalk' 4 | 5 | import { 6 | listAvailableCommands, 7 | commandMap, 8 | getMeowConfig, 9 | defaultHelp 10 | } from './util' 11 | 12 | import { logger } from '../util' 13 | 14 | export function runCLI(argv: string[]) { 15 | const [, , commandArg] = argv 16 | 17 | if (!commandArg) { 18 | throw Error(`No command provided. Try: ${listAvailableCommands()}`) 19 | } 20 | if (!commandMap[commandArg]) { 21 | throw Error( 22 | `Command ${chalk.whiteBright.bold( 23 | commandArg 24 | )} does not exist.\nTry: ${listAvailableCommands()}` 25 | ) 26 | } 27 | 28 | const SelectedCmd = commandMap[commandArg] 29 | 30 | logger.meta(SelectedCmd.commandName) 31 | 32 | const meowConfig = getMeowConfig(argv) 33 | 34 | if (SelectedCmd.additionalFlags) { 35 | meowConfig.flags = Object.assign( 36 | meowConfig.flags, 37 | SelectedCmd.additionalFlags 38 | ) 39 | } 40 | 41 | const meowResult = meow(SelectedCmd.commandHelp, meowConfig) 42 | 43 | if (meowResult.flags.help || !commandArg) { 44 | logger.help(SelectedCmd.commandHelp + '\n' + defaultHelp) 45 | process.exit(0) 46 | } 47 | 48 | const Cmd = new SelectedCmd(meowResult.flags) 49 | Cmd.run() 50 | } 51 | -------------------------------------------------------------------------------- /src/cli/util.ts: -------------------------------------------------------------------------------- 1 | import * as buildOptions from 'minimist-options' 2 | 3 | import { Map } from '../types' 4 | import { Setup } from '../commands/Setup' 5 | import { Relink } from '../commands/Relink' 6 | import { Rebuild } from '../commands/Rebuild' 7 | 8 | export const commandMap: Map = { 9 | setup: Setup, 10 | install: Setup, 11 | relink: Relink, 12 | rebuild: Rebuild, 13 | } 14 | 15 | export const defaultHelp = ` 16 | Global Options: 17 | 18 | --base-dir, --target-dir, -b, -t 19 | specify which build directory to target. 20 | overrides config file value 21 | 22 | --config-path, -c 23 | specify path to config file 24 | ./projects.yml by default 25 | 26 | --help, -h 27 | help with the particular command 28 | ` 29 | 30 | export const defaultFlags: buildOptions.Options = { 31 | buildDir: { 32 | name: 'baseDir', 33 | alias: 'b', 34 | default: null, 35 | type: 'string', 36 | }, 37 | targetDir: { 38 | name: 'targetDir', 39 | alias: 't', 40 | default: null, 41 | type: 'string', 42 | }, 43 | configPath: { 44 | name: 'configPath', 45 | alias: 'c', 46 | type: 'string', 47 | }, 48 | help: { 49 | name: 'help', 50 | alias: 'h', 51 | type: 'boolean', 52 | }, 53 | } 54 | 55 | export const listAvailableCommands = () => { 56 | return Object.values(commandMap) 57 | .filter(cmd => cmd.commandName) 58 | .reduce((list, cmd) => `${list}\n - yarn-compose ${cmd.commandName}`, '') 59 | } 60 | 61 | export const getMeowConfig = (argv: string[]) => ({ 62 | flags: defaultFlags, 63 | autoHelp: false, 64 | argv: argv.slice(3), 65 | }) 66 | -------------------------------------------------------------------------------- /src/commands/Rebuild.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '../Command' 2 | import { CommandInstanceOptions } from '../types' 3 | 4 | import { buildProject } from '../lib' 5 | 6 | export class Rebuild extends Command { 7 | static commandName = 'rebuild' 8 | 9 | static commandHelp = ` 10 | re-builds dependencies 11 | ` 12 | 13 | constructor(options: CommandInstanceOptions) { 14 | super(options) 15 | } 16 | 17 | public run() { 18 | super.eachProject(buildProject) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/commands/Relink.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '../Command' 2 | import { NodeProject, TaskOptions, CommandInstanceOptions } from '../types' 3 | 4 | import { linkTypes, linkDependencies, linkSelf } from '../lib' 5 | 6 | const linkedFilter = (project: NodeProject) => 7 | !!project.links && project.links.length > 0 8 | 9 | export class Relink extends Command { 10 | static commandName = 'relink' 11 | 12 | static commandHelp = ` 13 | re-links dependencies 14 | ` 15 | 16 | constructor(options: CommandInstanceOptions) { 17 | super(options) 18 | this.relinkDependencies = this.relinkDependencies.bind(this) 19 | } 20 | 21 | private relinkDependencies( 22 | projectDir: string, 23 | project: NodeProject, 24 | opts: TaskOptions 25 | ) { 26 | if (project.types) { 27 | linkTypes(projectDir, project, this.config) 28 | } 29 | linkDependencies(projectDir, project, opts) 30 | } 31 | 32 | public run() { 33 | super.eachProject(linkSelf) 34 | super.eachProject(this.relinkDependencies, { 35 | filter: linkedFilter, 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/commands/Setup.ts: -------------------------------------------------------------------------------- 1 | import * as execa from 'execa' 2 | import * as path from 'path' 3 | import * as fs from 'fs' 4 | import * as mkdirp from 'mkdirp' 5 | 6 | import { NodeProject, TaskOptions, CommandInstanceOptions } from '../types' 7 | import { Command } from '../Command' 8 | import { logger } from '../util' 9 | 10 | import { 11 | linkTypes, 12 | linkDependencies, 13 | buildProject, 14 | linkSelf, 15 | cloneTypeDefinitions, 16 | cloneAndInstall, 17 | } from '../lib' 18 | 19 | export interface SetupInstanceOptions extends CommandInstanceOptions { 20 | force?: boolean 21 | } 22 | 23 | export class Setup extends Command { 24 | static commandName = 'setup' 25 | 26 | static commandHelp = ` 27 | sets up project workspace, clones and installs projects, type definitions, dbuilds and links dependencies 28 | 29 | Usage: 30 | $ yarn-compose setup 31 | expects projects.yml by default 32 | $ yarn-compose setup -c path/to/config.yml 33 | or, specify a path to a config file 34 | 35 | Options: 36 | --force, -f 37 | force install 38 | ` 39 | 40 | static additionalArgs = { 41 | force: { 42 | name: 'force', 43 | alias: '-f', 44 | type: 'boolean', 45 | }, 46 | } 47 | 48 | constructor(options: SetupInstanceOptions) { 49 | super(options) 50 | this.setupProject = this.setupProject.bind(this) 51 | } 52 | 53 | private setupWorkingDirectory() { 54 | const cwd = path.join(this.config.baseDir) 55 | let isGitRepo = true 56 | try { 57 | execa.sync('git', ['status'], { cwd, preferLocal: false }) 58 | } catch (err) { 59 | isGitRepo = false 60 | } 61 | if (isGitRepo) { 62 | throw Error(`cannot intialize in a git repository`) 63 | } 64 | if (!fs.existsSync(cwd)) { 65 | logger.warn(`creating working directory`) 66 | mkdirp.sync(this.config.baseDir) 67 | } else { 68 | logger.info(`setup at existing baseDir ${this.config.baseDir}`) 69 | } 70 | } 71 | 72 | private setupProject( 73 | projectDir: string, 74 | project: NodeProject, 75 | opts: TaskOptions 76 | ) { 77 | if (project.types) { 78 | linkTypes(projectDir, project, this.config) 79 | } 80 | linkDependencies(projectDir, project, opts) 81 | buildProject(projectDir, project, opts) 82 | linkSelf(projectDir, project, opts) 83 | } 84 | 85 | public run() { 86 | this.setupWorkingDirectory() 87 | super.eachProject(cloneAndInstall) 88 | cloneTypeDefinitions(this.config.baseDir, this.config.typeDefs) 89 | super.eachProject(this.setupProject) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/commands/__tests__/Rebuild.spec.ts: -------------------------------------------------------------------------------- 1 | import { Rebuild } from '../Rebuild' 2 | import * as rimraf from 'rimraf' 3 | import * as lib from '../../lib' 4 | import { CommandInstanceOptions } from '../../types'; 5 | 6 | jest.mock('../../lib') 7 | 8 | const CONFIG_PATH = __dirname + '/../../__tests__/fixtures/projects.yml' 9 | 10 | const BUILD_DIR = '/tmp/yarn-compose' 11 | 12 | describe('Setup', () => { 13 | afterEach(() => { 14 | rimraf.sync(BUILD_DIR) 15 | }) 16 | it('should instantiate and call lib functions', () => { 17 | const options: CommandInstanceOptions = { 18 | configPath: CONFIG_PATH 19 | } 20 | new Rebuild(options).run() 21 | expect(lib.buildProject).toBeCalledTimes(6) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /src/commands/__tests__/Relink.spec.ts: -------------------------------------------------------------------------------- 1 | import { Relink } from '../Relink' 2 | import * as rimraf from 'rimraf' 3 | import * as lib from '../../lib' 4 | import { CommandInstanceOptions } from '../../types'; 5 | 6 | jest.mock('../../lib') 7 | 8 | const CONFIG_PATH = __dirname + '/../../__tests__/fixtures/projects.yml' 9 | 10 | const BUILD_DIR = '/tmp/yarn-compose' 11 | 12 | describe('Setup', () => { 13 | afterEach(() => { 14 | rimraf.sync(BUILD_DIR) 15 | }) 16 | it('should instantiate and call lib functions', () => { 17 | const config: CommandInstanceOptions = { 18 | configPath: CONFIG_PATH 19 | } 20 | new Relink(config).run() 21 | expect(lib.linkSelf).toBeCalledTimes(6) 22 | expect(lib.linkDependencies).toBeCalledTimes(5) 23 | expect(lib.linkTypes).toBeCalledTimes(2) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /src/commands/__tests__/Setup.spec.ts: -------------------------------------------------------------------------------- 1 | import { Setup, SetupInstanceOptions } from '../Setup' 2 | import * as rimraf from 'rimraf' 3 | import * as lib from '../../lib' 4 | import * as execa from 'execa' 5 | 6 | jest.mock('../../lib') 7 | 8 | const CONFIG_PATH = __dirname + '/../../__tests__/fixtures/projects.yml' 9 | 10 | const BUILD_DIR = '/tmp/yarn-compose' 11 | 12 | const options: SetupInstanceOptions = { 13 | configPath: CONFIG_PATH, 14 | } 15 | 16 | describe('Setup', () => { 17 | afterEach(() => { 18 | rimraf.sync(BUILD_DIR) 19 | }) 20 | it('should instantiate and call lib functions', () => { 21 | new Setup(options).run() 22 | expect(lib.buildProject).toBeCalledTimes(6) 23 | expect(lib.cloneAndInstall).toBeCalledTimes(6) 24 | expect(lib.linkSelf).toBeCalledTimes(6) 25 | expect(lib.linkDependencies).toBeCalledTimes(6) 26 | expect(lib.linkTypes).toBeCalledTimes(2) 27 | }) 28 | 29 | it('should throw when you attempt to use an existing git repo', () => { 30 | execa.sync('git', ['init', BUILD_DIR]) 31 | expect(() => new Setup(options).run()).toThrow( 32 | 'cannot intialize in a git repository' 33 | ) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Setup } from './commands/Setup' 2 | export { Relink } from './commands/Relink' 3 | export { Rebuild } from './commands/Rebuild' 4 | export { Command } from './Command' 5 | export * from './lib' 6 | export * from './cli/lib' 7 | -------------------------------------------------------------------------------- /src/lib.ts: -------------------------------------------------------------------------------- 1 | import * as execa from 'execa' 2 | import * as fs from 'fs' 3 | import * as rimraf from 'rimraf' 4 | import * as path from 'path' 5 | import * as mkdirp from 'mkdirp' 6 | import { logger, repoExists } from './util' 7 | 8 | import { NodeProject, CommandConfig, TypeDef, TaskOptions, Map } from './types' 9 | 10 | export function installDependencies( 11 | projectDir: string, 12 | project: NodeProject, 13 | opts: TaskOptions 14 | ) { 15 | const args = ['install', '--ignore-scripts'] 16 | if (opts.force) { 17 | args.push('--force') 18 | } 19 | 20 | execa.sync(project.npmClient, args, { cwd: projectDir }) 21 | logger.iterateInfo( 22 | `installed dependencies for ${project.package}`, 23 | opts.countOf 24 | ) 25 | } 26 | 27 | export function buildProject( 28 | projectDir: string, 29 | project: NodeProject, 30 | opts: TaskOptions 31 | ) { 32 | if (project.lerna) { 33 | execa.sync('lerna', ['run', 'build'], { cwd: projectDir }) 34 | logger.iterateInfo(`built ${project.package}`, opts.countOf) 35 | return 36 | } 37 | execa.sync(project.npmClient, [project.buildScript], { cwd: projectDir }) 38 | logger.iterateInfo(`built ${project.package}`, opts.countOf) 39 | return 40 | } 41 | 42 | export function linkSelf( 43 | projectDir: string, 44 | project: NodeProject, 45 | opts: TaskOptions 46 | ) { 47 | let cwd = projectDir 48 | if (project.linkFrom) { 49 | cwd = path.join(projectDir, project.linkFrom) 50 | } 51 | try { 52 | if (!project.lerna) { 53 | execa.sync(project.npmClient, ['unlink'], { cwd }) 54 | logger.iterateInfo(`unlinking ${project.package}`, opts.countOf) 55 | } else { 56 | execa.sync('lerna', ['exec', project.npmClient, 'unlink'], { cwd }) 57 | logger.iterateInfo( 58 | `unlinking subpackages in ${project.package}`, 59 | opts.countOf 60 | ) 61 | } 62 | } catch (err) { 63 | logger.warn( 64 | `No registered package found for "${project.package}" yet. Linking now...` 65 | ) 66 | } 67 | if (!project.lerna) { 68 | execa.sync(project.npmClient, ['link'], { cwd }) 69 | logger.iterateInfo(`linked ${project.package}`, opts.countOf) 70 | } else { 71 | execa.sync('lerna', ['exec', project.npmClient, 'link'], { cwd }) 72 | logger.iterateInfo(`linked subpackges in ${project.package}`, opts.countOf) 73 | } 74 | 75 | return 76 | } 77 | 78 | export function linkDependencies( 79 | projectDir: string, 80 | project: NodeProject, 81 | opts: TaskOptions 82 | ) { 83 | if (project.links) { 84 | if (project.lerna) { 85 | execa.sync( 86 | 'lerna', 87 | ['exec', '--', project.npmClient, 'link', ...project.links], 88 | { 89 | cwd: projectDir, 90 | } 91 | ) 92 | logger.iterateInfo( 93 | `linked subpackage dependencies in ${project.package}`, 94 | opts.countOf 95 | ) 96 | } else { 97 | execa.sync(project.npmClient, ['link', ...project.links], { 98 | cwd: projectDir, 99 | }) 100 | logger.iterateInfo( 101 | `linked dependencies of ${project.package}`, 102 | opts.countOf 103 | ) 104 | } 105 | 106 | return 107 | } 108 | } 109 | 110 | export function cloneTypeDefinitions(baseDir: string, typeDefs: Map) { 111 | for (let [typeDefName, typeInfo] of Object.entries(typeDefs)) { 112 | cloneTypeDefinition(baseDir, typeDefName, typeInfo) 113 | } 114 | } 115 | 116 | export function cloneTypeDefinition( 117 | baseDir: string, 118 | typeDefName: string, 119 | typeInfo: TypeDef 120 | ) { 121 | const typeDefPath = path.join(baseDir, '@types', typeDefName) 122 | if (!fs.existsSync(typeDefName)) { 123 | mkdirp.sync(typeDefName) 124 | } 125 | if (repoExists(typeDefPath)) { 126 | logger.warn(`Type repository for ${typeDefName} is present`) 127 | checkoutBranch(typeDefPath, typeInfo.branch) 128 | return 129 | } 130 | logger.warn('setting up typeDefs, this could take a while...') 131 | execa.sync( 132 | 'git', 133 | [ 134 | 'clone', 135 | typeInfo.remote, 136 | typeDefPath, 137 | '--branch', 138 | typeInfo.branch, 139 | '--depth', 140 | typeInfo.depth.toString(), 141 | ] 142 | ) 143 | return logger.info(`cloned typeDefinition for ${typeDefName}`) 144 | } 145 | 146 | export function linkType( 147 | type: string, 148 | projectDir: string, 149 | baseDir: string, 150 | typeDef: TypeDef 151 | ) { 152 | const typeSymbolicPath = path.join(projectDir, `node_modules/@types/${type}`) 153 | const typePath = path.join(baseDir, '@types', typeDef.typesPath) 154 | rimraf.sync(typeSymbolicPath) 155 | fs.symlinkSync(typePath, typeSymbolicPath, 'dir') 156 | } 157 | 158 | export function linkTypes( 159 | projectDir: string, 160 | project: NodeProject, 161 | config: CommandConfig 162 | ) { 163 | const { baseDir, typeDefs } = config 164 | if (project.types) { 165 | for (let type of project.types) { 166 | linkType(type, projectDir, baseDir, typeDefs[type]) 167 | } 168 | } 169 | } 170 | 171 | export function cloneProject(remote: string, projectDir: string) { 172 | if (!repoExists(projectDir)) { 173 | return execa.sync('git', ['clone', remote, projectDir]) 174 | } 175 | return 176 | } 177 | 178 | export function checkoutBranch(projectDir: string, branch: string) { 179 | if (!repoExists(projectDir)) { 180 | throw Error( 181 | `Cannot checkout ${branch}, ${projectDir} is not a git repository` 182 | ) 183 | } 184 | return execa.sync('git', ['checkout', branch], { cwd: projectDir }) 185 | } 186 | 187 | export function cloneAndInstall( 188 | projectDir: string, 189 | project: NodeProject, 190 | opts: TaskOptions 191 | ) { 192 | cloneProject(project.remote, projectDir) 193 | checkoutBranch(projectDir, project.branch) 194 | logger.iterateInfo( 195 | `cloned and checked out ${project.package}#${project.branch}`, 196 | opts.countOf 197 | ) 198 | installDependencies(projectDir, project, opts) 199 | } 200 | -------------------------------------------------------------------------------- /src/schema.ts: -------------------------------------------------------------------------------- 1 | export const configSchema = { 2 | $id: 'http://example.com/schemas/schema.json', 3 | type: 'object', 4 | properties: { 5 | baseDir: { type: 'string' }, 6 | projects: { 7 | type: 'array', 8 | items: { 9 | type: 'object', 10 | properties: { 11 | branch: { type: 'string' }, 12 | package: { type: 'string' }, 13 | remote: { type: 'string' }, 14 | lerna: { type: 'boolean', default: false }, 15 | links: { type: 'array', items: { type: 'string' } }, 16 | types: { type: 'array', items: { type: 'string' } }, 17 | buildScript: { type: 'string', default: 'build' }, 18 | linkFrom: { type: 'string' }, 19 | npmClient: { 20 | type: 'string', 21 | enum: ['npm', 'yarn', 'cnpm'], 22 | default: 'yarn' 23 | }, 24 | }, 25 | required: ['branch', 'package', 'remote'], 26 | additionalProperties: false, 27 | }, 28 | }, 29 | typeDefs: { 30 | type: 'object', 31 | additionalProperties: { 32 | type: 'object', 33 | properties: { 34 | branch: { type: 'string' }, 35 | remote: { type: 'string' }, 36 | typesPath: { type: 'string' }, 37 | depth: { type: 'integer', default: 1 }, 38 | }, 39 | required: ['branch', 'remote', 'typesPath'], 40 | }, 41 | }, 42 | }, 43 | additionalProperties: false, 44 | required: ['projects'], 45 | } 46 | -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | export interface Map { 2 | [K: string]: T 3 | } 4 | declare module 'pkginfo' 5 | 6 | 7 | export interface NodeProject { 8 | branch: string 9 | package: string 10 | remote: string 11 | buildScript: string 12 | lerna: boolean 13 | npmClient: 'npm' | 'yarn' | 'cnpm' 14 | links?: string[] 15 | types?: string[] 16 | linkFrom?: string 17 | } 18 | 19 | export interface TypeDef { 20 | branch: string 21 | remote: string 22 | typesPath: string 23 | depth: number 24 | } 25 | 26 | export interface TaskOptions { 27 | countOf: number[] 28 | force?: boolean 29 | } 30 | 31 | // Common flags/options that we expect 32 | export interface CommandInstanceOptions { 33 | baseDir?: string 34 | targetDir?: string 35 | configPath?: string 36 | help?: boolean 37 | } 38 | 39 | export interface CommandConfig { 40 | baseDir: string 41 | typeDefs: Map 42 | projects: NodeProject[] 43 | } 44 | -------------------------------------------------------------------------------- /src/types/pkginfo.d.ts: -------------------------------------------------------------------------------- 1 | export = pkginfo 2 | declare function pkginfo(pmodule: any, options?: any, ...args: any[]): any 3 | declare namespace pkginfo { 4 | function find(pmodule: any, dir: any): any 5 | function read(pmodule: any, dir: any): any 6 | const version: string 7 | } 8 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import * as yaml from 'js-yaml' 2 | import * as fs from 'fs' 3 | import * as path from 'path' 4 | import chalk from 'chalk' 5 | import * as getPkgInfo from 'pkginfo' 6 | 7 | const pkginfo = getPkgInfo(module) 8 | 9 | const LOG_PREFIX = 'yarn-compose' 10 | 11 | export function getConfig(configPath: string) { 12 | return yaml.safeLoad(fs.readFileSync(configPath, 'utf8')) 13 | } 14 | 15 | export function repoExists(projectPath: string) { 16 | return fs.existsSync(path.join(projectPath, '.git')) 17 | } 18 | 19 | const log = console.log 20 | export const logger = { 21 | meta: (msg: string) => 22 | log(chalk.whiteBright.bold(`${LOG_PREFIX} ${msg} v${pkginfo.version}\n`)), 23 | success: (msg: string) => log(`${chalk.green.bold('success')} ${msg}`), 24 | info: (msg: string) => log(`${chalk.green.bold('success')} ${msg}`), 25 | iterateInfo: (msg: string, countOf: number[]) => 26 | log( 27 | `${chalk.grey(`[${countOf[0]}/${countOf[1]}]`)} ${chalk.green.bold( 28 | 'success' 29 | )} ${msg}` 30 | ), 31 | warn: (msg: string) => log(`${chalk.yellow('warn')} ${msg}`), 32 | error: (msg: any) => log(`${chalk.red('error')} ${msg}`), 33 | help: (msg: string) => log(msg), 34 | } 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "*": ["src/types/*"] 6 | }, 7 | "target": "esnext", 8 | "module": "commonjs", 9 | "outDir": "./lib", 10 | "strict": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "allowSyntheticDefaultImports": true, 14 | "skipLibCheck": true, 15 | "typeRoots": [ 16 | "./src/types", 17 | "node_modules/@types" 18 | ] 19 | }, 20 | "exclude": [ 21 | "**/__tests__/*", 22 | "lib" 23 | ] 24 | } 25 | --------------------------------------------------------------------------------