├── .bookignore ├── .circleci ├── config.yml └── update-github-page.sh ├── .commitlintrc.yml ├── .editorconfig ├── .eslintrc.yml ├── .flowconfig ├── .gitattributes ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg └── pre-push ├── .ncmrc.yml ├── .npmignore ├── .nvmrc ├── .scrutinizer.yml ├── LICENSE ├── Makefile ├── README.md ├── babel.config.js ├── jest.config.js ├── package.json ├── renovate.json ├── src ├── actions │ ├── common-steps.js │ ├── config │ │ ├── index.js │ │ └── steps.js │ ├── init │ │ ├── index.js │ │ └── steps.js │ ├── secret │ │ └── index.js │ └── setup │ │ ├── index.js │ │ └── steps.js ├── constants.js ├── index.js └── lib │ ├── __tests__ │ ├── __snapshots__ │ │ └── format.js.snap │ └── format.js │ ├── child-process.js │ ├── format.js │ ├── fs.js │ ├── github.js │ ├── package-json.js │ ├── util.js │ └── vault.js ├── tsconfig.json └── yarn.lock /.bookignore: -------------------------------------------------------------------------------- 1 | # gitbook would first read .gitignore 2 | 3 | # ignore everything 4 | .* 5 | * 6 | 7 | # only include 8 | !.circleci/ 9 | !docs/ 10 | !book.json 11 | !README.md 12 | !SUMMARY.md 13 | !API.md 14 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | 4 | references: 5 | container-config-node8: &container-config-node8 6 | docker: 7 | - image: circleci/node:14 8 | restore-deps-cache: &restore-deps-cache 9 | restore_cache: 10 | keys: 11 | - yarn-packages-{{ checksum "yarn.lock" }} 12 | save-deps-cache: &save-deps-cache 13 | save_cache: 14 | key: yarn-packages-{{ checksum "yarn.lock" }} 15 | paths: 16 | - ~/.cache/yarn 17 | - node_modules 18 | jobs: 19 | install: 20 | <<: *container-config-node8 21 | steps: 22 | - checkout 23 | - *restore-deps-cache 24 | - run: 25 | name: Install Dependencies 26 | command: make install 27 | - *save-deps-cache 28 | test: 29 | <<: *container-config-node8 30 | steps: 31 | - checkout 32 | - *restore-deps-cache 33 | - run: 34 | name: Install Dependencies and Run Test Coverage 35 | command: make install test-coverage 36 | - *save-deps-cache 37 | - run: npx coveralls < coverage/lcov.info 38 | release: 39 | <<: *container-config-node8 40 | steps: 41 | - checkout 42 | - *restore-deps-cache 43 | - run: 44 | name: Build Package and Semantic-Release 45 | command: make install build release 46 | - run: 47 | name: Update Doc GitHub Page 48 | command: sh .circleci/update-github-page.sh 49 | 50 | workflows: 51 | version: 2 52 | test-and-release: 53 | jobs: 54 | - test: 55 | filters: 56 | branches: 57 | ignore: gh-pages 58 | - release: 59 | requires: 60 | - test 61 | filters: 62 | branches: 63 | only: master 64 | -------------------------------------------------------------------------------- /.circleci/update-github-page.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | git config --global user.name 'CircleCI'; 6 | git config --global user.email 'circleci@users.noreply.github.com'; 7 | npx documentation build src/** -f html -o docs 8 | npx gh-pages -d docs -m 'Automated Github Page Update [skip ci]'; 9 | -------------------------------------------------------------------------------- /.commitlintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: ['@commitlint/config-conventional'] 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [Makefile] 15 | indent_style = tab 16 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | parser: babel-eslint 3 | parserOptions: 4 | ecmaVersion: 6 5 | sourceType: module 6 | env: 7 | browser: true 8 | es6: true 9 | node: true 10 | jest/globals: true 11 | extends: 12 | - airbnb-base 13 | - plugin:jest/recommended 14 | - plugin:jsdoc/recommended 15 | - plugin:prettier/recommended 16 | plugins: 17 | - import 18 | - jest 19 | - jsdoc 20 | - prettier 21 | rules: 22 | valid-jsdoc: 'off' 23 | jsdoc/check-types: 'off' 24 | jsdoc/require-param: 'off' 25 | jsdoc/require-returns: 'off' 26 | import/extensions: [warn, {js: never}] 27 | import/no-extraneous-dependencies: 'off' 28 | import/no-named-as-default: 'off' 29 | jest/no-disabled-tests: warn 30 | jest/no-focused-tests: error 31 | jest/no-identical-title: error 32 | jest/no-try-expect: 'off' 33 | jest/valid-expect: error 34 | jest/no-conditional-expect: 'off' 35 | prettier/prettier: 36 | - error 37 | - { 38 | singleQuote: true, 39 | trailingComma: all 40 | } 41 | settings: 42 | import/resolver: 43 | node: 44 | moduleDirectory: 45 | - ./src 46 | - ./node_modules 47 | jsdoc: 48 | mode: typescript 49 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/node_modules/*. 3 | 4 | [include] 5 | src/ 6 | 7 | [libs] 8 | ./flow-typed 9 | 10 | [lints] 11 | 12 | [options] 13 | 14 | [strict] 15 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | yarn.lock binary 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules/ 3 | 4 | # test output 5 | coverage/ 6 | reports/ 7 | 8 | # dist 9 | dist/ 10 | types/ 11 | 12 | # docs output 13 | docs/ 14 | 15 | # build cache 16 | .cache 17 | .serverless 18 | .webpack 19 | .build 20 | *_book 21 | 22 | # dot files 23 | .DS_Store 24 | *.env* 25 | package-lock.json 26 | 27 | # log files 28 | *.log 29 | 30 | # data file 31 | output/ 32 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | make lint type-check test-coverage -------------------------------------------------------------------------------- /.ncmrc.yml: -------------------------------------------------------------------------------- 1 | ## created by @opbi/ncm 2 | --- 3 | component: 4 | type: package 5 | name: ncm 6 | description: node config manager - create and update dotfiles made easy 7 | keywords: automation, dotfiles, dotenv, node, cli 8 | private: false 9 | package: 10 | environment: cli 11 | npmScope: opbi 12 | owner: 13 | type: organisation 14 | github: opbi 15 | team: product 16 | name: OPBI 17 | email: engineering@opbi.org 18 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # ignore everything 2 | * 3 | 4 | # exclusion list 5 | !package.json 6 | !dist/**/* 7 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v14.17.6 2 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | --- 2 | build: 3 | nodes: 4 | analysis: 5 | # disable inferred commands [dependencies] 6 | dependencies: 7 | override: 8 | - true 9 | # as we're only use the code quality analyser 10 | tests: 11 | override: 12 | - js-scrutinizer-run 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 OPBI 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ## CONFIG 2 | export PATH := ./node_modules/.bin:$(PATH) 3 | SHELL := /bin/bash -v 4 | 5 | ## VARIABLES 6 | 7 | ## COMMANDS 8 | install: 9 | @yarn 10 | @husky install 11 | 12 | upgrade: 13 | @yarn upgrade-interactive --latest 14 | 15 | nvm-update: 16 | @bash -l -c 'nvm install --latest-npm --reinstall-packages-from=$(shell node -v)' 17 | 18 | cleanup: 19 | @rm -rf node_modules coverage dist types docs *.log 20 | 21 | build: 22 | @rm -rf dist 23 | @babel src -d dist --ignore '**/__tests__/*.js' 24 | 25 | build-watch: 26 | @babel src -d dist --ignore '**/__tests__/*.js' --watch 27 | 28 | type-check: 29 | @tsc 30 | 31 | lint: 32 | @eslint_d src 33 | 34 | lint-fix: 35 | @eslint_d src --fix 36 | 37 | lint-reset: 38 | @eslint_d restart 39 | 40 | test: 41 | @jest 42 | 43 | test-watch: 44 | @jest --watch --coverage 45 | 46 | test-coverage: 47 | @jest --coverage 48 | 49 | docs: 50 | @documentation build src/** -f html -o docs 51 | 52 | docs-watch: 53 | @documentation serve --watch src 54 | 55 | commit: 56 | @commit 57 | 58 | release: 59 | @semantic-release 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | ncm 3 |

