├── .looprc ├── .husky ├── .gitignore ├── pre-commit └── commit-msg ├── commitlint.config.js ├── .gitignore ├── .github ├── renovate.json ├── ISSUE_TEMPLATE │ ├── question.md │ ├── bug.md │ └── feature.md └── stale.yml ├── .prettierrc ├── jest.json ├── bin └── meta ├── __tests__ ├── index.js └── plugins │ ├── index.js │ └── __snapshots__ │ └── index.js.snap ├── .travis.yml ├── lib ├── registerPlugin.js ├── __mocks__ │ └── fs.js ├── __tests__ │ ├── __snapshots__ │ │ └── findPlugins.js.snap │ └── findPlugins.js └── findPlugins.js ├── .meta ├── LICENSE ├── index.js ├── package.json └── README.md /.looprc: -------------------------------------------------------------------------------- 1 | .meta -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx pretty-quick --staged 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _book 2 | node_modules 3 | npm-debug.log 4 | coverage 5 | plugins/* 6 | yarn-error.log 7 | coverage 8 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "automerge": true, 4 | "major": { 5 | "automerge": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": true, 3 | "printWidth": 120, 4 | "semi": true, 5 | "singleQuote": true, 6 | "trailingComma": "es5" 7 | } -------------------------------------------------------------------------------- /jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "testEnvironment": "node", 3 | "modulePaths": ["bin", "index.js", "/node_modules/"], 4 | "collectCoverageFrom": ["bin/*", "index.js"] 5 | } 6 | -------------------------------------------------------------------------------- /bin/meta: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | process.on('uncaughtException', (err) => { 4 | console.error(err); 5 | process.exit(1); 6 | }); 7 | 8 | require('..').run(process.cwd(), process.argv); 9 | -------------------------------------------------------------------------------- /__tests__/index.js: -------------------------------------------------------------------------------- 1 | const index = require('../index.js'); 2 | 3 | jest.spyOn(process, 'exit').mockImplementation(() => {}); 4 | 5 | describe('index.js', () => { 6 | it('should exist', () => { 7 | expect(index).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🤓 Question 3 | labels: 'question' 4 | about: Ask the maintainers (as a last resort) 5 | --- 6 | 7 | ## 🤓 Question 8 | 9 | (You _must_ search the issues before asking your question. Please consider asking in [Gitter](https://gitter.im/meta) first.) 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug report 3 | labels: 'bug' 4 | about: Create a report to help us improve 5 | --- 6 | 7 | ## 🐛 Bug Report 8 | 9 | (A clear and concise description of what the bug is.) 10 | 11 | ## To Reproduce 12 | 13 | Steps to reproduce the behavior: 14 | 15 | ## Expected behavior 16 | 17 | (A clear and concise description of what you expected to happen.) 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 Feature Proposal 3 | labels: 'enhancement' 4 | about: Submit a proposal for a new feature 5 | --- 6 | 7 | ## 🚀 Feature Proposal 8 | 9 | (A clear and concise description of what the feature is.) 10 | 11 | ## Motivation 12 | 13 | (Please outline the motivation for the proposal.) 14 | 15 | ## Example 16 | 17 | (Please provide an example for how this feature would be used.) 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 10 4 | - 12 5 | 6 | notifications: 7 | email: false 8 | 9 | before_install: 10 | - npm install -g codecov 11 | script: 12 | - npm test 13 | - codecov 14 | 15 | branches: 16 | except: 17 | - '/^v\d+\.\d+\.\d+$/' 18 | 19 | jobs: 20 | include: 21 | - stage: deploy 22 | if: branch == master && !fork 23 | node_js: node # pre-installed version 24 | script: 25 | - npm install -g semantic-release 26 | - semantic-release 27 | -------------------------------------------------------------------------------- /lib/registerPlugin.js: -------------------------------------------------------------------------------- 1 | const { gray, green, red, yellow } = require('chalk'); 2 | const debug = require('debug')('meta'); 3 | const path = require('path'); 4 | const tildify = require('tildify'); 5 | 6 | module.exports = (program, pluginPath) => { 7 | try { 8 | const plugin = require(pluginPath); 9 | if (plugin.register) { 10 | plugin.register(program); 11 | debug(` ${green('+')} ${path.basename(pluginPath)} ${yellow(tildify(pluginPath))}`); // prettier-ignore 12 | } else { 13 | debug(` ${red('×')} ${path.basename(pluginPath)} ${gray('(not a plugin)')}`); // prettier-ignore 14 | } 15 | } catch (e) { 16 | console.warn(`Plugin registration failed: '${tildify(pluginPath)}'`); 17 | console.error(e); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.meta: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [ 3 | ".git", 4 | ".vagrant", 5 | ".vscode", 6 | "node_modules" 7 | ], 8 | "projects": { 9 | "plugins/find-file-recursively-up": "git@github.com:mateodelnorte/find-file-recursively-up.git", 10 | "plugins/get-meta-file": "git@github.com:mateodelnorte/get-meta-file.git", 11 | "plugins/loop": "git@github.com:mateodelnorte/loop.git", 12 | "plugins/meta-exec": "git@github.com:mateodelnorte/meta-exec.git", 13 | "plugins/meta-gh": "git@github.com:mateodelnorte/meta-gh.git", 14 | "plugins/meta-git": "git@github.com:mateodelnorte/meta-git.git", 15 | "plugins/meta-init": "git@github.com:mateodelnorte/meta-init.git", 16 | "plugins/meta-loop": "git@github.com:mateodelnorte/meta-loop.git", 17 | "plugins/meta-npm": "git@github.com:mateodelnorte/meta-npm.git", 18 | "plugins/meta-project": "git@github.com:mateodelnorte/meta-project.git", 19 | "plugins/meta-yarn": "git@github.com:mateodelnorte/meta-yarn.git", 20 | "plugins/symlink-meta-dependencies": "git@github.com:mateodelnorte/symlink-meta-dependencies.git" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lib/__mocks__/fs.js: -------------------------------------------------------------------------------- 1 | const fs = jest.genMockFromModule('fs'); 2 | 3 | let files; 4 | fs.reset = () => (files = {}); 5 | fs.write = (filePath, content) => { 6 | const parts = filePath.split('/'); 7 | for (let i = 0, parent = ''; i < parts.length; i++) { 8 | const name = parent + (i > 1 ? '/' : '') + (parts[i] || '/'); 9 | if (i < parts.length - 1) { 10 | if (name in files) { 11 | parent = name; 12 | continue; 13 | } 14 | files[name] = []; 15 | } else { 16 | files[name] = content; 17 | } 18 | if (parent) files[parent].push(parts[i]); 19 | parent = name; 20 | } 21 | }; 22 | 23 | fs.statSync = name => { 24 | if (!(name in files)) { 25 | throw Error(`Path does not exist: '${name}'`); 26 | } 27 | const stats = new fs.Stats(); 28 | stats.isDirectory = () => Array.isArray(files[name]); 29 | return stats; 30 | }; 31 | 32 | fs.readdirSync = name => { 33 | const names = files[name]; 34 | if (Array.isArray(names)) return names; 35 | throw Error(`Path is not a directory: '${name}'`); 36 | }; 37 | 38 | module.exports = fs; 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Matt Walters 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 | -------------------------------------------------------------------------------- /lib/__tests__/__snapshots__/findPlugins.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`findPlugins falls back to (npm root -g) when $NODE_PATH is empty 1`] = ` 4 | Map { 5 | "meta-1" => "/npm/root/meta-1", 6 | "meta-2" => "/npm/root/@foo/meta-2", 7 | } 8 | `; 9 | 10 | exports[`findPlugins finds non-scoped plugins 1`] = ` 11 | Map { 12 | "meta-foo" => "/node_modules/meta-foo", 13 | } 14 | `; 15 | 16 | exports[`findPlugins finds scoped plugins 1`] = ` 17 | Map { 18 | "meta-foo" => "/node_modules/@foo/meta-foo", 19 | } 20 | `; 21 | 22 | exports[`findPlugins ignores non-plugins 1`] = `Map {}`; 23 | 24 | exports[`findPlugins searches every global directory 1`] = ` 25 | Map { 26 | "meta-foo" => "/foo/meta-foo", 27 | "meta-bar" => "/bar/@foo/meta-bar", 28 | } 29 | `; 30 | 31 | exports[`findPlugins searches every parent directory 1`] = ` 32 | Map { 33 | "meta-3" => "/1/2/3/node_modules/meta-3", 34 | "meta-2" => "/1/2/node_modules/meta-2", 35 | "meta-1" => "/1/node_modules/meta-1", 36 | "meta-0" => "/node_modules/meta-0", 37 | } 38 | `; 39 | 40 | exports[`findPlugins skips plugins whose name is already in use 1`] = ` 41 | Map { 42 | "meta-foo" => "/node_modules/@foo/meta-foo", 43 | } 44 | `; 45 | 46 | exports[`findPlugins tolerates missing "node_modules" when searching parent directories 1`] = ` 47 | Map { 48 | "meta-3" => "/1/2/3/node_modules/meta-3", 49 | "meta-1" => "/1/node_modules/meta-1", 50 | } 51 | `; 52 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const { gray, green } = require('chalk'); 2 | const debug = require('debug')('meta'); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | const meta = require('./package.json'); 7 | const findPlugins = require('./lib/findPlugins'); 8 | const registerPlugin = require('./lib/registerPlugin'); 9 | 10 | // Plugins depended on by the `meta` package. 11 | const corePlugins = new Map(); 12 | Object.keys(meta.dependencies).forEach((name) => { 13 | if (/^meta-/.test(name)) { 14 | const packagePath = require.resolve(path.join(name, 'package.json')); 15 | corePlugins.set(name, path.dirname(packagePath)); 16 | } 17 | }); 18 | 19 | exports.version = meta.version; 20 | 21 | exports.run = (cwd, argv) => { 22 | const program = require('commander').version(meta.version); 23 | 24 | // Ensure `cwd` is actually the working directory. 25 | cwd = path.resolve(cwd); 26 | process.chdir(cwd); 27 | 28 | // Load user plugins. 29 | const userPlugins = findPlugins(cwd); 30 | if (userPlugins.size) { 31 | debug(`\nLoading plugins:`); 32 | userPlugins.forEach((pluginPath) => registerPlugin(program, pluginPath)); 33 | } 34 | 35 | // Load core plugins after, so users can override them. 36 | debug(`\nLoading core plugins:`); 37 | corePlugins.forEach((pluginPath, name) => { 38 | if (userPlugins.has(name)) return debug(` ${green('+')} ${name} ${gray('(skip)')}`); // prettier-ignore 39 | registerPlugin(program, pluginPath); 40 | }); 41 | 42 | if (fs.existsSync('.meta')) { 43 | const gitPlugin = userPlugins.get('meta-git') || 'meta-git'; 44 | require(gitPlugin).update({ dryRun: true }); 45 | } 46 | 47 | program.parse(argv); 48 | }; 49 | -------------------------------------------------------------------------------- /__tests__/plugins/index.js: -------------------------------------------------------------------------------- 1 | const cp = require('child_process'); 2 | 3 | describe('plugins', () => { 4 | it('can be executed using exact executable name', () => { 5 | const cmd = cp.execSync('npx meta-project').toString(); 6 | expect(cmd).toMatchSnapshot(); 7 | }); 8 | it('can be executed using git style subcommands', () => { 9 | const cmd = cp.execSync('npx meta project').toString(); 10 | expect(cmd).toMatchSnapshot(); 11 | }); 12 | it('can be executed using local plugin discovery', () => { 13 | const cmd = cp.execSync('./bin/meta project').toString(); 14 | expect(cmd).toMatchSnapshot(); 15 | }); 16 | it('should include meta gh as dev dependency', () => { 17 | const cmd = cp.execSync('./bin/meta gh').toString(); 18 | expect(cmd).toMatchSnapshot(); 19 | }); 20 | it('should include meta git as dependency', () => { 21 | const cmd = cp.execSync('./bin/meta git').toString(); 22 | expect(cmd).toMatchSnapshot(); 23 | }); 24 | it('should include meta init as dependency', () => { 25 | const cmd = cp.execSync('./bin/meta init').toString(); 26 | expect(cmd).toMatchSnapshot(); 27 | }); 28 | it('should include meta exec as dependency', () => { 29 | const cmd = cp.execSync('./bin/meta exec').toString(); 30 | expect(cmd).toMatchSnapshot(); 31 | }); 32 | it('should include meta npm as dev dependency', () => { 33 | const cmd = cp.execSync('./bin/meta npm').toString(); 34 | expect(cmd).toMatchSnapshot(); 35 | }); 36 | it('should include meta project as dependency', () => { 37 | const cmd = cp.execSync('./bin/meta project').toString(); 38 | expect(cmd).toMatchSnapshot(); 39 | }); 40 | it('should include meta yarn as dev dependency', () => { 41 | const cmd = cp.execSync('./bin/meta yarn').toString(); 42 | expect(cmd).toMatchSnapshot(); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /lib/findPlugins.js: -------------------------------------------------------------------------------- 1 | const { green, gray, yellow } = require('chalk'); 2 | const cp = require('child_process'); 3 | const debug = require('debug')('meta'); 4 | const fs = require('fs'); 5 | const path = require('path'); 6 | const tildify = require('tildify'); 7 | 8 | /** 9 | * Meta's custom plugin resolution logic 10 | * 11 | * The `cwd` argument must be an absolute path. 12 | * 13 | * Returns a `Map` object that maps plugin names to plugin paths. 14 | */ 15 | module.exports = (cwd, searchDir = 'node_modules') => { 16 | const rootDir = path.parse(cwd).root; 17 | const plugins = new Map(); 18 | const onPlugin = (name, filePath, scopeName) => { 19 | const skipped = plugins.has(name); 20 | skipped || plugins.set(name, filePath); 21 | debug(` ${green('+')} ${scopeName ? scopeName + '/' : ''}${name}${skipped ? gray(' (skip)') : ''}`); // prettier-ignore 22 | }; 23 | 24 | debug(`\nResolving plugins:`); 25 | 26 | // Search relative to `cwd` first, then search every parent directory. 27 | let baseDir = cwd; 28 | while (true) { 29 | findNearbyPlugins(path.join(baseDir, searchDir), onPlugin); 30 | if (baseDir !== rootDir) baseDir = path.dirname(baseDir); 31 | else break; 32 | } 33 | 34 | // Search relative to every directory in $NODE_PATH. 35 | const globalRoots = process.env.NODE_PATH || getDefaultGlobalRoot(); 36 | globalRoots.split(':').forEach(cwd => findNearbyPlugins(cwd, onPlugin)); 37 | 38 | return plugins; 39 | }; 40 | 41 | function getDefaultGlobalRoot() { 42 | return (cp.execSync('npm root -g') + '').trim(); 43 | } 44 | 45 | /** Check a directory for potentially-scoped /^meta-/ packages */ 46 | function findNearbyPlugins(cwd, onPlugin) { 47 | if (isDir(cwd)) { 48 | debug(` ${yellow(tildify(cwd))}`); 49 | fs.readdirSync(cwd).forEach(name => { 50 | const filePath = path.join(cwd, name); 51 | if (name[0] === '@') { 52 | const scopeName = name; 53 | const scopePath = filePath; 54 | fs.readdirSync(scopePath).forEach(name => { 55 | if (/^meta-/.test(name)) 56 | onPlugin(name, path.join(scopePath, name), scopeName); 57 | }); 58 | } else if (/^meta-/.test(name)) { 59 | onPlugin(name, filePath); 60 | } 61 | }); 62 | } else { 63 | debug(` ${gray(tildify(cwd))}`); 64 | } 65 | } 66 | 67 | function isDir(filePath) { 68 | try { 69 | return fs.statSync(filePath).isDirectory(); 70 | } catch (e) {} 71 | return false; 72 | } 73 | -------------------------------------------------------------------------------- /lib/__tests__/findPlugins.js: -------------------------------------------------------------------------------- 1 | jest.mock('fs'); 2 | 3 | const findPlugins = require('../findPlugins'); 4 | const cp = require('child_process'); 5 | const fs = require('fs'); 6 | 7 | describe('findPlugins', () => { 8 | beforeEach(() => { 9 | fs.reset(); 10 | process.env.NODE_PATH = ''; 11 | }); 12 | 13 | it('ignores non-plugins', () => { 14 | fs.write('/node_modules/@foo/bar/index.js', ''); 15 | fs.write('/node_modules/foo/index.js', ''); 16 | expect(findPlugins('/')).toMatchSnapshot(); 17 | }); 18 | 19 | it('finds scoped plugins', () => { 20 | fs.write('/node_modules/@foo/meta-foo/index.js', ''); 21 | expect(findPlugins('/')).toMatchSnapshot(); 22 | }); 23 | 24 | it('finds non-scoped plugins', () => { 25 | fs.write('/node_modules/meta-foo/index.js', ''); 26 | expect(findPlugins('/')).toMatchSnapshot(); 27 | }); 28 | 29 | it('skips plugins whose name is already in use', () => { 30 | fs.write('/node_modules/@foo/meta-foo/index.js', ''); 31 | fs.write('/node_modules/meta-foo/index.js', ''); 32 | expect(findPlugins('/')).toMatchSnapshot(); 33 | }); 34 | 35 | it('searches every parent directory', () => { 36 | fs.write('/node_modules/meta-0/index.js', ''); 37 | fs.write('/1/node_modules/meta-1/index.js', ''); 38 | fs.write('/1/2/node_modules/meta-2/index.js', ''); 39 | fs.write('/1/2/3/node_modules/meta-3/index.js', ''); 40 | fs.write('/1/2/3/index.js', ''); 41 | expect(findPlugins('/1/2/3')).toMatchSnapshot(); 42 | }); 43 | 44 | it('searches every global directory', () => { 45 | fs.write('/foo/meta-foo/index.js', ''); 46 | fs.write('/bar/@foo/meta-bar/index.js', ''); 47 | 48 | process.env.NODE_PATH = '/foo:/bar'; 49 | expect(findPlugins('/dev')).toMatchSnapshot(); 50 | }); 51 | 52 | it('falls back to (npm root -g) when $NODE_PATH is empty', () => { 53 | fs.write('/npm/root/foo/index.js', ''); 54 | fs.write('/npm/root/meta-1/index.js', ''); 55 | fs.write('/npm/root/@foo/meta-2/index.js', ''); 56 | 57 | delete process.env.NODE_PATH; 58 | const oldFn = cp.execSync; 59 | cp.execSync = jest.fn(() => '/npm/root'); 60 | 61 | expect(findPlugins('/')).toMatchSnapshot(); 62 | expect(cp.execSync.mock.calls).toEqual([['npm root -g']]); 63 | cp.execSync = oldFn; 64 | }); 65 | 66 | it('tolerates missing "node_modules" when searching parent directories', () => { 67 | fs.write('/1/node_modules/meta-1/index.js', ''); 68 | fs.write('/1/2/3/node_modules/meta-3/index.js', ''); 69 | fs.write('/1/2/3/4/index.js', ''); 70 | 71 | // Directories #2 and #4 have no "node_modules" 72 | expect(findPlugins('/1/2/3/4')).toMatchSnapshot(); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "meta", 3 | "version": "0.0.0-development", 4 | "description": "tool for turning many repos into a meta repo. why choose many repos or a monolithic repo, when you can have both with a meta repo?", 5 | "bin": { 6 | "meta": "./bin/meta" 7 | }, 8 | "scripts": { 9 | "clean-global-all": "npm run clean-global-loop-commands && npm run clean-global-loop-installs && npm run clean-global-meta-commands && npm run clean-global-meta-installs", 10 | "clean-global-loop-commands": "rm -f `npm config get prefix`/bin/*loop*", 11 | "clean-global-loop-installs": "rm -rf `npm config get prefix`/lib/node_modules/*loop*", 12 | "clean-global-meta-commands": "rm -f `npm config get prefix`/bin/*meta*", 13 | "clean-global-meta-installs": "rm -rf `npm config get prefix`/lib/node_modules/*meta*", 14 | "clean": "meta-npm clean", 15 | "commit": "git-cz", 16 | "completion": "tabtab install", 17 | "lint": "prettier --write \"bin/*\" index.js", 18 | "meta-install": "meta-npm install --exclude meta", 19 | "meta-link-all-global": "meta-npm link --all && npm link", 20 | "meta-link-all": "meta-npm link --all", 21 | "meta-link-global": "meta-npm link && npm link", 22 | "meta-link": "meta-npm link", 23 | "test": "jest --config jest.json --coverage", 24 | "test:coverage": "jest --config jest.json --coverage", 25 | "test:watch": "jest --config jest.json --watch" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "https://github.com/mateodelnorte/meta.git" 30 | }, 31 | "keywords": [ 32 | "git", 33 | "repo", 34 | "repository", 35 | "repositories", 36 | "meta", 37 | "metarepo", 38 | "metarepository", 39 | "project", 40 | "many" 41 | ], 42 | "author": "hi@iammattwalters.com", 43 | "contributors": [ 44 | "hi@iammattwalters.com", 45 | "pat@patscott.io", 46 | "Alec Larson" 47 | ], 48 | "license": "MIT", 49 | "bugs": { 50 | "url": "https://github.com/mateodelnorte/meta/issues" 51 | }, 52 | "homepage": "https://github.com/mateodelnorte/meta#readme", 53 | "dependencies": { 54 | "chalk": "3.0.0", 55 | "commander": "mateodelnorte/commander.js", 56 | "debug": "4.3.2", 57 | "meta-git": "1.1.7", 58 | "meta-init": "1.2.5", 59 | "meta-loop": "1.2.5", 60 | "meta-project": "2.5.0", 61 | "tabtab": "3.0.2", 62 | "tildify": "2.0.0" 63 | }, 64 | "devDependencies": { 65 | "@commitlint/cli": "12.1.4", 66 | "@commitlint/config-conventional": "12.1.4", 67 | "commitizen": "4.2.4", 68 | "cz-conventional-changelog": "3.3.0", 69 | "husky": "6.0.0", 70 | "jest": "26.6.3", 71 | "meta-gh": "1.1.5", 72 | "meta-npm": "1.2.7", 73 | "meta-yarn": "1.1.5", 74 | "prettier": "2.3.1", 75 | "pretty-quick": "3.1.0" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /__tests__/plugins/__snapshots__/index.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`plugins can be executed using exact executable name 1`] = ` 4 | "Usage: meta-project [options] [command] 5 | 6 | Options: 7 | -h, --help output usage information 8 | 9 | Commands: 10 | add (deprecated) add child repository 11 | create create and initialize a new child repository 12 | import import an existing child repository using git clone 13 | migrate migrate from a monorepo to a metarepo 14 | help [cmd] display help for [cmd] 15 | " 16 | `; 17 | 18 | exports[`plugins can be executed using git style subcommands 1`] = ` 19 | "Usage: meta-project [options] [command] 20 | 21 | Options: 22 | -h, --help output usage information 23 | 24 | Commands: 25 | add (deprecated) add child repository 26 | create create and initialize a new child repository 27 | import import an existing child repository using git clone 28 | migrate migrate from a monorepo to a metarepo 29 | help [cmd] display help for [cmd] 30 | " 31 | `; 32 | 33 | exports[`plugins can be executed using local plugin discovery 1`] = ` 34 | "Usage: meta-project [options] [command] 35 | 36 | Options: 37 | -h, --help output usage information 38 | 39 | Commands: 40 | add (deprecated) add child repository 41 | create create and initialize a new child repository 42 | import import an existing child repository using git clone 43 | migrate migrate from a monorepo to a metarepo 44 | help [cmd] display help for [cmd] 45 | " 46 | `; 47 | 48 | exports[`plugins should include meta exec as dependency 1`] = ` 49 | " 50 | usage: 51 | 52 | meta exec 53 | 54 | " 55 | `; 56 | 57 | exports[`plugins should include meta gh as dev dependency 1`] = ` 58 | "Usage: meta-gh [options] [command] 59 | 60 | Options: 61 | -h, --help output usage information 62 | 63 | Commands: 64 | issue|is Provides a set of util commands to work with Issues 65 | milestone|ms Provides a set of util commands to work with Milestones 66 | pull-request|pr Provides a set of util commands to work with Pull Requests 67 | repo|re Provides a set of util commands to work with Repositories 68 | help [cmd] display help for [cmd] 69 | " 70 | `; 71 | 72 | exports[`plugins should include meta git as dependency 1`] = ` 73 | "Usage: meta-git [options] [command] 74 | 75 | Options: 76 | -h, --help output usage information 77 | 78 | Commands: 79 | add Add file contents to the index 80 | branch List, create, or delete branches 81 | checkout Switch branches or restore working tree files 82 | clean Remove untracked files from the working tree 83 | clone Clone meta and child repositories into new directories 84 | commit Record changes to the repository 85 | diff Show changes between commits, commit and working tree, etc 86 | fetch Download objects and refs from another repository 87 | merge Join two or more development histories together 88 | pull Fetch from and integrate with another repository or a local branch 89 | push Update remote refs along with associated objects 90 | remote Manage set of tracked repositories 91 | status Show the working tree status 92 | tag Create, list, delete or verify a tag object signed with GPG 93 | update Clone any repos that exist in your .meta file but aren't cloned locally 94 | help [cmd] display help for [cmd] 95 | " 96 | `; 97 | 98 | exports[`plugins should include meta init as dependency 1`] = `""`; 99 | 100 | exports[`plugins should include meta npm as dev dependency 1`] = ` 101 | "Usage: meta-npm [options] [command] 102 | 103 | Options: 104 | -h, --help output usage information 105 | 106 | Commands: 107 | clean delete the node_modules folder in meta and child repositories 108 | install npm install meta and child repositories 109 | update npm update meta and child repositories 110 | link [--all] npm link child repositories where used within child and meta repositories 111 | outdated check outdated dependencies in meta and child repositories 112 | publish npm publish meta and child repositories 113 | run npm run commands against meta and child repositories 114 | symlink directly symlink meta and child repositories without using global npm link 115 | help [cmd] display help for [cmd] 116 | " 117 | `; 118 | 119 | exports[`plugins should include meta project as dependency 1`] = ` 120 | "Usage: meta-project [options] [command] 121 | 122 | Options: 123 | -h, --help output usage information 124 | 125 | Commands: 126 | add (deprecated) add child repository 127 | create create and initialize a new child repository 128 | import import an existing child repository using git clone 129 | migrate migrate from a monorepo to a metarepo 130 | help [cmd] display help for [cmd] 131 | " 132 | `; 133 | 134 | exports[`plugins should include meta yarn as dev dependency 1`] = ` 135 | "Usage: meta-yarn [options] [command] 136 | 137 | Options: 138 | -h, --help output usage information 139 | 140 | Commands: 141 | clean delete the node_modules folder in meta and child repositories 142 | install yarn install meta and child repositories 143 | link [--all] yarn link child repositories where used within child and meta repositories 144 | help [cmd] display help for [cmd] 145 | " 146 | `; 147 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.com/mateodelnorte/meta.svg?branch=master)](https://travis-ci.com/mateodelnorte/meta) 2 | [![npm version](https://badge.fury.io/js/meta.svg)](https://badge.fury.io/js/meta) 3 | Latest Release Date 4 | 5 | Dependency Status 6 | Dev Dependency Status 7 | 8 | NPM downloads 9 | Contributors 10 | Gitter 11 | 12 | # meta 13 | 14 | meta is a tool for managing multi-project systems and libraries. It answers the conundrum of choosing between a mono repo or many repos by saying "both", with a meta repo! 15 | 16 | meta is powered by plugins that wrap common commands, letting you execute them against some or all of the repos in your solution at once. meta is built on [loop](https://github.com/mateodelnorte/loop), and as such inherits loops ability to easily target a particular set of directories for executing a common command (eg `meta git status --include-only dir1,dir2`. See [loop](https://github.com/mateodelnorte/loop) for more available options). 17 | 18 | meta is packaged with a few of these core plugins by default: https://github.com/mateodelnorte/meta/blob/master/package.json#L63-L66 19 | 20 | ## Why meta? 21 | 22 | - clone a many-project architecture in one line 23 | - give every engineer on your team the same project setup, regardless of where it's cloned 24 | - npm / yarn install against all your projects at once 25 | - execute arbitrary commands against many repos to manage your projects 26 | - super simple plugin architecture using commander.js 27 | - easily wrap commands for working with any platform, not just Node! 28 | - meta repo keeps code in per project repos, benefiting deployment and reuse 29 | - use the same tools you always use. no strange side effects of git submodules or subtree 30 | - give different teams different slices of your architecture, with multiple metarepos! 31 | - use `meta project migrate` to migrate mono-repos to a meta repo consisting of many repos 32 | 33 | # getting started 34 | 35 | ## installing 36 | 37 | `npm i -g meta` will install a `meta` command on your system. 38 | 39 | ## initializing a new meta project 40 | 41 | To create a new meta project: 42 | 43 | 1. create a new directory for your meta project `mkdir my-meta-repo` 44 | 2. initialize a new git repository in your new dir: `cd my-meta-repo && git init` 45 | 3. initialize your new repository as a meta repo: `meta init` 46 | 47 | meta will have created a .meta file to hold references to any child repositories you add. 48 | 49 | 4. (a) to create a new project, use `meta project create [folder] [repo url]` 50 | (b) to import an existing project, use `meta project import [folder] [repo url]` 51 | 52 | for each project added, meta will update your .gitignore file and the .meta file with references to the new child repo 53 | 54 | [![asciicast](https://asciinema.org/a/d3nnfgv3n0vj2omzsl33l8um6.png)](https://asciinema.org/a/d3nnfgv3n0vj2omzsl33l8um6) 55 | 56 | You can now perform commands against all of the repositories that make up your meta repository by using `meta exec`. 57 | 58 | For example, to list all of the files in each project: 59 | 60 | ``` 61 | meta exec "ls -la" 62 | ``` 63 | 64 | ## cloning an existing meta project 65 | 66 | To clone an existing meta repo, rather than `git clone` like you are used to, simply execute `meta git clone [meta repo url]` instead. `meta` will clone your meta repo and all child repositories at once. 67 | 68 | ``` 69 | meta git clone git@github.com:mateodelnorte/meta.git 70 | ``` 71 | 72 | [![asciicast](https://asciinema.org/a/2rkev7pu41cv51a0bajwnxu7s.png)](https://asciinema.org/a/2rkev7pu41cv51a0bajwnxu7s) 73 | 74 | ## Getting meta project updates 75 | 76 | If you are working on a team and another members adds a project to the meta repository, to get the project, run `meta git update`. 77 | 78 | ```sh 79 | # get new .meta file 80 | git pull origin master 81 | 82 | # clone missing projects 83 | meta git update 84 | ``` 85 | 86 | # working with meta 87 | 88 | ## meta exec 89 | 90 | The most basic way to interact with meta repositories is to use the `meta exec` command. This will let you run any command against the projects that make up your meta repo. 91 | 92 | ``` 93 | meta exec "git checkout master" 94 | ``` 95 | 96 | In many cases, that is enough. There are also special cases where the functionality provided by the initial tool wasn't quite meta-y enough, and for those, there are plugins. 97 | 98 | Even meta-exec, itself is a plugin, but it comes with meta by default. 99 | 100 | ## plugins 101 | 102 | All meta functionality is contributed by plugins - node modules that begin with `meta-` and are either installed globally or in your meta repo's node_modules directory. We recommend you install them as devDependencies in your meta repo's package.json. Plugins add additional sub commands to meta, and can leverage [loop](https://github.com/mateodelnorte/loop) or [meta-loop](https://github.com/mateodelnorte/meta-loop) to easily execute a common command against your meta repo and all child repos. 103 | 104 | Here's how easy it is to install `meta-npm` as a plugin, and gain the ability to `meta npm install` all your repos at once: 105 | 106 | [![asciicast](https://asciinema.org/a/8iqph5ju6j00drxpknbj6lnm6.png)](https://asciinema.org/a/8iqph5ju6j00drxpknbj6lnm6) 107 | 108 | Going deeper - meta plugins are able to wrap common commands for a friendly user experience, such as `meta npm install`. They are also able to extend the native tool's capabilities. For example, `git update` is not a git command, but `meta git update` will clone any repos that exist in your .meta file that aren't cloned locally - a problem that doesn't exist with a single git repo. 109 | 110 | You shouldn't have much new syntax to memorize for some crazy new utilities nobody knows about. For instance, if you want to check the `git status` of all your repositories at once, you can just type `meta git status`: 111 | 112 | [![asciicast](https://asciinema.org/a/83lg1tvqz9gwynixq5nhwsm2k.png)](https://asciinema.org/a/83lg1tvqz9gwynixq5nhwsm2k) 113 | 114 | In the case a command has not been wrapped with a plugin, just use `meta exec` instead. 115 | 116 | ### Available Plugins 117 | 118 | - [meta-init](https://github.com/mateodelnorte/meta-init) 119 | - [meta-project](https://github.com/mateodelnorte/meta-project) 120 | - [meta-git](https://github.com/mateodelnorte/meta-git) 121 | - [meta-exec](https://github.com/mateodelnorte/meta-exec) 122 | - [meta-gh](https://github.com/mateodelnorte/meta-gh) 123 | - [meta-loop](https://github.com/mateodelnorte/meta-loop) 124 | - [meta-npm](https://github.com/mateodelnorte/meta-npm) 125 | - [meta-yarn](https://github.com/mateodelnorte/meta-yarn) 126 | - [meta-template](https://github.com/patrickleet/meta-template) 127 | 128 | ### Third-party Plugins 129 | 130 | - [meta-bump](https://github.com/patrykzurawik/meta-bump) 131 | - [meta-release](https://github.com/alqh/meta-release) 132 | - [meta-search](https://www.npmjs.com/package/meta-search) 133 | 134 | ### Available Templates 135 | 136 | - [meta-plugin](https://github.com/patrickleet/meta-template-meta-plugin) 137 | 138 | # Usage Scenarios 139 | 140 | ## Product Development Team 141 | 142 | Your product consists of multiple applications and services. As the project lead, you can use `meta` to group together the projects so every developer is able to `meta git clone` a single project to get everything they need for development. 143 | 144 | Furthermore, you could add a `docker-compose` file at this root level to run all of the services and applications: 145 | 146 | ``` 147 | version: '3.7' 148 | 149 | services: 150 | 151 | app1: 152 | image: app1 153 | build: 154 | context: projects/app1 155 | ports: 156 | - 1234:1234 157 | env_file: projects/app1/.env 158 | 159 | app2: 160 | image: app2 161 | build: 162 | context: projects/app2 163 | ports: 164 | - 1234:1234 165 | env_file: projects/app2/.env 166 | 167 | service1: 168 | image: service1 169 | build: 170 | context: projects/service1 171 | ports: 172 | - 1236:1234 173 | env_file: projects/service1/.env 174 | 175 | service1: 176 | image: service1 177 | build: 178 | context: src/service2 179 | ports: 180 | - 1237:1234 181 | env_file: src/service2/.env 182 | ``` 183 | 184 | The meta repo is a good place for things like this, including scripts and a `Makefile` that are responsible for meta things, like gettings secrets for each project, like `.env` files for local development. 185 | 186 | Take this example `Makefile` at the root of a meta repo: 187 | 188 | ```Makefile 189 | onboard: 190 | meta exec "make setup" 191 | 192 | setup: install-tools get-secrets 193 | 194 | install-tools: 195 | echo "add install scripts here" 196 | 197 | get-secrets: 198 | echo "get secrets via SOPS/Vault/however and cp into appropriate projects" 199 | ``` 200 | 201 | The command `make onboard` would start the setup task in the root and all of the child directories. 202 | 203 | Each project can then contain a `Makefile` like so: 204 | 205 | ``` 206 | setup: 207 | npm ci 208 | npm run dev 209 | ``` 210 | 211 | To get new projects up and running you can give them the instructions: 212 | 213 | ``` 214 | meta git clone git@github.com/yourorg/metaproject 215 | cd metaproject 216 | make onboard 217 | ``` 218 | 219 | And they would have a fully running dev environment. 220 | 221 | ## Developing a Library with many modules 222 | 223 | Meta itself is developed with meta. This way you have a monorepo like feel while developing, but with individual components with their own release cycles. 224 | 225 | It takes advantage of `npm link`, just like tools like Lerna do. 226 | 227 | Using `meta npm link && meta npm link --all` enables a good development experience by creating symlinks so each project uses the development version of any other project in the meta repo: 228 | 229 | ```sh 230 | # install meta 231 | npm i -g meta 232 | 233 | # clone and enter the meta repo 234 | meta git clone git@github.com:mateodelnorte/meta.git 235 | cd ./meta 236 | 237 | # install plugins 238 | npm install 239 | 240 | # run install for all child repos 241 | meta npm install 242 | 243 | # create symlinks to/from all child repos 244 | meta npm link --all 245 | 246 | # link meta itself globally 247 | npm link 248 | ``` 249 | 250 | There is admittedly now the problem of updating each repository to use the newly published versions of each other. For this, we recommend using a tool like Renovate, Dependabot, or Greenkeeper. 251 | 252 | See this article for an example: [Bring In The Bots, And Let Them Maintain Our Code!](https://hackernoon.com/bring-in-the-bots-and-let-them-maintain-our-code-gh3s33n9) 253 | 254 | ## Migrating a Monorepo to many repos 255 | 256 | 'meta project migrate' helps you move from a monorepo to a meta repo by moving directories from 257 | your existing repo into separate child repos, with git history intact. These are then referenced in 258 | your '.meta' file and cloned, making the operation transparent to your codebase. 259 | 260 | For example, given the following monorepo structure: 261 | 262 | ``` 263 | - monorepo-base 264 | - project-a 265 | - project-b 266 | - project-c 267 | ``` 268 | 269 | Create git repos for `project-a`, `project-b`, and `project-c`, then run: 270 | 271 | ``` 272 | cd monorepo-base 273 | meta init 274 | meta project migrate project-a git@github.com/yourorg/project-a 275 | meta project migrate project-b git@github.com/yourorg/project-b 276 | meta project migrate project-c git@github.com/yourorg/project-c 277 | ``` 278 | 279 | This will keep the git history of each subproject in tact, using some git magic: 280 | 281 | - Explanation: https://help.github.com/en/articles/splitting-a-subfolder-out-into-a-new-repository 282 | - Implementation: https://github.com/mateodelnorte/meta-project/blob/master/lib/splitSubtree.js 283 | 284 | ### How it works 285 | 286 | 1. Migrate will first create a copy of your project in a temporary directory and replace the remote 287 | 'origin' with the provided 288 | 1. It will split the history from and push to the provided : 289 | https://help.github.com/en/articles/splitting-a-subfolder-out-into-a-new-repository 290 | 1. Next is removed from your monorepo, and then cloned back into the same location. 291 | 292 | In the eyes of the monorepo, the only thing that has changed is the .meta file, however, now also has it's own distinct history. 293 | 294 | ### Migration Phase 295 | 296 | If you need the monorepos structure to stay in tact for any extended duration, such as supporting legacy CI systems, you can stop here. 297 | 298 | While in this 'migration' phase, you need to commit to the child directory's git history as well as the monorepo's git history. These commits can literally be made twice by cd-ing around or both can be made at once using 'meta git commit'. 299 | 300 | ### Finishing the Migration 301 | 302 | When the monorepo no longer needs to be maintained you can simply add the migrated project to your '.gitignore'. 303 | 304 | This will cause changes to only be tracked in the child repo, rather than both, such as during the migration phase. 305 | 306 | # FAQs 307 | 308 | ## How can I create a group of repositories, to, for example, run npm install on only node projects? 309 | 310 | There are two ways to do this: 311 | 312 | 1. Meta repos can contain other meta repos. Make smaller groups of repos that only contain projects with commands that will be executed together. This is commonly not an option such as in migrating legacy monorepos. 313 | 1. Use a `Makefile` to declare the groups, and use `make` commands: 314 | 315 | ``` 316 | NODE_APPS=app1,service1,app2,service2 317 | 318 | node-install: 319 | meta npm install --include-only $(NODE_APPS) 320 | ``` 321 | 322 | Then you can run `make node-install` 323 | 324 | ## Can I run things in parallel? 325 | 326 | Yes. 327 | 328 | ``` 329 | meta exec "npm ci" --parallel 330 | ``` 331 | 332 | Output is even grouped nicely together for you at the end! :) 333 | 334 | ## How to escape expressions 335 | 336 | If you try to evaluate an expression run in meta exec, you'll notice that the expression is evaluated before being run in the target projects. 337 | 338 | ```sh 339 | ➜ meta exec "echo `pwd`" --include-only=plugins/meta-loop 340 | 341 | plugins/meta-loop: 342 | /Users/patrickleet/dev/mateodelnorte/meta 343 | plugins/meta-loop ✓ 344 | ``` 345 | 346 | In these cases, simply escape the expression so that it is not executed until being run against each project rather than ahead of time: 347 | 348 | ```sh 349 | ➜ meta exec "echo \`pwd\`" --include-only=plugins/meta-loop 350 | 351 | plugins/meta-loop: 352 | /Users/patrickleet/dev/mateodelnorte/meta/plugins/meta-loop 353 | plugins/meta-loop ✓ 354 | ``` 355 | 356 | Or... 357 | 358 | ```sh 359 | ➜ meta exec "echo \$(pwd)" --include-only=plugins/meta-loop 360 | 361 | plugins/meta-loop: 362 | /Users/patrickleet/dev/mateodelnorte/meta/plugins/meta-loop 363 | plugins/meta-loop ✓ 364 | ``` 365 | 366 | # Developing meta locally 367 | 368 | The best way to get started is to do the following: 369 | 370 | ``` 371 | npm i -g meta 372 | meta git clone git@github.com:mateodelnorte/meta.git 373 | cd ./meta 374 | npm install 375 | meta npm install 376 | meta npm link --all 377 | npm link 378 | ``` 379 | 380 | This will clone the meta project, `meta`, enter the directory, and then use `meta` to perform `npm install`, `npm link --all` in each directory listed in `projects` of the `.meta` JSON configuration file, and link meta itself to be used as a global command. 381 | 382 | You can then write your command and test using `./bin/meta git gh [subcommand]`. 383 | 384 | You can run the above as a single command: 385 | 386 | ``` 387 | meta git clone git@github.com:mateodelnorte/meta.git && cd ./meta && npm i && meta npm install && meta npm link --all && npm link 388 | ``` 389 | 390 | Yarn lovers can do the same: 391 | 392 | ``` 393 | npm i -g meta 394 | meta git clone git@github.com:mateodelnorte/meta.git 395 | cd ./meta 396 | yarn 397 | meta yarn install 398 | meta yarn link --all 399 | yarn link 400 | ``` 401 | 402 | Or 403 | 404 | ``` 405 | meta git clone git@github.com:mateodelnorte/meta.git && cd ./meta && yarn && meta yarn install && meta yarn link --all && yarn link 406 | ``` 407 | 408 | See discussion [here](https://github.com/mateodelnorte/meta/issues/8) for more details 409 | 410 | ## More resources 411 | 412 | - [Mono-repo or multi-repo? Why choose one, when you can have both? by @patrickleet](https://medium.com/@patrickleet/mono-repo-or-multi-repo-why-choose-one-when-you-can-have-both-e9c77bd0c668) 413 | - [Developing a plugin for meta by @patrickleet](https://medium.com/@patrickleet/developing-a-plugin-for-meta-bd2e9c39882d) 414 | --------------------------------------------------------------------------------