4 | 5 |

ncm

6 |

node config manager - cross-project config consistency made easy

7 | 8 |

9 | 10 | npm 11 | 12 | 13 | Travis 14 | 15 | 16 | Coveralls 17 | 18 | 19 | inch-ci 20 | 21 | 22 | semantic-release 23 | 24 |

25 | 26 |

27 | 28 | Known Vulnerabilities 29 | 30 | 31 | License Scan 32 | 33 | 34 | Dependencies 35 | 36 | 37 | devDependencies 38 | 39 | 40 | Scrutinizer Code Quality 41 | 42 |

43 | 44 | --- 45 | 46 | ### Motivation 47 | 48 | #### Make Micro-Services Consistent 49 | 50 | With the growing amount of toolings and ci services we need to use to create a production-grade codebase, the difficulty of managing configs across multiple repos is increasing. Tools like [lerna](https://github.com/lerna/lerna) is easy to result in a heavy monorepo and doesn't help to manage apps in different teams, plus a lot of Github integrations are optmised on a repo level. `ncm` is created to manage packages (components or apps) in separate repos, keeping the repos light-weight for swift development experience, enjoying the benefits of micro-services while not sacarificing build layer consistency. 51 | 52 | #### Best-Practices In Code 53 | 54 | `ncm` is built to look after the whole lifecycle of a package (component or app) systematically from creation to development, maintainance and deprecation. We want to make it very easy to create and maintain a production-grade packages. It is built and released following [@opbi/principles](https://github.com/opbi/opbi#principles). 55 | 56 | 57 | ### How to Use 58 | 59 | #### Install 60 | ```shell 61 | yarn add @opbi/ncm -D 62 | ``` 63 | 64 | #### Before We Start 65 | It assumes that you've `git` installed and setup with SSH keys to clone public and private repo in your scope. 66 | 67 | Also to setup Github repo it requires you to provide a Github Access Token. You can create it on Github Settings page, and it requires repo:private access if you want to create private repo. When you have got the access token, you can store it in MacOS Keychain, and export it to `NCM_GITHUB_ACCESS_TOKEN` in your shell profile. 68 | 69 | #### Setup New Component 70 | 71 | ```shell 72 | ncm init # you will be asked a list of questions like `npm init` or `yarn init` 73 | # check .ncmrc.yml, add more details if necessary 74 | ncm setup # it will create dotfiles based on preset and custom config as well as create GitHub repo 75 | make install 76 | # all set, start development 🎉 77 | ``` 78 | Currently it needs manual clicks to setup project in CI and code quality reports, this can be automated by running a worker triggered by GitHub hook. 79 | 80 | #### Use Custom Template Repo 81 | By default, `ncm` uses ncm-preset-[component.type] as the config file template, which is a repo that is constantly being updated and tested to maintain fresh high-quality build config standards. In case you prefer use your own preset, you just need to add your Github repo to `.ncmrc.yml` under `component.template`. 82 | 83 | ```yml 84 | --- 85 | component: 86 | type: package # [package, service, app, job] 87 | template: opbi/ncm-preset-package # default to this repo for [package] 88 | ``` 89 | 90 | #### Update Config Files 91 | ```shell 92 | ncm config # it will replace the dotfiles with latest ones from the template 93 | ``` 94 | 95 | #### Fetch Dotenv Secrets 96 | ```shell 97 | ncm .env -s # it will write secrets in vault to .env file 98 | ``` 99 | 100 | #### Upgrade Node Version [TODO] 101 | ```shell 102 | ncm upgrade node # this will upgrade node version via nvm, update .ncmrc.yml, .nvmrc, babel.config.js, package.json, .circleci/config.yml 103 | ``` 104 | 105 | #### Develop A Component [TODO] 106 | As `ncm` is created to manage the whole lifecycle of a component in microservice system, workflow automation is included in the scope. Workflow logics over lifecycles of different types of components defined in [@opbi/practices](https://github.com/opbi/opbi#practices) would be materialised as automation in `ncm`. For example, when the following command in a package component, it would move the Trello card to development stage, create a new branch with preset naming convention, open a PR, link the PR to the Trello Card. 107 | 108 | ```shell 109 | ncm start #TrelloCard 110 | ``` 111 | 112 | #### Repo Specific Config [TODO] 113 | Add repo specific configuration to `.ncmrc.yml` to overwrite certain rules in the common dotfiles from the template. `ncm` will pick up the settings and inject them into the config files based on dotfiles from template. 114 | 115 | For example, if you want to use a specific babel-plugin that is not included in the template, then you can add the devDependency, and set `.ncmrc.yml` like the following, `ncm` will then pick up the `config.babel` and merge it into `babel.config.js`, the structure of `config.babel` would be exactly the same as `.babelrc`, except that it more yamlful. 116 | 117 | ```yml 118 | --- 119 | component: 120 | type: package 121 | config: 122 | babel: 123 | plugins: 124 | - @babel/plugin-of-your-choice 125 | gitbook: 126 | disabled: true 127 | ``` 128 | 129 | #### Rename A Component [TODO] 130 | ``` 131 | ncm rename # this would rename the dir, GitHub Repo, package.json, deployment service, etc. 132 | ``` 133 | 134 | #### Archive A Component [TODO] 135 | ```shell 136 | ncm archive # this will archive the repo, teardown all services, deprecate component/service/app 137 | ``` 138 | 139 | ### Glossary 140 | 141 | #### Component 142 | 143 | We use `component` to refer to a complete codebase unit that serves a specific function in a micro-service system, which could be in the type of [app, service, job, package, etc.]. For more details, please refer to [@opbi/glossary](https://github.com/opbi/opbi#glossary). 144 | 145 | 146 | ### Inspiration 147 | * [helm](https://github.com/helm/helm) 148 | * [mrm](https://github.com/sapegin/mrm) 149 | * [kyt](https://github.com/NYTimes/kyt/) 150 | * [yarn](https://github.com/yarnpkg/yarn) 151 | 152 | ### License 153 | [MIT](License) 154 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | node: '14.17', 8 | }, 9 | }, 10 | ], 11 | '@babel/typescript', 12 | ], 13 | plugins: [ 14 | '@babel/plugin-proposal-object-rest-spread', 15 | ['module-resolver', { root: ['./src'] }], 16 | ], 17 | }; 18 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | modulePaths: ['node_modules', './src'], 3 | testPathIgnorePatterns: [ 4 | 'node_modules', 5 | 'dist', 6 | 'types', 7 | '/__fixtures__/', 8 | '__tests__/helpers', 9 | ], 10 | testEnvironment: 'node', 11 | }; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@opbi/ncm", 3 | "description": "node config manager - create and update dotfiles made easy", 4 | "repository": "git@github.com:opbi/ncm.git", 5 | "author": "OPBI ", 6 | "license": "MIT", 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "dependencies": { 11 | "@octokit/rest": "^18.11.1", 12 | "commander": "^8.2.0", 13 | "cosmiconfig": "^7.0.1", 14 | "cpy": "^8.1.2", 15 | "inquirer": "^8.1.5", 16 | "jsonfile": "^6.1.0", 17 | "node-vault": "^0.9.22", 18 | "replace-in-file": "^6.2.0", 19 | "write-yaml": "^1.0.0" 20 | }, 21 | "devDependencies": { 22 | "@babel/cli": "^7.15.7", 23 | "@babel/core": "^7.15.5", 24 | "@babel/node": "^7.10.1", 25 | "@babel/plugin-proposal-object-rest-spread": "^7.15.6", 26 | "@babel/preset-env": "^7.15.6", 27 | "@babel/preset-typescript": "^7.15.0", 28 | "@commitlint/cli": "^13.1.0", 29 | "@commitlint/config-conventional": "^13.1.0", 30 | "@commitlint/prompt-cli": "^13.1.0", 31 | "babel-core": "^7.0.0-bridge.0", 32 | "babel-eslint": "^10.1.0", 33 | "babel-jest": "^27.2.1", 34 | "babel-plugin-module-resolver": "^4.1.0", 35 | "coveralls": "^3.1.1", 36 | "documentation": "^13.2.5", 37 | "eslint": "^7.32.0", 38 | "eslint-config-airbnb-base": "^14.2.1", 39 | "eslint-config-prettier": "^8.3.0", 40 | "eslint-config-standard-jsdoc": "^9.3.0", 41 | "eslint-import-resolver-node": "^0.3.6", 42 | "eslint-plugin-import": "^2.24.2", 43 | "eslint-plugin-jest": "^24.4.2", 44 | "eslint-plugin-jsdoc": "^36.1.0", 45 | "eslint-plugin-prettier": "^4.0.0", 46 | "eslint_d": "^11.0.0", 47 | "gh-pages": "^3.2.3", 48 | "husky": "^7.0.0", 49 | "jest": "^27.2.1", 50 | "prettier": "^2.4.1", 51 | "semantic-release": "^18.0.0", 52 | "typescript": "^4.4.3" 53 | }, 54 | "optionalDependencies": {}, 55 | "engines": { 56 | "node": ">=12" 57 | }, 58 | "bin": { 59 | "ncm": "dist/index.js" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | ":preserveSemverRanges", 5 | "docker:disable", 6 | "schedule:earlyMondays" 7 | ], 8 | "node": false 9 | } 10 | -------------------------------------------------------------------------------- /src/actions/common-steps.js: -------------------------------------------------------------------------------- 1 | import { cosmiconfig } from 'cosmiconfig'; 2 | import { exec } from 'lib/child-process'; 3 | 4 | // TODO: setup schema and sanitise config file 5 | export const readConfig = async () => { 6 | const MODULE_NAME = 'ncm'; 7 | const { config } = await cosmiconfig(MODULE_NAME).search(); 8 | return config; 9 | }; 10 | 11 | export const cloneTemplateRepo = async (config) => { 12 | const DEFAULT_TEMPLATE = `opbi/ncm-preset-${config.component.type}`; 13 | const template = config.component.template || DEFAULT_TEMPLATE; 14 | await exec(`echo template: ${template}`); 15 | await exec(`rm -rf .template`); 16 | await exec(`git clone git@github.com:${template}.git .template`); 17 | }; 18 | 19 | export const removeTemplateDir = async () => exec('rm -rf .template'); 20 | -------------------------------------------------------------------------------- /src/actions/config/index.js: -------------------------------------------------------------------------------- 1 | import * as commonSteps from '../common-steps'; 2 | import * as steps from './steps'; 3 | 4 | export default async () => { 5 | const config = await commonSteps.readConfig(); 6 | 7 | await commonSteps.cloneTemplateRepo(config); 8 | 9 | await steps.copyConfigFiles(); 10 | 11 | await steps.updatePackageJson(); 12 | 13 | await commonSteps.removeTemplateDir(); 14 | }; 15 | -------------------------------------------------------------------------------- /src/actions/config/steps.js: -------------------------------------------------------------------------------- 1 | import cpy from 'cpy'; 2 | import jsonfile from 'jsonfile'; 3 | import { exec } from 'lib/child-process'; 4 | import { sortObjectByKeys } from 'lib/util'; 5 | 6 | /** 7 | * Copy only config files. 8 | */ 9 | export const copyConfigFiles = async () => { 10 | await cpy( 11 | [ 12 | '.template/*', 13 | '.template/.*', 14 | '!.template/.ncmrc.yml', // there can be .ncmrc.yml in template 15 | '!.template/*.md', 16 | '!.template/package.json', 17 | '!.template/yarn.lock', 18 | ], 19 | '.', 20 | ); 21 | await exec('cp -r .template/.circleci .'); 22 | await exec('cp -r .template/.husky .'); 23 | }; 24 | 25 | /** 26 | * Merge devDependencies and optionalDependencies from template package.json. 27 | */ 28 | export const updatePackageJson = async () => { 29 | const template = await jsonfile.readFile('.template/package.json'); 30 | const target = await jsonfile.readFile('./package.json'); 31 | const updated = { ...target }; 32 | updated.devDependencies = sortObjectByKeys({ 33 | ...target.devDependencies, 34 | ...template.devDependencies, 35 | }); 36 | updated.optionalDependencies = sortObjectByKeys({ 37 | ...target.optionalDependencies, 38 | ...template.optionalDependencies, 39 | }); 40 | await jsonfile.writeFile('./package.json', updated, { spaces: 2 }); 41 | }; 42 | -------------------------------------------------------------------------------- /src/actions/init/index.js: -------------------------------------------------------------------------------- 1 | import inquirer from 'inquirer'; 2 | 3 | import { COMPONENT_TYPES, PACKAGE_ENVIRONMENTS, OWNER_TYPES } from 'constants'; 4 | import * as steps from './steps'; 5 | 6 | const componentQuestions = [ 7 | { 8 | type: 'list', 9 | name: 'componentType', 10 | message: 'Which type of component do you want to create?', 11 | choices: COMPONENT_TYPES, 12 | }, 13 | { 14 | type: 'list', 15 | name: 'packageEnvironment', 16 | message: 'Where will the package be used?', 17 | choices: PACKAGE_ENVIRONMENTS, 18 | when: ({ componentType }) => componentType === 'package', 19 | }, 20 | { 21 | type: 'confirm', 22 | name: 'componentPublic', 23 | message: 'Is the package to be public?', 24 | default: ({ componentType }) => componentType === 'package', 25 | when: ({ componentType }) => componentType === 'package', 26 | }, 27 | { 28 | type: 'input', 29 | name: 'componentName', 30 | message: ({ componentType }) => `Enter the name of the ${componentType}:`, 31 | // TODO: offer hooks to add naming convention validation as plugin 32 | }, 33 | ]; 34 | 35 | const ownerQuestions = [ 36 | { 37 | type: 'list', 38 | name: 'ownerType', 39 | message: 'Which is the type of the owner?', 40 | choices: OWNER_TYPES, 41 | }, 42 | { 43 | type: 'input', 44 | name: 'ownerGithub', 45 | message: ({ ownerType }) => `Enter the owner [${ownerType}] GitHub id:`, 46 | }, 47 | { 48 | type: 'input', 49 | name: 'packageNpmScope', 50 | message: 'What is the npm scope name for the package?', 51 | default: ({ ownerType, ownerGithub }) => 52 | ownerType === 'organisation' ? ownerGithub : undefined, 53 | when: ({ componentType, componentPublic }) => 54 | componentType === 'package' && componentPublic, 55 | }, 56 | { 57 | type: 'input', 58 | name: 'ownerEmail', 59 | message: 'Please enter the email of the primary maintainer:', 60 | // TODO: add validation function 61 | }, 62 | ]; 63 | 64 | const questions = [...componentQuestions, ...ownerQuestions]; 65 | 66 | export default () => 67 | inquirer.prompt(questions).then(async (answers) => { 68 | await steps.createNcmrc(answers); 69 | const { componentType, componentName } = answers; 70 | await steps.addCommentsToNcmrc({ componentName }); 71 | await steps.initGit({ componentName }); 72 | await steps.cdToComponentDir({ componentName }); 73 | console.log( 74 | `created new [${componentType}]: ${componentName}\ncheck .ncmrc.yml and run 'ncm setup'`, 75 | ); 76 | }); 77 | -------------------------------------------------------------------------------- /src/actions/init/steps.js: -------------------------------------------------------------------------------- 1 | import yaml from 'write-yaml'; 2 | 3 | import { readFile, writeFile } from 'lib/fs'; 4 | import { exec } from 'lib/child-process'; 5 | 6 | export const createNcmrc = async (answers) => { 7 | // TODO: parse answers to an object and validate against the schema 8 | const template = { 9 | component: { 10 | type: answers.componentType, 11 | name: answers.componentName, 12 | description: '', 13 | keywords: '', 14 | private: !answers.componentPublic, 15 | }, 16 | ...(answers.componentType === 'package' 17 | ? { 18 | package: { 19 | environment: answers.packageEnvironment, 20 | npmScope: answers.packageNpmScope, 21 | }, 22 | } 23 | : {}), 24 | owner: { 25 | type: answers.ownerType, 26 | github: answers.ownerGithub, 27 | team: '', 28 | name: '', 29 | email: answers.ownerEmail, 30 | }, 31 | }; 32 | const NCMRC_PATH = `./${answers.componentName}/.ncmrc.yml`; 33 | yaml.sync(NCMRC_PATH, template, { 34 | safe: true, 35 | }); 36 | }; 37 | 38 | export const addCommentsToNcmrc = async ({ componentName }) => { 39 | const NCMRC_PATH = `./${componentName}/.ncmrc.yml`; 40 | const template = await readFile(NCMRC_PATH); 41 | const updated = `## created by @opbi/ncm\n---\n${template}`; 42 | await writeFile(NCMRC_PATH, updated); 43 | }; 44 | 45 | export const initGit = async ({ componentName }) => 46 | exec(`git init -q ./${componentName}`); 47 | 48 | export const cdToComponentDir = async ({ componentName }) => 49 | exec(`cd ./${componentName}`); 50 | -------------------------------------------------------------------------------- /src/actions/secret/index.js: -------------------------------------------------------------------------------- 1 | import getSecretsFromVault from 'lib/vault'; 2 | import { objToDotenv } from 'lib/format'; 3 | import { writeFile } from 'lib/fs'; 4 | 5 | export default async ({ scope, endpoint, token, auth }) => { 6 | try { 7 | const secrets = await getSecretsFromVault({ 8 | scope, 9 | endpoint, 10 | token, 11 | auth, 12 | }); 13 | const content = objToDotenv(secrets); 14 | await writeFile('./.env', content); 15 | console.log(`secrets have been written to .env:\n${content}`); 16 | } catch (e) { 17 | console.log(e); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /src/actions/setup/index.js: -------------------------------------------------------------------------------- 1 | import * as commonSteps from '../common-steps'; 2 | import * as steps from './steps'; 3 | 4 | export default async () => { 5 | const config = await commonSteps.readConfig(); 6 | 7 | await commonSteps.cloneTemplateRepo(config); 8 | 9 | await steps.copyTemplateFiles(); 10 | 11 | await commonSteps.removeTemplateDir(); 12 | 13 | await steps.updatePackageJson(config); 14 | 15 | await steps.generateReadme(config); 16 | 17 | await steps.createGithubRepo(config); 18 | console.log(`GitHub repo created`); 19 | 20 | await steps.addGitRemoteOrigin(config); 21 | 22 | await steps.commitAndPushToGitHub(); 23 | }; 24 | -------------------------------------------------------------------------------- /src/actions/setup/steps.js: -------------------------------------------------------------------------------- 1 | import cpy from 'cpy'; 2 | import jsonfile from 'jsonfile'; 3 | import replace from 'replace-in-file'; 4 | 5 | import { setupGithubClient } from 'lib/github'; 6 | import { exec } from 'lib/child-process'; 7 | 8 | import configPackageJsonFromTemplate from 'lib/package-json'; 9 | 10 | export const copyTemplateFiles = async () => { 11 | await cpy( 12 | [ 13 | '.template/*', 14 | '.template/.*', 15 | '!.template/.ncmrc.yml', // there can be .ncmrc.yml in template 16 | '!.template/README.md', 17 | ], 18 | '.', 19 | { 20 | rename: (fileName) => 21 | fileName === 'TEMPLATE.md' ? 'README.md' : fileName, 22 | }, 23 | ); 24 | await exec('cp -r .template/src .'); 25 | await exec('cp -r .template/.circleci .'); 26 | }; 27 | 28 | export const updatePackageJson = async (config) => { 29 | const PACKAGE_JSON_PATH = './package.json'; 30 | const template = await jsonfile.readFile(PACKAGE_JSON_PATH); 31 | const packageJson = configPackageJsonFromTemplate(config, template); 32 | await jsonfile.writeFile(PACKAGE_JSON_PATH, packageJson, { 33 | spaces: 2, 34 | }); 35 | }; 36 | 37 | export const generateReadme = async (config) => { 38 | const PACKAGE_JSON_PATH = './package.json'; 39 | const { name: packageJsonName, license: packageJsonLicense } = 40 | await jsonfile.readFile(PACKAGE_JSON_PATH); 41 | await replace({ 42 | files: `./README.md`, 43 | from: [ 44 | /{{componentName}}/g, 45 | /{{componentDescription}}/g, 46 | /{{ownerGithub}}/g, 47 | /{{packageJsonName}}/g, 48 | /{{packageJsonLicense}}/g, 49 | ], 50 | to: [ 51 | config.component.name, 52 | config.component.description, 53 | config.owner.github, 54 | packageJsonName, 55 | packageJsonLicense, 56 | ], 57 | }); 58 | }; 59 | 60 | export const createGithubRepo = async (config) => { 61 | const authRequired = 62 | config.owner.type === 'organisation' || config.component.private; 63 | const github = await setupGithubClient({ authRequired }); 64 | await github.repos.createInOrg({ 65 | org: config.owner.github, 66 | name: config.component.name, 67 | description: config.component.description, 68 | private: config.component.private, 69 | }); 70 | }; 71 | 72 | export const addGitRemoteOrigin = async (config) => 73 | exec( 74 | `git remote add origin git@github.com:${config.owner.github}/${config.component.name}.git`, 75 | ); 76 | 77 | export const commitAndPushToGitHub = async () => { 78 | await exec('git add .'); 79 | await exec(`git commit -m 'chore: init'`); 80 | await exec('git push -u origin master'); 81 | }; 82 | 83 | // export const installDeps = async () => {}; 84 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | import os from 'os'; 2 | 3 | export const COMPONENT_TYPES = [ 4 | 'package', 5 | 'service', 6 | 'worker(TODO)', 7 | 'app(TODO)', 8 | ]; 9 | export const PACKAGE_ENVIRONMENTS = [ 10 | 'node', 11 | 'cli', 12 | 'browser(TODO)', 13 | 'universal(TODO)', 14 | ]; 15 | export const OWNER_TYPES = ['organisation', 'personal']; 16 | 17 | export const CWD = process.cwd(); // current working directory: location where node command is invoked 18 | export const HOME = os.homedir(); 19 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import cli from 'commander'; 4 | 5 | import initAction from 'actions/init'; 6 | import setupAction from 'actions/setup'; 7 | import configAction from 'actions/config'; 8 | import secretAction from 'actions/secret'; 9 | 10 | import packageJson from '../package'; 11 | 12 | cli.version(packageJson.version, '-v, --version'); 13 | 14 | cli 15 | .command('init') 16 | .description('create the ncm specs of a new component') 17 | .action(initAction); 18 | 19 | cli 20 | .command('setup') 21 | .description('setup the new component according to .ncmrc.yml') 22 | .action(setupAction); 23 | 24 | cli 25 | .command('config') 26 | .option('-e, --environment [envID]', 'dev, ci') 27 | .option('-t, --target [targetTemplate]', 'repo of config files template') 28 | .description( 29 | 'generate config files based on template and specs in .ncmrc.yml', 30 | ) 31 | .action(configAction); 32 | 33 | cli 34 | .command('secret') 35 | .alias('.env') 36 | .description('write vault dev secrets to .env') 37 | .option('-s, --scope ', 'path of the secret in vault') 38 | .option('-e --endpoint [vaultEndpoint]', 'endpoint of the vault server') 39 | .option('-t, --token [token]', 'vault auth token') 40 | .option('-a, --auth [method]', 'vault auth method: [token], github') 41 | .action(secretAction); 42 | 43 | cli.parse(process.argv); 44 | -------------------------------------------------------------------------------- /src/lib/__tests__/__snapshots__/format.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`objToDotenv transform json into dotenv style key-value content 1`] = ` 4 | "id=1 5 | foo=bar 6 | " 7 | `; 8 | -------------------------------------------------------------------------------- /src/lib/__tests__/format.js: -------------------------------------------------------------------------------- 1 | import { objToDotenv } from '../format'; 2 | 3 | describe('objToDotenv', () => { 4 | it('transform json into dotenv style key-value content', () => { 5 | const obj = { 6 | id: 1, 7 | foo: 'bar', 8 | }; 9 | const result = objToDotenv(obj); 10 | expect(result).toMatchSnapshot(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/lib/child-process.js: -------------------------------------------------------------------------------- 1 | import childProcess from 'child_process'; 2 | import { promisify } from 'util'; 3 | 4 | export const exec = promisify(childProcess.exec); 5 | 6 | export default {}; 7 | -------------------------------------------------------------------------------- /src/lib/format.js: -------------------------------------------------------------------------------- 1 | export const objToDotenv = (obj) => 2 | Object.keys(obj).reduce((output, key) => `${output}${key}=${obj[key]}\n`, ''); 3 | 4 | export default { 5 | objToDotenv, 6 | }; 7 | -------------------------------------------------------------------------------- /src/lib/fs.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { promisify } from 'util'; 3 | 4 | export const readFile = (filePath, option = { encoding: 'utf-8' }) => 5 | promisify(fs.readFile)(filePath, option); 6 | export const writeFile = promisify(fs.writeFile); 7 | export const mkdir = promisify(fs.mkdir); 8 | -------------------------------------------------------------------------------- /src/lib/github.js: -------------------------------------------------------------------------------- 1 | import octokit from '@octokit/rest'; 2 | 3 | export const setupGithubClient = async ({ authRequired = false }) => { 4 | const github = octokit(); 5 | if (authRequired) { 6 | if (!process.env.NCM_AUTH_GITHUB_TOKEN) { 7 | throw Error('NCM_AUTH_GITHUB_TOKEN not found in environment'); 8 | } 9 | await github.authenticate({ 10 | type: 'oauth', 11 | token: process.env.NCM_AUTH_GITHUB_TOKEN, 12 | }); 13 | } 14 | return github; 15 | }; 16 | 17 | export default {}; 18 | -------------------------------------------------------------------------------- /src/lib/package-json.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Logics of generate package.json as per component type. 3 | * 4 | * @type {Object} The generated package.json content. 5 | */ 6 | const configPackageJsonFromTemplate = (config, template) => { 7 | const output = {}; 8 | 9 | // header 10 | if (config.component.type === 'package') { 11 | output.name = `@${config.package.npmScope}/${config.component.name}`; 12 | output.description = config.component.description; 13 | output.keywords = config.component.keywords; 14 | output.repository = `git@github.com:${config.owner.github}/${config.component.name}.git`; // ASSUME: using GitHub 15 | output.author = `${config.owner.name} <${config.owner.email}>`; 16 | 17 | if (!config.component.private) { 18 | output.license = config.package.license || template.license; 19 | output.publishConfig = { 20 | access: 'public', 21 | }; // make it compatible with semantic-release 22 | } else { 23 | output.private = true; 24 | } 25 | } else { 26 | output.name = config.component.name; // only attached to make it easy to distinguish files in development 27 | output.private = config.component.private; 28 | } 29 | 30 | // dependencies 31 | output.dependencies = template.dependencies; 32 | output.devDependencies = template.devDependencies; 33 | output.optionalDependencies = template.optionalDependencies; 34 | 35 | // footer - package only 36 | if (config.component.type === 'package') { 37 | output.engines = template.engines; 38 | 39 | if (config.package.environment === 'cli') { 40 | output.bin = { 41 | [config.component.name]: 'dist/index.js', // ASSUME: cli built use Babel 42 | }; 43 | } else { 44 | output.main = 'dist/index.js'; 45 | } 46 | } 47 | 48 | return output; 49 | }; 50 | 51 | export default configPackageJsonFromTemplate; 52 | -------------------------------------------------------------------------------- /src/lib/util.js: -------------------------------------------------------------------------------- 1 | export const sortObjectByKeys = (obj) => 2 | Object.keys(obj) 3 | .sort() 4 | .reduce( 5 | (output, key) => ({ 6 | ...output, 7 | [key]: obj[key], 8 | }), 9 | {}, 10 | ); 11 | 12 | export default {}; 13 | -------------------------------------------------------------------------------- /src/lib/vault.js: -------------------------------------------------------------------------------- 1 | import vault from 'node-vault'; 2 | 3 | import { readFile } from 'lib/fs'; 4 | import { HOME } from 'constants'; 5 | 6 | const getVaultTokenFromFile = async () => { 7 | const VAULT_TOKEN_FILE = '.vault-token'; 8 | const token = await readFile(`${HOME}/${VAULT_TOKEN_FILE}`); 9 | return token; 10 | }; 11 | 12 | const setupVaultWithGithubToken = async ({ endpoint }) => { 13 | const { VAULT_AUTH_GITHUB_TOKEN } = process.env; 14 | if (!VAULT_AUTH_GITHUB_TOKEN) 15 | throw Error('VAULT_AUTH_GITHUB_TOKEN not found'); 16 | 17 | const vaultClient = vault({ endpoint }); 18 | 19 | const res = await vaultClient.githubLogin({ 20 | token: VAULT_AUTH_GITHUB_TOKEN, 21 | }); 22 | vaultClient.token = res.auth.client_token; 23 | 24 | return vaultClient; 25 | }; 26 | 27 | const setupVaultClient = async ({ 28 | endpoint = process.env.VAULT_ADDR, 29 | token = process.env.VAULT_TOKEN, 30 | auth = 'token', 31 | }) => { 32 | if (auth.toLowerCase() === 'github') { 33 | const vaultClient = await setupVaultWithGithubToken({ endpoint }); 34 | return vaultClient; 35 | } 36 | if (token) { 37 | return vault({ endpoint, token }); 38 | } 39 | try { 40 | const fileToken = await getVaultTokenFromFile(); 41 | return vault({ endpoint, token: fileToken }); 42 | } catch (e) { 43 | if (e.code === 'ENOENT') { 44 | throw Error( 45 | 'vault access token is not provided and local vault not authenticated', 46 | ); 47 | } 48 | throw e; 49 | } 50 | }; 51 | 52 | const getVaultSecrets = async ({ endpoint, token, auth, scope }) => { 53 | const vaultClient = await setupVaultClient({ endpoint, token, auth }); 54 | const { data } = await vaultClient.read(scope); 55 | return data; 56 | }; 57 | 58 | export default getVaultSecrets; 59 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "allowJs": true, 5 | "checkJs": false, 6 | "removeComments": false, 7 | "declaration": true, 8 | "alwaysStrict": true, 9 | "strictNullChecks": false, 10 | "target": "es6", 11 | "moduleResolution": "node", 12 | "module": "commonjs", 13 | "outDir": "types", 14 | "baseUrl": "./src" 15 | }, 16 | "include": [ 17 | "src/**/*" 18 | ], 19 | "exclude": [ 20 | "node_modules", 21 | "types", 22 | "dist", 23 | "coverage", 24 | "docs", 25 | "**/__tests__/**" 26 | ] 27 | } 28 | --------------------------------------------------------------------------------