├── .circleci ├── cleanDir.js ├── config.yml ├── createDirect.js ├── jest-integration.config.json └── removeDirect.js ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .husky └── pre-commit ├── .npmignore ├── .prettierignore ├── LICENSE ├── README.md ├── azure-pipelines.yml ├── bin └── gluegun ├── docs ├── .nojekyll ├── README.md ├── _category_.json ├── _coverpage.md ├── _sidebar.md ├── contributing.md ├── getting-started.md ├── guide-architecture.md ├── index.html ├── plugins.md ├── releasing.md ├── runtime.md ├── sniff.md ├── toolbox-api.md ├── toolbox-config.md ├── toolbox-filesystem.md ├── toolbox-http.md ├── toolbox-meta.md ├── toolbox-package-manager.md ├── toolbox-parameters.md ├── toolbox-patching.md ├── toolbox-print.md ├── toolbox-prompt.md ├── toolbox-semver.md ├── toolbox-strings.md ├── toolbox-system.md ├── toolbox-template.md ├── tutorial-making-a-movie-cli.md └── tutorial-making-a-plugin.md ├── package.json ├── renovate.json ├── sniff-async.js ├── sniff.js ├── src ├── cli │ ├── cli.integration.ts │ ├── cli.ts │ ├── commands │ │ ├── kitchen.ts │ │ ├── new.test.ts │ │ └── new.ts │ ├── extensions │ │ └── cli-extension.ts │ └── templates │ │ ├── cli │ │ ├── .eslintrc.js.ejs │ │ ├── .gitignore.ejs │ │ ├── LICENSE.ejs │ │ ├── __tests__ │ │ │ └── cli-integration.test.js.ejs │ │ ├── bin │ │ │ └── cli-executable.ejs │ │ ├── docs │ │ │ ├── commands.md.ejs │ │ │ └── plugins.md.ejs │ │ ├── package.json.ejs │ │ ├── readme.md.ejs │ │ ├── src │ │ │ ├── cli.js.ejs │ │ │ ├── commands │ │ │ │ ├── default.js.ejs │ │ │ │ └── generate.js.ejs │ │ │ ├── extensions │ │ │ │ └── cli-extension.js.ejs │ │ │ ├── templates │ │ │ │ └── model.js.ejs.ejs │ │ │ └── types.js.ejs │ │ └── tsconfig.json.ejs │ │ └── test │ │ └── kitchen-sink-command.js.ejs ├── core-commands │ ├── default.ts │ ├── help.ts │ └── version.ts ├── core-extensions │ ├── filesystem-extension.test.ts │ ├── filesystem-extension.ts │ ├── http-extension.test.ts │ ├── http-extension.ts │ ├── meta-extension.test.ts │ ├── meta-extension.ts │ ├── package-manager-extension.ts │ ├── patching-extension.test.ts │ ├── patching-extension.ts │ ├── print-extension.test.ts │ ├── print-extension.ts │ ├── prompt-extension.test.ts │ ├── prompt-extension.ts │ ├── semver-extension.ts │ ├── strings-extension.test.ts │ ├── strings-extension.ts │ ├── system-extension.test.ts │ ├── system-extension.ts │ ├── template-extension.test.ts │ └── template-extension.ts ├── domain │ ├── builder.test.ts │ ├── builder.ts │ ├── command.test.ts │ ├── command.ts │ ├── extension.ts │ ├── options.ts │ ├── plugin.test.ts │ ├── plugin.ts │ ├── toolbox.test.ts │ └── toolbox.ts ├── filesystem.ts ├── fixtures │ ├── bad-modules │ │ ├── blank.js │ │ ├── number.js │ │ ├── object.js │ │ └── text.js │ ├── bad-plugins │ │ └── long-async │ │ │ └── extensions │ │ │ └── longAsyncExtension.ts │ ├── good-modules │ │ ├── async-function.js │ │ ├── module-exports-fat-arrow-fn.js │ │ ├── module-exports-function.js │ │ └── module-exports-object.js │ └── good-plugins │ │ ├── args │ │ ├── args.config.js │ │ └── commands │ │ │ ├── config.js │ │ │ └── hello.js │ │ ├── async-extension │ │ └── extensions │ │ │ └── loadDataExtension.js │ │ ├── auto-detect │ │ ├── commands │ │ │ └── detectCommand.js │ │ └── extensions │ │ │ └── detectExtension.js │ │ ├── blank-name │ │ └── blank-name.config.js │ │ ├── empty │ │ └── .gitkeep │ │ ├── excluded │ │ ├── commands │ │ │ ├── bar.js │ │ │ ├── foo.js │ │ │ └── foo.test.js │ │ └── extensions │ │ │ ├── baz-extension.js │ │ │ └── baz-extension.test.js │ │ ├── front-matter │ │ ├── commands │ │ │ └── full.js │ │ └── extensions │ │ │ └── hello.js │ │ ├── generate-build │ │ ├── build │ │ │ ├── commands │ │ │ │ └── build │ │ │ │ │ ├── missing.js │ │ │ │ │ ├── props.js │ │ │ │ │ ├── simple.js │ │ │ │ │ └── special.js │ │ │ ├── custom-directory │ │ │ │ └── special.ejs │ │ │ └── templates │ │ │ │ ├── props.ejs │ │ │ │ └── simple.ejs │ │ └── gluegun.toml │ │ ├── generate │ │ ├── commands │ │ │ ├── missing.js │ │ │ ├── props.js │ │ │ ├── simple.js │ │ │ └── special.js │ │ ├── custom-directory │ │ │ └── special.ejs │ │ ├── gluegun.toml │ │ └── templates │ │ │ ├── props.ejs │ │ │ └── simple.ejs │ │ ├── hidden │ │ └── commands │ │ │ └── hide.js │ │ ├── missing-name │ │ ├── commands │ │ │ └── foo.js │ │ └── gluegun.toml │ │ ├── nested-build │ │ └── build │ │ │ ├── commands │ │ │ ├── implied │ │ │ │ └── bar.js │ │ │ ├── nested.js │ │ │ └── thing │ │ │ │ ├── foo.js │ │ │ │ └── thing.js │ │ │ └── extensions │ │ │ └── nested-build-extension.js │ │ ├── nested │ │ └── commands │ │ │ ├── implied │ │ │ └── bar.js │ │ │ ├── nested.js │ │ │ └── thing │ │ │ ├── foo.js │ │ │ └── thing.js │ │ ├── simplest │ │ └── .gitkeep │ │ ├── threepack │ │ ├── commands │ │ │ ├── one.js │ │ │ ├── three.js │ │ │ └── two.js │ │ └── package.json │ │ └── throws │ │ ├── commands │ │ └── throw.js │ │ └── gluegun.toml ├── http.ts ├── index.test.ts ├── index.ts ├── loaders │ ├── command-loader.test.ts │ ├── command-loader.ts │ ├── config-loader.ts │ ├── extension-loader.test.ts │ ├── extension-loader.ts │ ├── module-loader.test.ts │ ├── module-loader.ts │ ├── plugin-loader.test.ts │ └── plugin-loader.ts ├── package-manager.ts ├── patching.ts ├── print.ts ├── prompt.ts ├── runtime │ ├── run.ts │ ├── runtime-config.test.ts │ ├── runtime-extensions.test.ts │ ├── runtime-find-command.ts │ ├── runtime-parameters.test.ts │ ├── runtime-plugin.test.ts │ ├── runtime-plugins.test.ts │ ├── runtime-run-bad.test.ts │ ├── runtime-run-good.test.ts │ ├── runtime-src.test.ts │ └── runtime.ts ├── semver.ts ├── strings.ts ├── system.ts └── toolbox │ ├── __snapshots__ │ └── print-tools.test.ts.snap │ ├── filesystem-tools.test.ts │ ├── filesystem-tools.ts │ ├── filesystem-types.ts │ ├── http-tools.ts │ ├── http-types.ts │ ├── meta-tools.test.ts │ ├── meta-tools.ts │ ├── meta-types.ts │ ├── package-manager-tools.ts │ ├── package-manager-types.ts │ ├── package-manager.test.ts │ ├── parameter-tools.ts │ ├── patching-tools.ts │ ├── patching-types.ts │ ├── print-tools.test.ts │ ├── print-tools.test.ts.md │ ├── print-tools.ts │ ├── print-types.ts │ ├── prompt-enquirer-types.ts │ ├── prompt-tools.ts │ ├── prompt-types.ts │ ├── semver-tools.ts │ ├── semver-types.ts │ ├── string-tools.test.ts │ ├── string-tools.ts │ ├── strings-types.ts │ ├── system-tools.test.ts │ ├── system-tools.ts │ ├── system-types.ts │ ├── template-tools.ts │ ├── template-types.ts │ └── utils.ts ├── tsconfig.json └── yarn.lock /.circleci/cleanDir.js: -------------------------------------------------------------------------------- 1 | const { readdirSync, statSync, rmdir, unlink, existsSync } = require('fs') 2 | const path = require('path') 3 | 4 | const targetDir = process.argv.length > 2 ? process.argv[2] : null 5 | 6 | if (targetDir == null || !existsSync(targetDir)) { 7 | throw Error('You must provider a valid path to clean.') 8 | } 9 | 10 | const getChildren = (dir) => readdirSync(dir).map((name) => path.join(dir, name)) 11 | const stat = (path) => ({ 12 | path, 13 | isDir: statSync(path).isDirectory(), 14 | }) 15 | const isEmpty = ({ path, isDir }) => { 16 | const stats = statSync(path) 17 | if (!isDir) { 18 | return stats.size === 0 19 | } else { 20 | const children = getChildren(path) 21 | return children.length === 0 || children.map(stat).every(isEmpty) 22 | } 23 | } 24 | 25 | for (const { path, isDir } of getChildren(targetDir).map(stat).filter(isEmpty)) { 26 | if (isDir) { 27 | rmdir(path, (err) => (!!err ? console.log(err) : null)) 28 | } else { 29 | unlink(path, (err) => (!!err ? console.log(err) : null)) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | 6 | defaults: &defaults 7 | docker: 8 | # Choose the version of Node you want here 9 | - image: cimg/node:18.17.1 10 | working_directory: ~/repo 11 | 12 | version: 2 13 | jobs: 14 | setup: 15 | <<: *defaults 16 | steps: 17 | - checkout 18 | - restore_cache: 19 | name: Restore node modules 20 | keys: 21 | - v1-dependencies-{{ checksum "yarn.lock" }} 22 | # fallback to using the latest cache if no exact match is found 23 | - v1-dependencies- 24 | - run: 25 | name: Install dependencies 26 | command: | 27 | yarn install --frozen-lockfile 28 | - save_cache: 29 | name: Save node modules 30 | paths: 31 | - node_modules 32 | key: v1-dependencies-{{ checksum "yarn.lock" }} 33 | 34 | tests: 35 | <<: *defaults 36 | steps: 37 | - checkout 38 | - restore_cache: 39 | name: Restore node modules 40 | keys: 41 | - v1-dependencies-{{ checksum "yarn.lock" }} 42 | # fallback to using the latest cache if no exact match is found 43 | - v1-dependencies- 44 | - run: 45 | name: Install React Native CLI and Ignite CLI 46 | command: | 47 | sudo npm i -g ignite-cli react-native-cli 48 | - run: 49 | name: Run tests 50 | command: yarn ci:test # this command will be added to/found in your package.json scripts 51 | 52 | publish: 53 | <<: *defaults 54 | steps: 55 | - checkout 56 | - run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc 57 | - restore_cache: 58 | name: Restore node modules 59 | keys: 60 | - v1-dependencies-{{ checksum "yarn.lock" }} 61 | # fallback to using the latest cache if no exact match is found 62 | - v1-dependencies- 63 | # Run semantic-release after all the above is set. 64 | - run: 65 | name: Publish to NPM 66 | command: yarn ci:publish # this will be added to your package.json scripts 67 | 68 | workflows: 69 | version: 2 70 | test_and_release: 71 | jobs: 72 | - setup 73 | - tests: 74 | requires: 75 | - setup 76 | - publish: 77 | requires: 78 | - tests 79 | filters: 80 | branches: 81 | only: master 82 | -------------------------------------------------------------------------------- /.circleci/createDirect.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This script creates the files necessary to access Gluegun tools directly. 3 | * 4 | * For example: 5 | * 6 | * const { print } = require('gluegun/print') 7 | * 8 | * It drops the files into the root directory just prior to releasing a new 9 | * version of Gluegun. 10 | * 11 | * They are .gitignore'd by version control and quickly cleaned up after release. 12 | */ 13 | 14 | const directFiles = [ 15 | 'filesystem', 16 | 'strings', 17 | 'print', 18 | 'system', 19 | 'semver', 20 | 'http', 21 | 'patching', 22 | 'prompt', 23 | 'package-manager', 24 | ] 25 | 26 | const fs = require('fs') 27 | 28 | // add all the direct access files 29 | directFiles.forEach((f) => { 30 | const filename = __dirname + '/../' + f + '.js' 31 | fs.writeFileSync(filename, `module.exports = require('./build/${f}')\n`) 32 | }) 33 | 34 | // add the toolbox.js file for backwards compatibility 35 | fs.writeFileSync( 36 | __dirname + '/../toolbox.js', 37 | `// for backwards compatibility with beta-rc7 38 | module.exports = require('./build/index') 39 | `, 40 | ) 41 | -------------------------------------------------------------------------------- /.circleci/jest-integration.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "testEnvironment": "node", 3 | "transform": { 4 | "^.+\\.ts$": "ts-jest" 5 | }, 6 | "testRegex": "(\\.|/)integration\\.ts$", 7 | "moduleFileExtensions": ["ts", "js", "json", "node"], 8 | "roots": ["/.."] 9 | } 10 | -------------------------------------------------------------------------------- /.circleci/removeDirect.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file cleans up the build artifacts generated by createDirect.js. 3 | */ 4 | 5 | const directFiles = [ 6 | 'filesystem', 7 | 'strings', 8 | 'print', 9 | 'system', 10 | 'semver', 11 | 'http', 12 | 'patching', 13 | 'prompt', 14 | 'package-manager', 15 | 'toolbox', 16 | ] 17 | const fs = require('fs') 18 | directFiles.forEach((f) => { 19 | const filename = __dirname + '/../' + f + '.js' 20 | if (fs.existsSync(filename)) fs.unlinkSync(filename) 21 | }) 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Got a bug? Let's hear about it 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. Please note that Gluegun is community-supported, so submitting a PR to fix this bug would be greatly appreciated. We will prioritize reviewing and releasing bugfixes. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Create a CLI with '...' 16 | 2. Add this code '....' 17 | 3. Run the CLI with '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Doctor (please complete the following information):** 27 | - OS: [e.g. macOS 12.0.1] 28 | - Gluegun Version [e.g. 5.0.1] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Note that Gluegun is community-supported and the core team will not be adding new features other than those submitted as PRs by the community.** 11 | 12 | **Is your feature request related to a problem? Please describe.** 13 | A clear and concise description of what the problem is. Ex. Gluegun doesn't work very well when [...] 14 | 15 | **Describe the solution you'd like** 16 | A clear and concise description of what you want to happen. 17 | 18 | **Describe alternatives you've considered** 19 | A clear and concise description of any alternative solutions or features you've considered. 20 | 21 | **Additional context** 22 | Add any other context or screenshots about the feature request here. 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log* 3 | npm-debug.log 4 | yarn-error.log 5 | .vscode 6 | package-lock.json 7 | coverage 8 | .nyc_output 9 | generated 10 | dist/**/* 11 | build/**/* 12 | .DS_Store 13 | *.tgz 14 | 15 | # these are generated at build-time 16 | toolbox.js 17 | filesystem.js 18 | strings.js 19 | print.js 20 | system.js 21 | semver.js 22 | http.js 23 | patching.js 24 | prompt.js 25 | package-manager.js 26 | 27 | # Webstorm & IntelliJ 28 | .idea 29 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | tsconfig.json 2 | docs/ 3 | 4 | readme.md -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | package.json 3 | package-lock.json 4 | yarn.lock 5 | readme.md 6 | dist/ 7 | build/ 8 | docs/readme.md 9 | *.ejs 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-3016 Infinite Red, Inc. 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 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | pool: 2 | vmImage: 'windows-latest' 3 | 4 | steps: 5 | - task: NodeTool@0 6 | inputs: 7 | versionSpec: '14.x' 8 | displayName: 'Install Node.js' 9 | 10 | - script: | 11 | choco install yarn -y 12 | displayName: 'install yarn' 13 | 14 | - script: | 15 | yarn install 16 | displayName: 'yarn install' 17 | 18 | - script: | 19 | yarn ci:test 20 | displayName: 'yarn ci:test' 21 | 22 | - task: ArchiveFiles@2 23 | inputs: 24 | rootFolderOrFile: '$(System.DefaultWorkingDirectory)/build/' 25 | includeRootFolder: false 26 | 27 | - task: PublishBuildArtifacts@1 28 | -------------------------------------------------------------------------------- /bin/gluegun: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // speed up `gluegun --version` et al 4 | if (['v', 'version', '-v', '--v', '-version', '--version'].includes(process.argv[2])) { 5 | var contents = require('fs').readFileSync(__dirname + '/../package.json') 6 | var package = JSON.parse(contents) 7 | console.log(package.version) 8 | process.exit(0) 9 | } 10 | 11 | // check if we're running in dev mode 12 | var devMode = require('fs').existsSync(`${__dirname}/../src`) 13 | var wantsCompiled = process.argv.indexOf('--compiled-gluegun') >= 0 14 | 15 | if (devMode && !wantsCompiled) { 16 | // hook into ts-node so we can run typescript on the fly 17 | require('ts-node').register({ 18 | project: `${__dirname}/../tsconfig.json`, 19 | compiler: require.resolve('typescript', { paths: [__dirname] }), 20 | }) 21 | // kick off gluegun 22 | require(`${__dirname}/../src/cli/cli`).run(process.argv) 23 | } else { 24 | require(`${__dirname}/../build/cli/cli`).run(process.argv) 25 | } 26 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/infinitered/gluegun/9332f599353785b20b64b322c59bb9e36137b497/docs/.nojekyll -------------------------------------------------------------------------------- /docs/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Gluegun", 3 | "link": null, 4 | "customProps": { 5 | "description": "A delightful toolkit for building Node-based CLIs in TS/JS", 6 | "projectName": "gluegun", 7 | "repoUrl": "https://github.com/infinitered/gluegun", 8 | "sidebar": { 9 | "type": "autogenerated", 10 | "dirName": "gluegun" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /docs/_coverpage.md: -------------------------------------------------------------------------------- 1 | # Gluegun 2.0 2 | 3 | > A delightful toolkit for building Node-powered CLIs. 4 | 5 | - Lightweight (~83kB tarball) 6 | - Batteries included 7 | - Plugin-ready 8 | 9 | [GitHub](https://github.com/infinitered/gluegun) 10 | [Get Started](#quick-start) 11 | -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 | - [Home](/) 2 | - [Getting Started](/getting-started) 3 | - [Runtime API](/runtime) 4 | - [Toolbox API](/toolbox-api) 5 | - [config](/toolbox-config) 6 | - [filesystem](/toolbox-filesystem) 7 | - [semver](/toolbox-semver) 8 | - [http](/toolbox-http) 9 | - [parameters](/toolbox-parameters) 10 | - [print](/toolbox-print) 11 | - [prompt](/toolbox-prompt) 12 | - [strings](/toolbox-strings) 13 | - [system](/toolbox-system) 14 | - [template](/toolbox-template) 15 | - [patching](/toolbox-patching) 16 | - [packageManager](/toolbox-package-manager) 17 | - Tutorials 18 | - [Tutorial: Making a Movie CLI](/tutorial-making-a-movie-cli) 19 | - [Tutorial: Making a Plugin](/tutorial-making-a-plugin) 20 | - Guides 21 | - [Guide: Architecting Your Gluegun CLI](/guide-architecture) 22 | - [Plugins](/plugins) 23 | - [Contributing](/contributing) 24 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | _Welcome!_ 4 | 5 | Bug fixes, features, docs, marketing, and issue support are all contributions. We love it when people help out and are more than willing to give you advice, guidance, or just be a 🐥 debugger for you. 6 | 7 | ## Global Dependencies 8 | 9 | If you're reading this, you might be interested in pitching in from a code point of view. 10 | 11 | `gluegun` is powered by Node (7.6 or above). Install Node using `brew` (if on macOS) or by following the instructions here: [https://nodejs.org/en/download/current/](https://nodejs.org/en/download/current/) 12 | 13 | Also install yarn: `brew install yarn` or [https://yarnpkg.com](https://yarnpkg.com). 14 | 15 | ## Installing `gluegun` 16 | 17 | Next, fork the repo [on Github](https://github.com/infinitered/gluegun) and clone down your repo. 18 | 19 | ```sh 20 | git clone git@github.com//gluegun 21 | ``` 22 | 23 | Install all the dependencies. 24 | 25 | ``` 26 | cd gluegun 27 | yarn 28 | ``` 29 | 30 | Gluegun's source files are mostly in `./src` and are written in [TypeScript](www.typescriptlang.org). Documentation lives in `/docs`. 31 | 32 | ## Running Tests And Linting 33 | 34 | On macOS or Linux: 35 | 36 | ```sh 37 | yarn test 38 | yarn lint 39 | yarn watch 40 | yarn integration 41 | ``` 42 | 43 | On windows: 44 | 45 | ```sh 46 | yarn lint 47 | yarn windows:test 48 | ``` 49 | 50 | ## Features & Fixes 51 | 52 | ```sh 53 | git branch feature/fun 54 | # furious typing 55 | yarn test 56 | yarn lint 57 | git commit -m "Adds fun" 58 | git push -u origin --HEAD 59 | ``` 60 | 61 | Passing tests and linting is required before we'll merge a pull request. If you need help with this, feel free to file an issue or chat with us on the [Infinite Red Community Slack](http://community.infinite.red). 62 | 63 | ## Submitting a Pull Request 64 | 65 | Jump on Github on your fork. Switch to the branch with your new changes, and 66 | open a PR against `master` of [infinitered/gluegun](https://github.com/infinitered/gluegun). 67 | 68 | Screenshots of what the feature is 💯. Animated gifs (suggested apps: licecap, Gif Brewery, or Kap) are 💯 + 🦄. 69 | 70 | Then submit the pull request. 71 | 72 | - It's okay to submit an issue before breaking changes or shenanigans to get a sense if it's cool 73 | - It's okay to submit PRs to start a discussion - just mark it 🚨🚨🚨 (or whatever) to let us know it's a conversation 74 | - It's okay to submit changes to PRs not yet merged, just make sure it's related to the PR 75 | - If Github is complaining about conflicts, rebase downstream, merge upstream 76 | 77 | ## Keeping up to date 78 | 79 | You want your fork's `master` to be the same as `gluegun/master`. 80 | 81 | ```sh 82 | # just once, run this to track our repo as `upstream` 83 | git remote add upstream https://github.com/infinitered/gluegun.git 84 | 85 | # then when you need to update 86 | git checkout master 87 | git pull upstream 88 | # if on your own branch 89 | git checkout 90 | git merge master 91 | 92 | # and here's where you'd create your branch 93 | git checkout -b feature/mybranch 94 | ``` 95 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | The fastest way to get started is to use the built-in Gluegun CLI (very meta!) to generate it. 4 | 5 | ## Creating a new Gluegun-powered CLI 6 | 7 | Gluegun works on macOS, Linux, and Windows 10. First, ensure you have Node installed and that you can access it (minimum version 7.6): 8 | 9 | ``` 10 | $ node --version 11 | ``` 12 | 13 | We will also be using [yarn](https://yarnpkg.com/) in this guide rather than `npm`. You can use `npm` if you want. 14 | 15 | Install `gluegun` globally. 16 | 17 | ``` 18 | $ yarn global add gluegun 19 | ``` 20 | 21 | Next, navigate to the folder you'd like to create your CLI in and generate it. 22 | 23 | ``` 24 | $ gluegun new mycli 25 | ``` 26 | 27 | Gluegun will ask if you want to use TypeScript or modern JavaScript: 28 | 29 | ``` 30 | ? Which language would you like to use? (Use arrow keys) 31 | TypeScript - Gives you a build pipeline out of the box (default) 32 | Modern JavaScript - Node 8.2+ and ES2016+ (https://node.green/) 33 | ``` 34 | 35 | You can also pass in `--typescript` or `--javascript` (or `-t` or `-j` for short) to bypass the prompt: 36 | 37 | ``` 38 | $ gluegun new mycli -t 39 | $ gluegun new mycli -j 40 | ``` 41 | 42 | _Note: We recommend TypeScript, but you don't have to use it! Gluegun works great with modern JavaScript._ 43 | 44 | ## Linking your CLI so you can access it 45 | 46 | Navigate to the new `mycli` folder and run `yarn link` to have it available globally on your command line. 47 | 48 | ``` 49 | $ cd mycli 50 | $ yarn link 51 | $ mycli --help 52 | ``` 53 | 54 | ## Creating your first command 55 | 56 | Your Gluegun-powered CLI isn't very useful without a command! In your CLI, create a new JS file in `src/commands` called `hello.js`. In that file, add this: 57 | 58 | ```js 59 | // src/commands/hello.js 60 | module.exports = { 61 | run: async toolbox => { 62 | toolbox.print.info('Hello, world!') 63 | }, 64 | } 65 | ``` 66 | 67 | For TypeScript, it's not much different: 68 | 69 | ```typescript 70 | // src/commands/hello.ts 71 | import { GluegunToolbox } from 'gluegun' 72 | module.exports = { 73 | run: async (toolbox: GluegunToolbox) => { 74 | toolbox.print.info('Hello, world!') 75 | }, 76 | } 77 | ``` 78 | 79 | Now run your command: 80 | 81 | ``` 82 | $ mycli hello 83 | Hello, world! 84 | ``` 85 | 86 | Yay! 87 | 88 | ## Creating your first extension 89 | 90 | You can add more tools into the `toolbox` for _all_ of your commands to use by creating an `extension`. In your `mycli` folder, add a new file in `src/extensions` called `hello-extension.js`. (It doesn't _have_ to end in `-extension`, but that's a convention.) 91 | 92 | ```js 93 | // src/extensions/hello-extension.js 94 | module.exports = async toolbox => { 95 | toolbox.hello = () => { 96 | toolbox.print.info('Hello from an extension!') 97 | } 98 | } 99 | ``` 100 | 101 | Or TypeScript: 102 | 103 | ```typescript 104 | // src/extensions/hello-extension.ts 105 | import { GluegunToolbox } from 'gluegun' 106 | module.exports = async (toolbox: GluegunToolbox) => { 107 | toolbox.hello = () => { 108 | toolbox.print.info('Hello from an extension!') 109 | } 110 | } 111 | ``` 112 | 113 | Then, in your `hello` command, use that function instead: 114 | 115 | ```js 116 | // src/commands/hello.js 117 | module.exports = { 118 | run: toolbox => { 119 | const { hello } = toolbox 120 | 121 | hello() 122 | }, 123 | } 124 | ``` 125 | 126 | When you run the command, this time it'll use the extension's output. 127 | 128 | ``` 129 | $ mycli hello 130 | Hello from an extension! 131 | ``` 132 | 133 | Note that we sometimes call the `toolbox` the `context` or the `RunContext`. That's just another word for the same thing. 134 | 135 | ## Next steps 136 | 137 | There are _many_ more tools in the toolbox than just `print.info`. You can generate from a template, manipulate files, hit API endpoints via HTTP, access parameters, run system commands, ask for user input, and much more. Explore the [API docs](./toolbox-api.md) in this folder to learn more or follow a tutorial like [Making a Movie CLI](./tutorial-making-a-movie-cli.md). 138 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Gluegun - A delightful toolkit for building Node-powered CLIs. 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /docs/releasing.md: -------------------------------------------------------------------------------- 1 | # Releasing Gluegun 2 | 3 | We now have CircleCI continuous integration and continous deployment set up! Thanks to Morgan Laco for setting that up. 4 | 5 | To release, just squash and merge a pull request and prefix with one of the following: 6 | 7 | - "fix: Some feature" - will release a patch version (aka 1.0.1) 8 | - "feat: Some feature" - will release a feature version (aka 1.1.0) 9 | - "feat: BREAKING CHANGE: Some breaking change" - will release a breaking version (aka 2.0.0) 10 | - "doc: Some docs" or "chore: Some chore" won't release 11 | -------------------------------------------------------------------------------- /docs/sniff.md: -------------------------------------------------------------------------------- 1 | # Sniff 2 | 3 | The `gluegun` requires a Node 7.6.0 environment which provides `async` and `await` support natively. 4 | 5 | You can safely check these requirements by using the `sniff` module. 6 | 7 | ```js 8 | const { ok } = require('gluegun/sniff') 9 | 10 | if (ok) { 11 | // we are clear for lift-off 12 | } 13 | ``` 14 | 15 | The `ok` property will be `true` if everything is good to go. 16 | 17 | `sniff` also has a few more properties you can use for better errors. 18 | 19 | | property | type | value | 20 | | ------------- | ------ | ------------------------------------------ | 21 | | ok | bool | `true` if everything is good to go | 22 | | isNewEnough | bool | `true` if we have Node.js >= 7.6.0 | 23 | | hasAsyncAwait | bool | `true` if we have `--harmony` enabled | 24 | | nodeVersion | string | the node version such as `'7.6.0'` | 25 | | nodeMinimum | string | the node minimum that sniff is looking for | 26 | 27 | These two properties will both be set to `true` if we're running in Node 7.6.0. 28 | -------------------------------------------------------------------------------- /docs/toolbox-api.md: -------------------------------------------------------------------------------- 1 | # Inside the Gluegun Toolbox 2 | 3 | Let's explore the inside of the famous Gluegun "Toolbox" (or "Context" as it's sometimes called). 4 | 5 | ```js 6 | module.exports = { 7 | name: 'dostuff', 8 | alias: 'd', 9 | run: async function (toolbox) { 10 | // great! now what? 11 | }, 12 | } 13 | ``` 14 | 15 | Here's what's available inside the `toolbox` object you see all over Gluegun. 16 | 17 | | name | provides the... | 3rd party | 18 | | ------------------ | -------------------------------------------------- | ------------------------------ | 19 | | **meta** | information about the currently running CLI | | 20 | | **config** | configuration options from the app or plugin | | 21 | | **filesystem** | ability to copy, move & delete files & directories | fs-jetpack | 22 | | **http** | ability to talk to the web | apisauce | 23 | | **parameters** | command line arguments and options | yargs-parser | 24 | | **patching** | manipulating file contents easily | fs-jetpack | 25 | | **print** | tools to print output to the command line | colors, ora | 26 | | **prompt** | tools to acquire extra command line user input | enquirer | 27 | | **semver** | utilities for working with semantic versioning | semver | 28 | | **strings** | some string helpers like case conversion, etc. | lodash | 29 | | **system** | ability to execute | node-which, execa, cross-spawn | 30 | | **template** | code generation from templates | ejs | 31 | | **packageManager** | ability to add or remove packages with Yarn/NPM | | 32 | 33 | The `toolbox` has "drawers" full of useful tools for building CLIs. For example, the `toolbox.meta.version` function can be invoked like this: 34 | 35 | ```js 36 | module.exports = { 37 | name: 'dostuff', 38 | alias: 'd', 39 | run: async function (toolbox) { 40 | // use them like this... 41 | toolbox.print.info(toolbox.meta.version()) 42 | 43 | // or destructure! 44 | const { 45 | print: { info }, 46 | meta: { version }, 47 | } = toolbox 48 | info(version()) 49 | }, 50 | } 51 | ``` 52 | 53 | To learn more about each tool, explore the rest of the `toolbox-*.md` files in this folder. 54 | 55 | ## Accessing Tools Directly 56 | 57 | You can access almost all of Gluegun's toolbox tools without running a command. This is useful when you'd like to use these tools outside of a CLI context or when doing some really specialized CLI. 58 | 59 | ```js 60 | const { print, filesystem, strings } = require('gluegun') 61 | // or 62 | const { print } = require('gluegun/print') 63 | const { filesystem } = require('gluegun/filesystem') 64 | const { strings } = require('gluegun/strings') 65 | const { packageManager } = require('gluegun/package-manager') 66 | 67 | print.info(`Hey, I'm Gluegun!`) 68 | filesystem.dir('/tmp/jamon') 69 | print.error(strings.isBlank('')) 70 | ``` 71 | -------------------------------------------------------------------------------- /docs/toolbox-config.md: -------------------------------------------------------------------------------- 1 | You can have your plugin authors configure the behavior of your CLI by providing a configuration file in the root of any plugin. You can also provide one in the root level of the main CLI. 2 | 3 | This is an object. Each plugin will have its own root level key. 4 | 5 | In `movies.config.js`: 6 | 7 | ```js 8 | module.exports = { 9 | name: 'movies', 10 | defaults: { 11 | movie: { 12 | cache: '~/.movies/cache', 13 | }, 14 | another: { 15 | count: 100, 16 | }, 17 | }, 18 | } 19 | ``` 20 | 21 | In `movies-imdb.config.js`: 22 | 23 | ```js 24 | module.exports = { 25 | name: 'movies-imdb', 26 | defaults: { 27 | fun: true, 28 | level: 10, 29 | }, 30 | } 31 | ``` 32 | 33 | It takes the plugin's defaults, and merges the user's changes overtop. 34 | 35 | ```js 36 | module.exports = async toolbox => { 37 | toolbox.config.movies // { fun: true, level: 10 } 38 | } 39 | ``` 40 | 41 | If you'd like to load your own config files, use the `loadConfig` function included in the config object which is powered by [cosmiconfig](https://github.com/davidtheclark/cosmiconfig): 42 | 43 | ```js 44 | module.exports = { 45 | run: async toolbox => { 46 | const { 47 | config: { loadConfig }, 48 | print: { info }, 49 | runtime: { brand }, 50 | } = toolbox 51 | 52 | // use cosmiconfig directly: brand (string) & directory (string) 53 | const myConfig = loadConfig(brand, process.cwd()) 54 | // or 55 | const myConfig = loadConfig('movie', '~/.myconfig/') 56 | 57 | // now access myConfig 58 | info(myConfig.shirtSize) 59 | 60 | // if you want to load multiple configs and have them override: 61 | const currentFolder = process.cwd() 62 | const myConfig = { 63 | ...loadConfig('movies', '~/.myconfig/'), 64 | ...loadConfig('movies', '~/configurations/myconfig/'), 65 | ...loadConfig('movies', currentFolder), 66 | } 67 | }, 68 | } 69 | ``` 70 | 71 | By default, Cosmiconfig will start where you tell it to start and search up the directory tree for the following: 72 | 73 | - a package.json property 74 | - a JSON or YAML, extensionless "rc file" 75 | - an "rc file" with the extensions .json, .yaml, .yml, or .js. 76 | - a .config.js CommonJS module 77 | 78 | For example, if your module's name is "soursocks", cosmiconfig will search up the directory tree for configuration in the following places: 79 | 80 | - a soursocks property in package.json 81 | - a .soursocksrc file in JSON or YAML format 82 | - a .soursocksrc.json file 83 | - a .soursocksrc.yaml, .soursocksrc.yml, or .soursocksrc.js file 84 | - a soursocks.config.js file exporting a JS object 85 | 86 | Cosmiconfig continues to search up the directory tree, checking each of these places in each directory, until it finds some acceptable configuration (or hits the home directory). 87 | -------------------------------------------------------------------------------- /docs/toolbox-filesystem.md: -------------------------------------------------------------------------------- 1 | A set of functions & values to work with files and directories. The majority of these functions come 2 | straight from [fs-jetpack](https://github.com/szwacz/fs-jetpack), a fantastic API for working with the 3 | file system. All jetpack-based functions have an equivalent `*Async` version if you need it. 4 | 5 | You can access these tools on the Gluegun toolbox, via `const { filesystem } = require('gluegun')`, or directly via `const { filesystem } = require('gluegun/filesystem')`. 6 | 7 | ## separator 8 | 9 | This value is the path separator `\` or `/` depending on the OS. 10 | 11 | ```js 12 | toolbox.filesystem.separator // '/' on posix but '\' on windows 13 | ``` 14 | 15 | ## eol 16 | 17 | This value is the end of line byte sequence. 18 | 19 | ```js 20 | toolbox.filesystem.eol // '\n' on posix but '\r\n' on windows 21 | ``` 22 | 23 | ## homedir 24 | 25 | This function retrieves the path to the home directory. 26 | 27 | ```js 28 | toolbox.filesystem.homedir() // '/Users/jh' on my macOS machine 29 | ``` 30 | 31 | ## subdirectories 32 | 33 | Finds the immediate subdirectories in a given directory. 34 | 35 | ```js 36 | toolbox.filesystem.subdirectories(`~/Desktop`) // [] 37 | ``` 38 | 39 | ## append 40 | 41 | [Appends](https://github.com/szwacz/fs-jetpack#appendpath-data-options) data to the end of a file. 42 | 43 | ## chmodSync 44 | 45 | Changes directory ownership. See more in the [fs documentation](https://nodejs.org/api/fs.html#fs_fs_chmodsync_path_mode). 46 | 47 | ## copy 48 | 49 | [Copies](https://github.com/szwacz/fs-jetpack#copyfrom-to-options) a file or a directory. 50 | 51 | ## cwd 52 | 53 | Gets the [current working directory](https://github.com/szwacz/fs-jetpack#createreadstreampath-options). 54 | 55 | ## dir 56 | 57 | [Ensures a directory exists](https://github.com/szwacz/fs-jetpack#dirpath-criteria) and creates a new jetpack 58 | instance with it's `cwd` pointing there. 59 | 60 | ## exists 61 | 62 | Checks to see if file or directory [exists](https://github.com/szwacz/fs-jetpack#existspath). 63 | 64 | ## file 65 | 66 | [Ensures a file exists](https://github.com/szwacz/fs-jetpack#filepath-criteria). 67 | 68 | ## find 69 | 70 | [Finds](https://github.com/szwacz/fs-jetpack#findpath-searchoptions) files or directories. 71 | 72 | ## inspect 73 | 74 | [Grabs information](https://github.com/szwacz/fs-jetpack#inspectpath-options) about a file or directory. 75 | 76 | ## inspectTree 77 | 78 | [Grabs nested information](https://github.com/szwacz/fs-jetpack#inspecttreepath-options) about a set of files or directories. 79 | 80 | ## list 81 | 82 | [Gets a directory listing](https://github.com/szwacz/fs-jetpack#listpath), like `ls`. 83 | 84 | ## move 85 | 86 | [Moves](https://github.com/szwacz/fs-jetpack#movefrom-to) files and directories. 87 | 88 | ## path 89 | 90 | [Grabs path parts](https://github.com/szwacz/fs-jetpack#pathparts) as a string. 91 | 92 | ## read 93 | 94 | [Reads](https://github.com/szwacz/fs-jetpack#readpath-returnas) the contents of a file as a string or JSON. 95 | 96 | ## remove 97 | 98 | [Deletes](https://github.com/szwacz/fs-jetpack#removepath) a file or directory. 99 | 100 | ## rename 101 | 102 | [Renames](https://github.com/szwacz/fs-jetpack#renamepath-newname) a file or directory. 103 | 104 | ## resolve 105 | 106 | [Resolves](https://nodejs.org/docs/latest/api/path.html#path_path_resolve_paths) a sequence of paths or path segments into an absolute path. 107 | 108 | ## symlink 109 | 110 | [Makes a symbolic link](https://github.com/szwacz/fs-jetpack#symlinksymlinkvalue-path) to a file or directory. 111 | 112 | ## write 113 | 114 | [Writes](https://github.com/szwacz/fs-jetpack#writepath-data-options) data to a file. 115 | -------------------------------------------------------------------------------- /docs/toolbox-http.md: -------------------------------------------------------------------------------- 1 | Gives you the ability to talk to HTTP(s) web and API servers using [apisauce](https://github.com/skellock/apisauce) which 2 | is a thin wrapper around [axios](https://github.com/mzabriskie/axios). 3 | 4 | You can access these tools on the Gluegun toolbox, via `const { http } = require('gluegun')`, or directly via `const { http } = require('gluegun/http')`. 5 | 6 | ## create 7 | 8 | This creates an `apisauce` client. It takes 1 parameter called `options` which is an object. 9 | 10 | ```js 11 | const api = toolbox.http.create({ 12 | baseURL: 'https://api.github.com', 13 | headers: { Accept: 'application/vnd.github.v3+json' }, 14 | }) 15 | ``` 16 | 17 | Once you have this api object, you can then call `HTTP` verbs on it. All verbs are `async` so don't forget your `await` call. 18 | 19 | ```js 20 | // GET 21 | const { ok, data } = await api.get('/repos/skellock/apisauce/commits') 22 | 23 | // and others 24 | api.get('/repos/skellock/apisauce/commits') 25 | api.head('/me') 26 | api.delete('/users/69') 27 | api.post('/todos', {note: 'jump around'}, {headers: {'x-ray': 'machine'}}) 28 | api.patch('/servers/1', {live: false}) 29 | api.put('/servers/1', {live: true}) 30 | ``` 31 | -------------------------------------------------------------------------------- /docs/toolbox-meta.md: -------------------------------------------------------------------------------- 1 | Provides functions for accessing information about the currently running CLI. You can access this on the Gluegun toolbox. 2 | 3 | ## src 4 | 5 | The currently running CLI's source folder. 6 | 7 | ```js 8 | toolbox.meta.src // "/Users/jh/Code/gluegun" 9 | ``` 10 | 11 | ## version 12 | 13 | Retrieves the currently running CLI's version. 14 | 15 | ```js 16 | toolbox.meta.version() // '1.0.0' 17 | ``` 18 | 19 | ## packageJSON 20 | 21 | Retrieves the currently running CLI's package.json contents as an object. 22 | 23 | ```js 24 | toolbox.meta.packageJSON() 25 | // { name: 'gluegun', version: '9.4.2', ... } 26 | ``` 27 | 28 | ## checkForUpdate 29 | 30 | Async function that checks NPM to see if there's an update to the currently running CLI. 31 | 32 | ```js 33 | const newVersion = await toolbox.meta.checkForUpdate() 34 | // false (if none exists) 35 | // '9.4.3' (new version if exists) 36 | if (newVersion) { 37 | toolbox.print.info(`New version available: ${newVersion})`) 38 | } 39 | ``` 40 | 41 | ## commandInfo 42 | 43 | Retrieves information about all of this CLI's commands. You can use this to display a custom help screen, for example. 44 | 45 | ```js 46 | const commandInfo = toolbox.meta.commandInfo() 47 | toolbox.print.table(commandInfo) 48 | ``` 49 | 50 | ## onAbort 51 | 52 | Executes the given callback when a [termination signal](https://nodejs.org/api/process.html#process_signal_events) is received. These signals are `SIGINT`, `SIGQUIT`, `SIGTERM`, `SIGHUP`, `SIGBREAK`. If callback returns a promise, it will wait for promise to resolve before aborting. 53 | 54 | ```js 55 | toolbox.meta.onAbort((signal) => { 56 | console.log('Received termination signal', signal) 57 | }) 58 | ``` 59 | -------------------------------------------------------------------------------- /docs/toolbox-package-manager.md: -------------------------------------------------------------------------------- 1 | Provides an API for intelligently running commands in yarn or npm depending on which is installed. 2 | 3 | ## hasYarn 4 | 5 | Whether the current system has yarn installed 6 | 7 | ```js 8 | toolbox.packageManager.hasYarn() // true 9 | ``` 10 | 11 | ## add (async) 12 | 13 | Adds a package using yarn or npm 14 | 15 | ```js 16 | await toolbox.packageManager.add('infinite_red', { 17 | dev: true, 18 | dryRun: false, 19 | force: 'npm', //remove this to have the system determine which 20 | }) 21 | ``` 22 | 23 | Will return an object similar to the following: 24 | 25 | ```js 26 | { 27 | success: true, 28 | command: 'npm install --save-dev infinite_red', 29 | stdout: '' 30 | } 31 | ``` 32 | 33 | You can also use an array with the package names you want to install to add it all at once. 34 | 35 | ```js 36 | await toolbox.packageManager.add(['infinite_red', 'infinite_blue'], { 37 | dev: true, 38 | dryRun: false, 39 | ``` 40 | 41 | ## remove (async) 42 | 43 | Removes a package using yarn or npm 44 | 45 | ```js 46 | await toolbox.packageManager.remove('infinite_red', { 47 | dryRun: false, 48 | force: 'npm', //remove this to have the system determine which 49 | }) 50 | ``` 51 | 52 | Like `add` function, you can also use an array to remove multiple packages. 53 | 54 | ```js 55 | await toolbox.packageManager.remove(['infinite_red', 'infinite_blue'], { 56 | dryRun: false, 57 | }) 58 | ``` 59 | -------------------------------------------------------------------------------- /docs/toolbox-parameters.md: -------------------------------------------------------------------------------- 1 | Information about how the command was invoked. You can access this on the Gluegun toolbox. Check out this example of creating a new Reactotron plugin. 2 | 3 | ```sh 4 | gluegun reactotron plugin MyAwesomePlugin full --comments --lint standard 5 | ``` 6 | 7 | | name | type | purpose | from the example above | 8 | | ----------- | ------ | --------------------------------- | ------------------------------------ | 9 | | **plugin** | string | the plugin used | `'reactotron'` | 10 | | **command** | string | the command used | `'plugin'` | 11 | | **string** | string | the command arguments as a string | `'MyAwesomePlugin full'` | 12 | | **array** | array | the command arguments as an array | `['MyAwesomePlugin', 'full']` | 13 | | **first** | string | the 1st argument | `'MyAwesomePlugin'` | 14 | | **second** | string | the 2nd argument | `'full'` | 15 | | **third** | string | the 3rd argument | `undefined` | 16 | | **options** | object | command line options | `{comments: true, lint: 'standard'}` | 17 | | **argv** | object | raw argv | | 18 | 19 | ## options 20 | 21 | Options are the command line flags. Always exists however it may be empty. 22 | 23 | ```sh 24 | gluegun say hello --loud -v --wave furiously 25 | ``` 26 | 27 | ```js 28 | module.exports = async function(toolbox) { 29 | toolbox.parameters.options // { loud: true, v: true, wave: 'furiously' } 30 | } 31 | ``` 32 | 33 | ## string 34 | 35 | Everything else after the command as a string. 36 | 37 | ```sh 38 | gluegun say hello there 39 | ``` 40 | 41 | ```js 42 | module.exports = async function(toolbox) { 43 | toolbox.parameters.string // 'hello there' 44 | } 45 | ``` 46 | 47 | ## array 48 | 49 | Everything else after the command, but as an array. 50 | 51 | ```sh 52 | gluegun reactotron plugin full 53 | ``` 54 | 55 | ```js 56 | module.exports = async function(toolbox) { 57 | toolbox.parameters.array // ['plugin', 'full'] 58 | } 59 | ``` 60 | 61 | ## first / .second / .third 62 | 63 | The first, second, and third element in `array`. It is provided as a shortcut, and there isn't one, 64 | this will be `undefined`. 65 | 66 | ```sh 67 | gluegun reactotron plugin full 68 | ``` 69 | 70 | ```js 71 | module.exports = async function(toolbox) { 72 | toolbox.parameters.first // 'plugin' 73 | toolbox.parameters.second // 'full' 74 | toolbox.parameters.third // undefined 75 | } 76 | ``` 77 | -------------------------------------------------------------------------------- /docs/toolbox-patching.md: -------------------------------------------------------------------------------- 1 | Tools to help adjust the contents of text files. 2 | 3 | You can access these tools on the Gluegun toolbox, via `const { patching } = require('gluegun')`, or directly via `const { patching } = require('gluegun/patching')`. 4 | 5 | ## exists 6 | 7 | > This is an **async** function. 8 | 9 | Reads in a file and checks whether it's content matches a string or regular expression. 10 | 11 | ```js 12 | // Case sensitive string match 13 | const barbExists = await toolbox.patching.exists('config.txt', 'Barb') 14 | 15 | // Short form regex 16 | const barbExists = await toolbox.patching.exists('config.txt', /Barb/) 17 | 18 | // Regex Object 19 | const barbExists = await toolbox.patching.exists('config.txt', new Regex(/Barb/, 'i')) 20 | ``` 21 | 22 | ## update 23 | 24 | > This is an **async** function. 25 | 26 | Updates a given file by reading it in and then taking the result of the provided callback and writing it back to the config file. 27 | 28 | If the file ends in `.json`, it'll be read in as an object. Return the updated object to have it written back to the config. 29 | 30 | If the file doesn't end in `.json`, you'll receive a string. Return an updated string to write back to the file. 31 | 32 | ```js 33 | await toolbox.patching.update('config.json', config => { 34 | config.key = 'new value' 35 | return config 36 | }) 37 | 38 | await toolbox.patching.update('config.txt', data => { 39 | return data.replace('Jamon', 'Boss') 40 | }) 41 | ``` 42 | 43 | ## append 44 | 45 | > This is an **async** function. 46 | 47 | Appends a string to the given file. 48 | 49 | ```js 50 | await toolbox.patching.append('config.txt', 'Append this string\n') 51 | ``` 52 | 53 | ## prepend 54 | 55 | > This is an **async** function. 56 | 57 | Prepends a string to the given file. 58 | 59 | ```js 60 | await toolbox.patching.prepend('config.txt', 'Prepend this string\n') 61 | ``` 62 | 63 | ## replace 64 | 65 | > This is an **async** function. 66 | 67 | Replaces a string in a given file. 68 | 69 | ```js 70 | await toolbox.patching.replace('config.txt', 'Remove this string\n', 'Replace with this string\n') 71 | ``` 72 | 73 | ## patch 74 | 75 | > This is an **async** function. 76 | 77 | Allows inserting next to, deleting, and replacing strings or regular expression in a given file. If `insert` is already present in the file, it won't change the file, unless you also pass through `force: true`. 78 | 79 | ```js 80 | await toolbox.patching.patch('config.txt', { insert: 'Jamon', before: 'Something else' }) 81 | await toolbox.patching.patch('config.txt', { insert: 'Jamon', after: 'Something else' }) 82 | await toolbox.patching.patch('config.txt', { insert: 'Jamon', replace: 'Something else' }) 83 | await toolbox.patching.patch('config.txt', { insert: 'Jamon', replace: 'Something else', force: true }) 84 | await toolbox.patching.patch('config.txt', { delete: 'Something' }) 85 | await toolbox.patching.patch('config.txt', { insert: 'Jamon', after: new RegExp('some regexp') }) 86 | await toolbox.patching.patch( 87 | 'config.txt', 88 | { insert: 'Jamon', after: 'Something else' }, 89 | { insert: 'Jamon', before: 'Something else' }, 90 | ) 91 | ``` 92 | -------------------------------------------------------------------------------- /docs/toolbox-semver.md: -------------------------------------------------------------------------------- 1 | A set of functions & values to work with semantic versions. The majority of these functions come 2 | straight from [semver](https://github.com/npm/node-semver) 3 | 4 | You can access these tools on the Gluegun toolbox, via `const { semver } = require('gluegun')`, or directly via `const { semver } = require('gluegun/semver')`. 5 | 6 | ## Usage 7 | 8 | All your common semver needs are accessible. 9 | 10 | ```js 11 | // Step 1: grab from toolbox 12 | const semver = { toolbox } 13 | 14 | // Step 2: start using 15 | semver.valid('1.2.3') // '1.2.3' 16 | semver.valid('a.b.c') // null 17 | semver.clean(' =v1.2.3 ') // '1.2.3' 18 | semver.satisfies('1.2.3', '1.x || >=2.5.0 || 5.0.0 - 7.2.3') // true 19 | semver.gt('1.2.3', '9.8.7') // false 20 | semver.lt('1.2.3', '9.8.7') // true 21 | ``` 22 | -------------------------------------------------------------------------------- /docs/toolbox-system.md: -------------------------------------------------------------------------------- 1 | Provides access to shell and OS processes. 2 | 3 | You can access these tools on the Gluegun toolbox, via `const { system } = require('gluegun')`, or directly via `const { system } = require('gluegun/system')`. 4 | 5 | ## run 6 | 7 | > This is an **async** function. 8 | 9 | Runs a shell command and returns the output as a string. 10 | 11 | The first parameter `commandLine` is the shell command to run. It can have pipes! The 12 | second parameter is `options`, object. This is a promise wrapped around node.js `child-process.exec` 13 | [api call](https://nodejs.org/api/child_process.html#child_process_child_process_exec_command_options_callback). 14 | 15 | You can also pass `trim: true` inside the options parameter to have the output automatically trim all 16 | starting and trailing spaces. 17 | 18 | Should the process fail, an `error` will be thrown with properties such as: 19 | 20 | | property | type | purpose | 21 | | -------- | ------ | ----------------------------------------------------- | 22 | | code | number | the exit code | 23 | | cmd | string | the command we asked to run | 24 | | stdout | string | any information the process wrote to `stdout` | 25 | | stderr | string | any information the process wrote to `stderr` | 26 | | killed | bool | if the process was killed or not | 27 | | signal | number | the signal number used to off the process (if killed) | 28 | 29 | ```js 30 | const nodeVersion = toolbox.system.run('node -v', { trim: true }) 31 | ``` 32 | 33 | ### toolbox.system.which 34 | 35 | Returns the full path to a command on your system if located on your path. 36 | 37 | ```js 38 | const whereIsIt = toolbox.system.which('npm') 39 | ``` 40 | 41 | ### toolbox.system.open 42 | 43 | :( 44 | 45 | ### toolbox.system.startTimer 46 | 47 | Starts a timer for... well... timing stuff. `startTimer()` returns a function. When this is called, the number of milliseconds will be returned. 48 | 49 | ```js 50 | const timer = toolbox.system.startTimer() 51 | 52 | // time passes... 53 | console.log(`that just took ${timer()} ms.`) 54 | ``` 55 | 56 | Caveat: Due to the event loop scheduler in Node.JS, they don't guarantee millisecond accuracy when invoking async functions. For that reason, expect a up to a 4ms overage. 57 | 58 | Note that this lag doesn't apply to synchronous code. 59 | -------------------------------------------------------------------------------- /docs/toolbox-template.md: -------------------------------------------------------------------------------- 1 | Features for generating files based on a template. You can access these tools on the Gluegun toolbox. 2 | 3 | ## generate 4 | 5 | > This is an **async** function. 6 | 7 | Generates a new file based on a template. 8 | 9 | #### example 10 | 11 | ```js 12 | module.exports = async function(toolbox) { 13 | const name = toolbox.parameters.first 14 | 15 | await toolbox.template.generate({ 16 | template: 'component.ejf', 17 | target: `app/components/${name}-view.js`, 18 | props: { name }, 19 | }) 20 | } 21 | ``` 22 | 23 | In the EJS template, you will use the props object to get the data defined previously. 24 | 25 | ```ejs 26 | <%= props.name %> 27 | ``` 28 | 29 | Note: `generate()` will always overwrite the target if given. Make sure to prompt your users if that's 30 | the behaviour you're after. 31 | 32 | | option | type | purpose | notes | 33 | | ----------- | ------ | ------------------------------------ | -------------------------------------------- | 34 | | `template` | string | path to the EJS template | relative from plugin's `templates` directory | 35 | | `target` | string | path to create the file | relative from user's working directory | 36 | | `props` | object | more data to render in your template | | 37 | | `directory` | string | where to find templates | an absolute path (optional) | 38 | 39 | `generate()` returns the string that was generated in case you didn't want to render to a target. 40 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "timezone": "America/Los_Angeles", 6 | "schedule": "12th of every month" 7 | } 8 | -------------------------------------------------------------------------------- /sniff-async.js: -------------------------------------------------------------------------------- 1 | /** 2 | * When we require this file, it'll blow up if we don't support async/await 3 | */ 4 | export default async function() { 5 | await new Promise(resolve => resolve()) 6 | } 7 | -------------------------------------------------------------------------------- /sniff.js: -------------------------------------------------------------------------------- 1 | // check the node version 2 | 3 | const nodeMinimum = '7.6.0' 4 | const nodeVersion = process.version.replace('v', '') 5 | const ver = nodeVersion.split('.').map(Number) 6 | const isNewEnough = ver[0] > 7 || (ver[0] >= 7 && ver[1] >= 6) 7 | let hasAsyncAwait = false 8 | let ok = false 9 | 10 | // check for async/await features, but only if below Node 8 11 | if (ver[0] >= 8) { 12 | hasAsyncAwait = true 13 | } else { 14 | try { 15 | require('./sniff-async') 16 | hasAsyncAwait = true 17 | } catch (e) {} 18 | } 19 | 20 | ok = hasAsyncAwait && isNewEnough 21 | 22 | module.exports = { 23 | nodeMinimum, 24 | nodeVersion, 25 | isNewEnough, 26 | hasAsyncAwait, 27 | ok, 28 | } 29 | -------------------------------------------------------------------------------- /src/cli/cli.ts: -------------------------------------------------------------------------------- 1 | import { build, GluegunToolbox } from '../index' 2 | 3 | /** 4 | * Create the cli and kick it off 5 | */ 6 | export async function run(argv?: string[] | string): Promise { 7 | // create a CLI runtime 8 | const gluegunCLI = build('gluegun') 9 | .src(__dirname) 10 | .help() 11 | .version() 12 | .defaultCommand({ 13 | run: async (toolbox: GluegunToolbox) => { 14 | const { print, meta } = toolbox 15 | print.info(`Gluegun version ${meta.version()}`) 16 | print.info(``) 17 | print.info(` Type gluegun --help for more info`) 18 | }, 19 | }) 20 | .exclude(['http', 'patching']) 21 | .checkForUpdates(25) 22 | .create() 23 | 24 | // and run it 25 | const toolbox = await gluegunCLI.run(argv) 26 | 27 | // send it back (for testing, mostly) 28 | return toolbox 29 | } 30 | -------------------------------------------------------------------------------- /src/cli/commands/kitchen.ts: -------------------------------------------------------------------------------- 1 | import { prompt } from '../../toolbox/prompt-tools' 2 | import { GluegunToolbox } from '../../domain/toolbox' 3 | 4 | module.exports = { 5 | name: 'kitchen', 6 | description: 'Runs through a kitchen sink of Gluegun tools', 7 | run: async (toolbox: GluegunToolbox) => { 8 | const { print } = toolbox 9 | 10 | const update = await toolbox.meta.checkForUpdate() 11 | print.info(`Checking for update: ${update}`) 12 | 13 | const result = await prompt.ask([ 14 | { 15 | type: 'list', 16 | name: 'exlist', 17 | message: 'What shoes are you wearing?', 18 | choices: ['Clown', 'Other'], 19 | }, 20 | { 21 | type: 'confirm', 22 | name: 'exconfirm', 23 | message: 'Are you sure?', 24 | }, 25 | { 26 | type: 'select', 27 | name: 'exselect', 28 | message: 'What is your favorite team?', 29 | choices: ['Jazz', 'Trail Blazers', 'Lakers', 'Warriors'], 30 | }, 31 | { 32 | type: 'multiselect', 33 | name: 'exmultiselect', 34 | message: 'What are your favorite months?', 35 | choices: ['January', 'July', 'September', 'November'], 36 | }, 37 | { 38 | type: 'password', 39 | name: 'expassword', 40 | message: 'Enter a fake password', 41 | }, 42 | { 43 | type: 'input', 44 | name: 'exinput', 45 | message: 'What is your middle name?', 46 | }, 47 | { 48 | type: 'autocomplete', 49 | name: 'exautocomplete', 50 | message: 'State?', 51 | choices: ['Oregon', 'Washington', 'California'], 52 | // You can leave this off unless you want to customize behavior 53 | suggest(s, choices) { 54 | return choices.filter((choice) => { 55 | return choice.message.toLowerCase().startsWith(s.toLowerCase()) 56 | }) 57 | }, 58 | }, 59 | ]) 60 | 61 | print.debug(result) 62 | }, 63 | } 64 | -------------------------------------------------------------------------------- /src/cli/extensions/cli-extension.ts: -------------------------------------------------------------------------------- 1 | import { chmodSync } from 'fs' 2 | import { resolve } from 'path' 3 | import { GluegunToolbox } from '../../domain/toolbox' 4 | 5 | export default (toolbox: GluegunToolbox) => { 6 | toolbox.filesystem.resolve = resolve 7 | toolbox.filesystem.chmodSync = chmodSync 8 | } 9 | -------------------------------------------------------------------------------- /src/cli/templates/cli/.eslintrc.js.ejs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import("eslint").Linter.Config} 3 | */ 4 | module.exports = { 5 | <% if (props.language === "typescript") { %> 6 | parser: '@typescript-eslint/parser', 7 | <% } %> 8 | parserOptions: { 9 | ecmaVersion: 2020, 10 | sourceType: 'module' 11 | }, 12 | extends: [ 13 | <% if (props.language === "typescript") { %> 14 | 'plugin:@typescript-eslint/recommended', 15 | <% } %> 16 | 'prettier', 17 | 'plugin:prettier/recommended' 18 | ], 19 | rules: {} 20 | } 21 | -------------------------------------------------------------------------------- /src/cli/templates/cli/.gitignore.ejs: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | npm-debug.log 4 | coverage 5 | .nyc_output 6 | dist 7 | build 8 | .vscode 9 | -------------------------------------------------------------------------------- /src/cli/templates/cli/LICENSE.ejs: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 <%= props.author %> 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 | -------------------------------------------------------------------------------- /src/cli/templates/cli/__tests__/cli-integration.test.js.ejs: -------------------------------------------------------------------------------- 1 | <% if (props.language === "typescript") { %> 2 | import { system, filesystem } from 'gluegun' 3 | <% } else { %> 4 | const { system, filesystem } = require('gluegun') 5 | <% } %> 6 | 7 | const src = filesystem.path(__dirname, '..') 8 | 9 | const cli = async cmd => system.run('node ' + filesystem.path(src, 'bin', '<%= props.name %>') + ` ${cmd}`) 10 | 11 | test('outputs version', async () => { 12 | const output = await cli('--version') 13 | expect(output).toContain('0.0.1') 14 | }) 15 | 16 | test('outputs help', async () => { 17 | const output = await cli('--help') 18 | expect(output).toContain('0.0.1') 19 | }) 20 | 21 | test('generates file', async () => { 22 | const output = await cli('generate foo') 23 | <% if (props.language === "typescript") { %> 24 | expect(output).toContain('Generated file at models/foo-model.ts') 25 | const foomodel = filesystem.read('models/foo-model.ts') 26 | <% } else { %> 27 | expect(output).toContain('Generated file at models/foo-model.js') 28 | const foomodel = filesystem.read('models/foo-model.js') 29 | <% } %> 30 | 31 | expect(foomodel).toContain(`module.exports = {`) 32 | expect(foomodel).toContain(`name: 'foo'`) 33 | 34 | // cleanup artifact 35 | filesystem.remove('models') 36 | }) 37 | -------------------------------------------------------------------------------- /src/cli/templates/cli/bin/cli-executable.ejs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | <% if (props.language === "typescript") { %> 4 | /* tslint:disable */ 5 | // check if we're running in dev mode 6 | var devMode = require('fs').existsSync(`${__dirname}/../src`) 7 | // or want to "force" running the compiled version with --compiled-build 8 | var wantsCompiled = process.argv.indexOf('--compiled-build') >= 0 9 | 10 | if (wantsCompiled || !devMode) { 11 | // this runs from the compiled javascript source 12 | require(`${__dirname}/../build/cli`).run(process.argv) 13 | } else { 14 | // this runs from the typescript source (for dev only) 15 | // hook into ts-node so we can run typescript on the fly 16 | require('ts-node').register({ project: `${__dirname}/../tsconfig.json` }) 17 | // run the CLI with the current process arguments 18 | require(`${__dirname}/../src/cli`).run(process.argv) 19 | } 20 | 21 | <% } else { %> 22 | // run the CLI with the current process arguments 23 | require('../src/cli').run(process.argv) 24 | 25 | <% } %> 26 | -------------------------------------------------------------------------------- /src/cli/templates/cli/docs/commands.md.ejs: -------------------------------------------------------------------------------- 1 | # Command Reference for <%= props.name %> 2 | 3 | TODO: Add your command reference here 4 | -------------------------------------------------------------------------------- /src/cli/templates/cli/docs/plugins.md.ejs: -------------------------------------------------------------------------------- 1 | # Plugin guide for <%= props.name %> 2 | 3 | Plugins allow you to add features to <%= props.name %>, such as commands and 4 | extensions to the `toolbox` object that provides the majority of the functionality 5 | used by <%= props.name %>. 6 | 7 | Creating a <%= props.name %> plugin is easy. Just create a repo with two folders: 8 | 9 | ``` 10 | commands/ 11 | extensions/ 12 | ``` 13 | 14 | A command is a file that looks something like this: 15 | 16 | ```js 17 | // commands/foo.js 18 | 19 | module.exports = { 20 | run: (toolbox) => { 21 | const { print, filesystem } = toolbox 22 | 23 | const desktopDirectories = filesystem.subdirectories(`~/Desktop`) 24 | print.info(desktopDirectories) 25 | } 26 | } 27 | ``` 28 | 29 | An extension lets you add additional features to the `toolbox`. 30 | 31 | ```js 32 | // extensions/bar-extension.js 33 | 34 | module.exports = (toolbox) => { 35 | const { print } = toolbox 36 | 37 | toolbox.bar = () => { print.info('Bar!') } 38 | } 39 | ``` 40 | 41 | This is then accessible in your plugin's commands as `toolbox.bar`. 42 | 43 | # Loading a plugin 44 | 45 | To load a particular plugin (which has to start with `<%= props.name %>-*`), 46 | install it to your project using `npm install --save-dev <%= props.name %>-PLUGINNAME`, 47 | and <%= props.name %> will pick it up automatically. 48 | -------------------------------------------------------------------------------- /src/cli/templates/cli/package.json.ejs: -------------------------------------------------------------------------------- 1 | { 2 | "name": "<%= props.name %>", 3 | "version": "0.0.1", 4 | "description": "<%= props.name %> CLI", 5 | "private": true, 6 | <% if (props.language === "typescript") { %> 7 | "types": "build/types/types.d.ts", 8 | <% } %> 9 | "bin": { 10 | "<%= props.name %>": "bin/<%= props.name %>" 11 | }, 12 | "scripts": { 13 | <% if (props.language === "typescript") { %> 14 | "clean-build": "rimraf -rf ./build", 15 | "compile": "tsc -p .", 16 | "copy-templates": "copyfiles ./src/templates/* ./build/templates", 17 | "build": "yarn clean-build && yarn compile && yarn copy-templates", 18 | "prepublishOnly": "yarn build", 19 | "format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write", 20 | <% } else { %> 21 | "format": "eslint \"**/*.{js,jsx}\" --fix && prettier \"**/*.{js,jsx,json}\" --write", 22 | <% } %> 23 | "test": "jest", 24 | "watch": "jest --watch", 25 | "snapupdate": "jest --updateSnapshot", 26 | "coverage": "jest --coverage" 27 | }, 28 | "files": [ 29 | <% if (props.language === "typescript") { %> 30 | "build", 31 | <% } else { %> 32 | "src", 33 | <% } %> 34 | "LICENSE", 35 | "readme.md", 36 | "docs", 37 | "bin" 38 | ], 39 | "license": "MIT", 40 | "dependencies": { 41 | "gluegun": "latest" 42 | }, 43 | "devDependencies": { 44 | <% if (props.language === "typescript") { %> 45 | "@types/node": "^12.7.11", 46 | "@types/jest": "^26.0.20", 47 | "@typescript-eslint/eslint-plugin": "^4.17.0", 48 | "@typescript-eslint/parser": "^4.17.0", 49 | "ts-jest": "^26.5.3", 50 | "ts-node": "^10.9.1", 51 | "typescript": "~4.5.0", 52 | <% } %> 53 | "copyfiles": "^2.4.1", 54 | "eslint": "^7.22.0", 55 | "eslint-config-prettier": "^8.1.0", 56 | "eslint-plugin-prettier": "^3.3.1", 57 | "husky": "^5.1.3", 58 | "jest": "^26.6.3", 59 | "prettier": "^2.2.1", 60 | "pretty-quick": "^3.1.0" 61 | }, 62 | "jest": { 63 | <% if (props.language === "typescript") { %> 64 | "preset": "ts-jest", 65 | <% } %> 66 | "testEnvironment": "node" 67 | }, 68 | "prettier": { 69 | "semi": false, 70 | "singleQuote": true 71 | }, 72 | "husky": { 73 | "hooks": { 74 | "pre-commit": "pretty-quick --staged" 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/cli/templates/cli/readme.md.ejs: -------------------------------------------------------------------------------- 1 | # <%= props.name %> CLI 2 | 3 | A CLI for <%= props.name %>. 4 | 5 | ## Customizing your CLI 6 | 7 | Check out the documentation at https://github.com/infinitered/gluegun/tree/master/docs. 8 | 9 | ## Publishing to NPM 10 | 11 | To package your CLI up for NPM, do this: 12 | 13 | ```shell 14 | $ npm login 15 | $ npm whoami 16 | $ npm test 17 | <% if (props.language === "typescript") { %> 18 | $ npm run build 19 | <% } %> 20 | $ npm publish 21 | ``` 22 | 23 | # License 24 | 25 | MIT - see LICENSE 26 | 27 | -------------------------------------------------------------------------------- /src/cli/templates/cli/src/cli.js.ejs: -------------------------------------------------------------------------------- 1 | <% if (props.language === "typescript") { %> 2 | import { build } from 'gluegun' 3 | <% } else { %> 4 | const { build } = require('gluegun') 5 | <% } %> 6 | 7 | /** 8 | * Create the cli and kick it off 9 | */ 10 | async function run(argv) { 11 | // create a CLI runtime 12 | const cli = build() 13 | .brand('<%= props.name %>') 14 | .src(__dirname) 15 | .plugins('./node_modules', { matching: '<%= props.name %>-*', hidden: true }) 16 | .help() // provides default for help, h, --help, -h 17 | .version() // provides default for version, v, --version, -v 18 | .create() 19 | // enable the following method if you'd like to skip loading one of these core extensions 20 | // this can improve performance if they're not necessary for your project: 21 | // .exclude(['meta', 'strings', 'print', 'filesystem', 'semver', 'system', 'prompt', 'http', 'template', 'patching', 'package-manager']) 22 | // and run it 23 | const toolbox = await cli.run(argv) 24 | 25 | // send it back (for testing, mostly) 26 | return toolbox 27 | } 28 | 29 | module.exports = { run } 30 | -------------------------------------------------------------------------------- /src/cli/templates/cli/src/commands/default.js.ejs: -------------------------------------------------------------------------------- 1 | <% if (props.language === "typescript") { %> 2 | import { GluegunCommand } from 'gluegun' 3 | <% } %> 4 | 5 | const command<%= (props.language === "typescript") ? ": GluegunCommand" : "" %> = { 6 | name: '<%= props.name %>', 7 | run: async toolbox => { 8 | const { print } = toolbox 9 | 10 | print.info('Welcome to your CLI') 11 | }, 12 | } 13 | 14 | module.exports = command 15 | -------------------------------------------------------------------------------- /src/cli/templates/cli/src/commands/generate.js.ejs: -------------------------------------------------------------------------------- 1 | <% if (props.language === "typescript") { %> 2 | import { GluegunToolbox } from 'gluegun' 3 | <% } %> 4 | 5 | module.exports = { 6 | name: 'generate', 7 | alias: ['g'], 8 | run: async <%= (props.language === "typescript") ? "(toolbox: GluegunToolbox)" : "toolbox" %> => { 9 | const { 10 | parameters, 11 | template: { generate }, 12 | print: { info }, 13 | } = toolbox 14 | 15 | const name = parameters.first 16 | 17 | await generate({ 18 | template: 'model.<%= props.extension %>.ejs', 19 | target: `models/${name}-model.<%= props.extension %>`, 20 | props: { name }, 21 | }) 22 | 23 | info(`Generated file at models/${name}-model.<%= props.extension %>`) 24 | }, 25 | } 26 | -------------------------------------------------------------------------------- /src/cli/templates/cli/src/extensions/cli-extension.js.ejs: -------------------------------------------------------------------------------- 1 | <% if (props.language === "typescript") { %> 2 | import { GluegunToolbox } from 'gluegun' 3 | <% } %> 4 | 5 | // add your CLI-specific functionality here, which will then be accessible 6 | // to your commands 7 | module.exports = <%= (props.language === "typescript") ? "(toolbox: GluegunToolbox)" : "toolbox" %> => { 8 | toolbox.foo = () => { 9 | toolbox.print.info('called foo extension') 10 | } 11 | 12 | // enable this if you want to read configuration in from 13 | // the current folder's package.json (in a "<%= props.name %>" property), 14 | // <%= props.name %>.config.json, etc. 15 | // toolbox.config = { 16 | // ...toolbox.config, 17 | // ...toolbox.config.loadConfig("<%= props.name %>", process.cwd()) 18 | // } 19 | } 20 | -------------------------------------------------------------------------------- /src/cli/templates/cli/src/templates/model.js.ejs.ejs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: '<%- '<' + '%= props.name %' + '>' %>' 3 | } 4 | -------------------------------------------------------------------------------- /src/cli/templates/cli/src/types.js.ejs: -------------------------------------------------------------------------------- 1 | // export types -------------------------------------------------------------------------------- /src/cli/templates/cli/tsconfig.json.ejs: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "experimentalDecorators": true, 5 | "lib": ["es2015", "scripthost", "es2015.promise", "es2015.generator", "es2015.iterable", "dom"], 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noImplicitThis": true, 10 | "noUnusedLocals": true, 11 | "sourceMap": true, 12 | "inlineSourceMap": true, 13 | "outDir": "build", 14 | "strict": false, 15 | "target": "es5", 16 | "declaration": true, 17 | "declarationDir": "build/types", 18 | }, 19 | "include": ["src/**/*"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /src/cli/templates/test/kitchen-sink-command.js.ejs: -------------------------------------------------------------------------------- 1 | // from gluegun 2 | const { 3 | filesystem, 4 | semver, 5 | http, 6 | print, 7 | prompt, 8 | strings, 9 | system, 10 | patching 11 | } = require('gluegun') 12 | 13 | // from gluegun/* 14 | const { filesystem: filesystemDirect } = require('gluegun/filesystem') 15 | const { semver: semverDirect } = require('gluegun/semver') 16 | const { http: httpDirect } = require('gluegun/http') 17 | const { print: printDirect } = require('gluegun/print') 18 | const { prompt: promptDirect } = require('gluegun/prompt') 19 | const { strings: stringsDirect } = require('gluegun/strings') 20 | const { system: systemDirect } = require('gluegun/system') 21 | const { patching: patchingDirect } = require('gluegun/patching') 22 | 23 | module.exports = { 24 | name: 'kitchen', 25 | run: async (toolbox) => { 26 | // smoke test all the things 27 | const asyncs = [] 28 | 29 | // filesystem smoke test 30 | asyncs.push(filesystem.cwd()) 31 | asyncs.push(filesystem.exists(__filename)) 32 | asyncs.push(filesystemDirect.cwd()) 33 | 34 | // semver smoke test 35 | semver.valid('1.2.3') // '1.2.3' 36 | semver.clean(' =v1.2.3 ') // '1.2.3' 37 | semver.satisfies('1.2.3', '1.x || >=2.5.0 || 5.0.0 - 7.2.3') // true 38 | semver.lt('1.2.3', '9.8.7') // true 39 | semverDirect.valid('1.2.3') // '1.2.3' 40 | 41 | // http smoke test 42 | const api = http.create({ 43 | baseURL: 'https://api.github.com', 44 | headers: { Accept: 'application/vnd.github.v3+json' }, 45 | }) 46 | asyncs.push(api.get('/repos/skellock/apisauce/commits')) 47 | 48 | const apiDirect = httpDirect.create({ 49 | baseURL: 'https://api.github.com', 50 | headers: { Accept: 'application/vnd.github.v3+json' }, 51 | }) 52 | asyncs.push(apiDirect.get('/repos/skellock/apisauce/commits')) 53 | 54 | // print smoke test 55 | print.info(print.colors.success('Hello. I am a chatty plugin.')) 56 | print.spin('Time for fun!').stop() 57 | print.printHelp(toolbox) 58 | print.table( 59 | [ 60 | ['First Name', 'Last Name', 'Age'], 61 | ['Jamon', 'Holmgren', 35], 62 | ['Gant', 'Laborde', 36], 63 | ['Steve', 'Kellock', 43], 64 | ['Gary', 'Busey', 73], 65 | ], 66 | { format: 'markdown' }, 67 | ) 68 | printDirect.info(print.colors.success('Hello. I am a chatty plugin.')) 69 | 70 | // prompt smoke test (not very thorough, just ensuring it's there) 71 | if (!prompt.ask) throw new Error('no ask?') 72 | if (!prompt.confirm) throw new Error('no confirm?') 73 | if (!prompt.separator) throw new Error('no separator?') 74 | if (!promptDirect.ask) throw new Error('no ask?') 75 | 76 | // strings smoke test 77 | strings.identity('hello') 78 | strings.padEnd('hello', 10, '!') 79 | strings.kebabCase('hello there') 80 | strings.upperFirst('hello there') 81 | strings.isSingular('bugs') 82 | strings.addUncountableRule('paper') 83 | stringsDirect.addUncountableRule('paper') 84 | 85 | // system smoke test 86 | system.which('npm') 87 | systemDirect.which('npm') 88 | asyncs.push(system.run('node -v', { trim: true })) 89 | 90 | // patching smoke test 91 | asyncs.push(patching.exists(__filename, 'Barb')) 92 | asyncs.push(patching.replace(__filename, 'SELF REPLACING STRING', 'REPLACED STRING WHOA')) 93 | asyncs.push(patchingDirect.exists(__filename, 'Barb')) 94 | 95 | // run all the asyncs at once yolo 96 | await Promise.all(asyncs) 97 | } 98 | } -------------------------------------------------------------------------------- /src/core-commands/default.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | run: ({ parameters, runtime, print, strings, meta }) => { 3 | const infoMessage = strings.isBlank(parameters.first) 4 | ? `Welcome to ${print.colors.cyan(runtime.brand)} CLI version ${meta.version()}!` 5 | : `Sorry, didn't recognize that command!` 6 | print.info(` 7 | ${infoMessage} 8 | Type ${print.colors.magenta(`${runtime.brand} --help`)} to view common commands.`) 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /src/core-commands/help.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'help', 3 | alias: 'h', 4 | dashed: true, 5 | run: (toolbox) => { 6 | toolbox.print.printHelp(toolbox) 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /src/core-commands/version.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'version', 3 | alias: 'v', 4 | description: 'Output the version number', 5 | dashed: true, 6 | run: (toolbox) => { 7 | toolbox.print.info(toolbox.meta.version()) 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /src/core-extensions/filesystem-extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import * as os from 'os' 3 | import * as path from 'path' 4 | import { Toolbox } from '../domain/toolbox' 5 | import createExtension from './filesystem-extension' 6 | 7 | test('has the proper interface', () => { 8 | const toolbox = new Toolbox() 9 | createExtension(toolbox) 10 | const ext = toolbox.filesystem 11 | 12 | expect(ext).toBeTruthy() 13 | 14 | // a few dumb checks to ensure we're talking to jetpack 15 | expect(typeof ext.copy).toBe('function') 16 | expect(typeof ext.path).toBe('function') 17 | expect(typeof ext.subdirectories).toBe('function') 18 | expect(ext.read(__filename).split(os.EOL)[0]).toBe(`import * as expect from 'expect'`) 19 | // the extra values we've added 20 | expect(ext.eol).toBe(os.EOL) 21 | expect(ext.separator).toBe(path.sep) 22 | }) 23 | -------------------------------------------------------------------------------- /src/core-extensions/filesystem-extension.ts: -------------------------------------------------------------------------------- 1 | import { GluegunToolbox } from '../domain/toolbox' 2 | import { filesystem } from '../toolbox/filesystem-tools' 3 | 4 | /** 5 | * Extensions to filesystem. Brought to you by fs-jetpack. 6 | * 7 | * @param toolbox The running toolbox. 8 | */ 9 | export default function attach(toolbox: GluegunToolbox) { 10 | toolbox.filesystem = filesystem 11 | } 12 | -------------------------------------------------------------------------------- /src/core-extensions/http-extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import * as http from 'http' 3 | import { Toolbox } from '../domain/toolbox' 4 | import createExtension from './http-extension' 5 | 6 | const toolbox = new Toolbox() 7 | createExtension(toolbox) 8 | const ext = toolbox.http 9 | 10 | /** 11 | * Sends a HTTP response. 12 | * 13 | * @param res - The http response object. 14 | * @param statusCode - The http response status code. 15 | * @param body - The reponse data. 16 | */ 17 | const sendResponse = (res: any, statusCode: number, body: string) => { 18 | res.writeHead(statusCode) 19 | res.write(body) 20 | res.end() 21 | } 22 | 23 | /** 24 | * Sends a 200 OK with some data. 25 | * 26 | * @param res - The http response object. 27 | * @param body - The http response data. 28 | */ 29 | const send200 = (res: any, body?: string) => { 30 | sendResponse(res, 200, body || '

OK

') 31 | } 32 | 33 | test('has the proper interface', () => { 34 | expect(ext).toBeTruthy() 35 | expect(typeof ext.create).toBe('function') 36 | }) 37 | 38 | test('connects to a server', async () => { 39 | const server = http.createServer((req, res) => { 40 | send200(res, 'hi') 41 | }) 42 | server.listen() 43 | const { port } = server.address() as any 44 | const api = ext.create({ 45 | baseURL: `http://127.0.0.1:${port}`, 46 | }) 47 | const response = await api.get('/') 48 | expect(response.data).toBe('hi') 49 | }) 50 | -------------------------------------------------------------------------------- /src/core-extensions/http-extension.ts: -------------------------------------------------------------------------------- 1 | import { GluegunToolbox } from '../domain/toolbox' 2 | import { http } from '../toolbox/http-tools' 3 | 4 | /** 5 | * An extension to talk to ye olde internet. 6 | * 7 | * @param toolbox The running toolbox. 8 | */ 9 | export default function attach(toolbox: GluegunToolbox): void { 10 | toolbox.http = http 11 | } 12 | -------------------------------------------------------------------------------- /src/core-extensions/meta-extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import { Toolbox } from '../domain/toolbox' 3 | import { Runtime } from '../runtime/runtime' 4 | import createExtension, { GluegunMeta } from './meta-extension' 5 | 6 | test('has the proper interface', () => { 7 | const toolbox = new Toolbox() 8 | const fakeRuntime = { defaultPlugin: { directory: '/the/path' } } as Runtime 9 | toolbox.runtime = fakeRuntime 10 | createExtension(toolbox) 11 | const ext = toolbox.meta as GluegunMeta 12 | expect(ext).toBeTruthy() 13 | expect(ext.src).toEqual('/the/path') 14 | expect(typeof ext.version).toBe('function') 15 | expect(typeof ext.commandInfo).toBe('function') 16 | expect(typeof ext.checkForUpdate).toBe('function') 17 | }) 18 | -------------------------------------------------------------------------------- /src/core-extensions/meta-extension.ts: -------------------------------------------------------------------------------- 1 | import { commandInfo, getVersion, checkForUpdate, getPackageJSON, onAbort } from '../toolbox/meta-tools' 2 | import { GluegunToolbox } from '../domain/toolbox' 3 | import { PackageJSON } from '../toolbox/meta-types' 4 | 5 | export interface GluegunMeta { 6 | src: string | void 7 | version: () => string 8 | packageJSON: () => PackageJSON 9 | commandInfo: () => string[][] 10 | checkForUpdate: () => Promise 11 | onAbort: typeof onAbort 12 | } 13 | 14 | /** 15 | * Extension that lets you learn more about the currently running CLI. 16 | * 17 | * @param toolbox The running toolbox. 18 | */ 19 | export default function attach(toolbox: GluegunToolbox): void { 20 | const meta: GluegunMeta = { 21 | src: toolbox.runtime && toolbox.runtime.defaultPlugin && toolbox.runtime.defaultPlugin.directory, 22 | version: () => getVersion(toolbox), 23 | packageJSON: () => getPackageJSON(toolbox), 24 | commandInfo: () => commandInfo(toolbox), 25 | checkForUpdate: () => checkForUpdate(toolbox), 26 | onAbort, 27 | } 28 | toolbox.meta = meta 29 | } 30 | -------------------------------------------------------------------------------- /src/core-extensions/package-manager-extension.ts: -------------------------------------------------------------------------------- 1 | import { GluegunToolbox } from '../domain/toolbox' 2 | import { packageManager } from '../toolbox/package-manager-tools' 3 | 4 | /** 5 | * Extensions to filesystem. Brought to you by fs-jetpack. 6 | * 7 | * @param toolbox The running toolbox. 8 | */ 9 | export default function attach(toolbox: GluegunToolbox) { 10 | toolbox.packageManager = packageManager 11 | } 12 | -------------------------------------------------------------------------------- /src/core-extensions/patching-extension.ts: -------------------------------------------------------------------------------- 1 | import { GluegunToolbox } from '../domain/toolbox' 2 | import { patching } from '../toolbox/patching-tools' 3 | 4 | /** 5 | * Builds the patching feature. 6 | * 7 | * @param toolbox The running toolbox. 8 | */ 9 | export default function attach(toolbox: GluegunToolbox): void { 10 | toolbox.patching = patching 11 | } 12 | -------------------------------------------------------------------------------- /src/core-extensions/print-extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import { Toolbox } from '../domain/toolbox' 3 | import printExtension from './print-extension' 4 | 5 | const toolbox = new Toolbox() 6 | printExtension(toolbox) 7 | 8 | const { print } = toolbox 9 | 10 | test('info', () => { 11 | expect(typeof print.info).toBe('function') 12 | }) 13 | 14 | test('warning', () => { 15 | expect(typeof print.warning).toBe('function') 16 | }) 17 | 18 | test('success', () => { 19 | expect(typeof print.success).toBe('function') 20 | }) 21 | 22 | test('error', () => { 23 | expect(typeof print.error).toBe('function') 24 | }) 25 | 26 | test('debug', () => { 27 | expect(typeof print.debug).toBe('function') 28 | }) 29 | 30 | test('newline', () => { 31 | expect(typeof print.newline).toBe('function') 32 | }) 33 | 34 | test('table', () => { 35 | expect(typeof print.table).toBe('function') 36 | }) 37 | 38 | test('spin', () => { 39 | expect(typeof print.spin).toBe('function') 40 | }) 41 | 42 | test('colors', () => { 43 | expect(typeof print.colors.highlight).toBe('function') 44 | expect(typeof print.colors.info).toBe('function') 45 | expect(typeof print.colors.warning).toBe('function') 46 | expect(typeof print.colors.success).toBe('function') 47 | expect(typeof print.colors.error).toBe('function') 48 | expect(typeof print.colors.line).toBe('function') 49 | expect(typeof print.colors.muted).toBe('function') 50 | }) 51 | -------------------------------------------------------------------------------- /src/core-extensions/print-extension.ts: -------------------------------------------------------------------------------- 1 | import { GluegunToolbox } from '../domain/toolbox' 2 | import { print } from '../toolbox/print-tools' 3 | 4 | /** 5 | * Extensions to print to the console. 6 | * 7 | * @param toolbox The running toolbox. 8 | */ 9 | export default function attach(toolbox: GluegunToolbox): void { 10 | // attach the feature set 11 | toolbox.print = print 12 | } 13 | -------------------------------------------------------------------------------- /src/core-extensions/prompt-extension.ts: -------------------------------------------------------------------------------- 1 | import { GluegunToolbox } from '../domain/toolbox' 2 | import { prompt } from '../toolbox/prompt-tools' 3 | 4 | /** 5 | * Provides user input prompts via enquirer.js. 6 | * 7 | * @param toolbox The running toolbox. 8 | */ 9 | export default function attach(toolbox: GluegunToolbox) { 10 | toolbox.prompt = prompt 11 | } 12 | -------------------------------------------------------------------------------- /src/core-extensions/semver-extension.ts: -------------------------------------------------------------------------------- 1 | import { GluegunToolbox } from '../domain/toolbox' 2 | import { semver } from '../toolbox/semver-tools' 3 | 4 | /** 5 | * Extensions to access semver and helpers 6 | * 7 | * @param toolbox The running toolbox. 8 | */ 9 | export default function attach(toolbox: GluegunToolbox): void { 10 | toolbox.semver = semver 11 | } 12 | -------------------------------------------------------------------------------- /src/core-extensions/strings-extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import { Toolbox } from '../domain/toolbox' 3 | import createExtension from './strings-extension' 4 | 5 | test('has the proper interface', () => { 6 | const toolbox = new Toolbox() 7 | createExtension(toolbox) 8 | const ext = toolbox.strings 9 | expect(ext).toBeTruthy() 10 | expect(typeof ext.trim).toBe('function') 11 | expect(ext.trim(' lol')).toBe('lol') 12 | }) 13 | -------------------------------------------------------------------------------- /src/core-extensions/strings-extension.ts: -------------------------------------------------------------------------------- 1 | import { strings } from '../toolbox/string-tools' 2 | import { GluegunToolbox } from '../domain/toolbox' 3 | 4 | /** 5 | * Attaches some string helpers for convenience. 6 | * 7 | * @param toolbox The running toolbox. 8 | */ 9 | export default function attach(toolbox: GluegunToolbox): void { 10 | toolbox.strings = strings 11 | } 12 | -------------------------------------------------------------------------------- /src/core-extensions/system-extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import { platform } from 'os' 3 | import { Toolbox } from '../domain/toolbox' 4 | import create from './system-extension' 5 | 6 | const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) 7 | 8 | const toolbox = new Toolbox() 9 | create(toolbox) 10 | const system = toolbox.system 11 | 12 | test('survives the factory function', () => { 13 | expect(system).toBeTruthy() 14 | expect(typeof system.run).toBe('function') 15 | }) 16 | 17 | test('captures stdout', async () => { 18 | const stdout = await system.run(`${platform() === 'win32' ? 'dir /S /B' : 'ls'} ${__filename}`) 19 | expect(stdout).toContain(__filename) 20 | }) 21 | 22 | test('captures stderr', async () => { 23 | expect.assertions(1) 24 | try { 25 | await system.run(`omgdontrunlol ${__filename}`) 26 | } catch (e) { 27 | expect(/not (found|recognized)/.test(e.stderr)).toBe(true) 28 | } 29 | }) 30 | 31 | test('knows about which', () => { 32 | const npm = system.which('npm') 33 | expect(npm).toBeTruthy() 34 | }) 35 | 36 | test('can spawn and capture results', async () => { 37 | const good = await system.spawn('echo hello') 38 | expect(good.status).toBe(0) 39 | expect(good.stdout.toString()).toEqual(expect.stringMatching(/"?hello"?\w*/)) 40 | }) 41 | 42 | test('spawn deals with missing programs', async () => { 43 | const crap = await system.spawn('dfsjkajfkldasjklfajsd') 44 | expect(crap.error).toBeTruthy() 45 | expect(crap.output).toBeFalsy() 46 | expect(crap.status).toBe(null) 47 | }) 48 | 49 | test('spawn deals exit codes', async () => { 50 | const crap = await system.spawn('npm') 51 | expect(crap.error).toBeFalsy() 52 | expect(crap.status).toBe(1) 53 | }) 54 | 55 | test('start timer returns the number of milliseconds', async () => { 56 | const WAIT = 10 57 | 58 | const elapsed = system.startTimer() // start a timer 59 | await delay(WAIT) // simulate a delay 60 | const duration = elapsed() // how long was that? 61 | 62 | // due to rounding this can be before the timeout. 63 | expect(duration >= WAIT - 1).toBe(true) 64 | }) 65 | -------------------------------------------------------------------------------- /src/core-extensions/system-extension.ts: -------------------------------------------------------------------------------- 1 | import { GluegunToolbox } from '../domain/toolbox' 2 | import { system } from '../toolbox/system-tools' 3 | 4 | /** 5 | * Extensions to launch processes and open files. 6 | * 7 | * @param toolbox The running toolbox. 8 | */ 9 | export default function attach(toolbox: GluegunToolbox) { 10 | toolbox.system = system 11 | } 12 | -------------------------------------------------------------------------------- /src/core-extensions/template-extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os' 2 | import * as expect from 'expect' 3 | import * as path from 'path' 4 | import { Runtime } from '../runtime/runtime' 5 | 6 | const createRuntime = () => { 7 | const r = new Runtime() 8 | r.addCoreExtensions() 9 | r.addPlugin(path.join(__dirname, '..', 'fixtures', 'good-plugins', 'generate')) 10 | r.addPlugin(path.join(__dirname, '..', 'fixtures', 'good-plugins', 'generate-build')) 11 | return r 12 | } 13 | 14 | test('generates a simple file', async () => { 15 | const toolbox = await createRuntime().run('simple') 16 | 17 | expect(toolbox.result).toBe('simple file' + os.EOL) 18 | }) 19 | 20 | test('supports props', async () => { 21 | const toolbox = await createRuntime().run('props Greetings_and_salutations', { 22 | stars: 5, 23 | }) 24 | 25 | expect(toolbox.result).toBe( 26 | `greetingsAndSalutations world${os.EOL}` + `red${os.EOL}green${os.EOL}blue${os.EOL}*****${os.EOL}`, 27 | ) 28 | }) 29 | 30 | test('detects missing templates', async () => { 31 | try { 32 | await createRuntime().run('missing') 33 | } catch (e) { 34 | expect(e.message).toContain('template not found') 35 | } 36 | }) 37 | 38 | test('supports directories', async () => { 39 | const toolbox = await createRuntime().run('special location') 40 | 41 | expect(toolbox.result).toBe('location' + os.EOL) 42 | }) 43 | 44 | // Test in a build folder 45 | 46 | test('generates a simple file in a build folder', async () => { 47 | const toolbox = await createRuntime().run('build simple') 48 | 49 | expect(toolbox.result).toBe('simple file' + os.EOL) 50 | }) 51 | 52 | test('supports props in a build folder', async () => { 53 | const toolbox = await createRuntime().run('build props Greetings_and_salutations', { 54 | stars: 5, 55 | }) 56 | 57 | expect(toolbox.result).toBe( 58 | `greetingsAndSalutations world${os.EOL}` + `red${os.EOL}green${os.EOL}blue${os.EOL}*****${os.EOL}`, 59 | ) 60 | }) 61 | 62 | test('detects missing templates in a build folder', async () => { 63 | try { 64 | await createRuntime().run('build missing') 65 | } catch (e) { 66 | expect(e.message).toContain('template not found') 67 | } 68 | }) 69 | 70 | test('supports directories in a build folder', async () => { 71 | const toolbox = await createRuntime().run('build special location') 72 | 73 | expect(toolbox.result).toBe('location' + os.EOL) 74 | }) 75 | -------------------------------------------------------------------------------- /src/core-extensions/template-extension.ts: -------------------------------------------------------------------------------- 1 | import { GluegunToolbox } from '../domain/toolbox' 2 | import { buildGenerate } from '../toolbox/template-tools' 3 | 4 | /** 5 | * Builds the code generation feature. 6 | * 7 | * @param toolbox The running toolbox. 8 | */ 9 | export default function attach(toolbox: GluegunToolbox): void { 10 | const generate = buildGenerate(toolbox) 11 | toolbox.template = { generate } 12 | } 13 | -------------------------------------------------------------------------------- /src/domain/builder.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import * as path from 'path' 3 | import { build } from './builder' 4 | import { Toolbox } from './toolbox' 5 | 6 | test('the gauntlet', () => { 7 | const builder = build('test') 8 | // plugins 9 | .src(path.join(__dirname, '..', 'fixtures', 'good-plugins', 'threepack')) 10 | .help() 11 | .version({ 12 | name: 'gimmedatversion', 13 | alias: ['version', 'v'], 14 | run: () => 'it works', 15 | }) 16 | .defaultCommand() 17 | .plugin(path.join(__dirname, '..', 'fixtures', 'good-plugins', 'simplest')) 18 | .plugins(path.join(__dirname, '..', 'fixtures', 'good-plugins'), { hidden: true }) 19 | .checkForUpdates(0) // don't actually check, but run the command 20 | 21 | const runtime = builder.create() 22 | expect(runtime).toBeTruthy() 23 | 24 | expect(runtime.brand).toBe('test') 25 | expect(runtime.commands.length).toBe(34) 26 | expect(runtime.extensions.length).toBe(16) 27 | expect(runtime.defaultPlugin.commands.length).toBe(6) 28 | 29 | const { commands } = runtime.defaultPlugin 30 | 31 | expect(commands[0].name).toBe('one') 32 | expect(commands[1].name).toBe('three') 33 | expect(commands[2].name).toBe('two') 34 | expect(commands[3].name).toBe('test') 35 | expect(commands[4].name).toBe('help') 36 | expect(commands[5].name).toBe('gimmedatversion') 37 | expect(commands[5].run(new Toolbox())).toBe('it works') 38 | 39 | expect(runtime.plugins.length).toBe(18) 40 | }) 41 | -------------------------------------------------------------------------------- /src/domain/command.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import { Command } from './command' 3 | 4 | test('default state', () => { 5 | const command = new Command() 6 | expect(command).toBeTruthy() 7 | expect(command.name).toBeFalsy() 8 | expect(command.file).toBeFalsy() 9 | expect(command.description).toBeFalsy() 10 | expect(command.run).toBeFalsy() 11 | expect(command.dashed).toBeFalsy() 12 | expect(command.hidden).toBe(false) 13 | }) 14 | 15 | test('matchesAlias', () => { 16 | const command = new Command() 17 | command.name = 'yogurt' 18 | command.alias = ['yo', 'y'] 19 | 20 | expect(command.matchesAlias(['asdf', 'i', 'yo'])).toBeTruthy() 21 | expect(command.matchesAlias('yogurt')).toBeTruthy() 22 | expect(command.matchesAlias(['asdf', 'i', 'womp'])).toBeFalsy() 23 | }) 24 | -------------------------------------------------------------------------------- /src/domain/command.ts: -------------------------------------------------------------------------------- 1 | import { Toolbox } from './toolbox' 2 | import { Plugin } from './plugin' 3 | 4 | export interface GluegunCommand { 5 | /** The name of your command */ 6 | name?: string 7 | /** A tweet-sized summary of your command */ 8 | description?: string 9 | /** The function for running your command, can be async */ 10 | run: (toolbox: TContext) => void 11 | /** Should your command be shown in the listings */ 12 | hidden?: boolean 13 | /** The command path, an array that describes how to get to this command */ 14 | commandPath?: string[] 15 | /** Potential other names for this command */ 16 | alias?: string | string[] 17 | /** Lets you run the command as a dashed command, like `--version` or `-v`. */ 18 | dashed?: boolean 19 | /** The path to the file name for this command. */ 20 | file?: string 21 | /** A reference to the plugin that contains this command. */ 22 | plugin?: Plugin 23 | } 24 | 25 | /** 26 | * A command is user-callable function that runs stuff. 27 | */ 28 | export class Command implements GluegunCommand { 29 | public name 30 | public description 31 | public file 32 | public run 33 | public hidden 34 | public commandPath 35 | public alias 36 | public dashed 37 | public plugin 38 | 39 | constructor(props?: GluegunCommand) { 40 | this.name = null 41 | this.description = null 42 | this.file = null 43 | this.run = null 44 | this.hidden = false 45 | this.commandPath = null 46 | this.alias = [] 47 | this.dashed = false 48 | this.plugin = null 49 | if (props) Object.assign(this, props) 50 | } 51 | 52 | /** 53 | * Returns normalized list of aliases. 54 | * 55 | * @returns list of aliases. 56 | */ 57 | get aliases(): string[] { 58 | if (!this.alias) return [] 59 | return Array.isArray(this.alias) ? this.alias : [this.alias] 60 | } 61 | 62 | /** 63 | * Checks if the command has any aliases at all. 64 | * 65 | * @returns whether the command has any aliases 66 | */ 67 | public hasAlias(): boolean { 68 | return this.aliases.length > 0 69 | } 70 | 71 | /** 72 | * Checks if a given alias matches with this command's aliases, including name. 73 | * Can take a list of aliases too and check them all. 74 | * 75 | * @param alias 76 | * @returns whether the alias[es] matches 77 | */ 78 | public matchesAlias(alias: string | string[]): boolean { 79 | const aliases = Array.isArray(alias) ? alias : [alias] 80 | return Boolean(aliases.find((a) => this.name === a || this.aliases.includes(a))) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/domain/extension.ts: -------------------------------------------------------------------------------- 1 | import { EmptyToolbox } from './toolbox' 2 | 3 | /** 4 | * An extension will add functionality to the toolbox that each command will receive. 5 | */ 6 | export class Extension { 7 | /** The name of the extension. */ 8 | public name?: string = null 9 | /** The description. */ 10 | public description?: string = null 11 | /** The file this extension comes from. */ 12 | public file?: string = null 13 | /** The function used to attach functionality to the toolbox. */ 14 | public setup?: (toolbox: EmptyToolbox) => void | Promise = null 15 | } 16 | -------------------------------------------------------------------------------- /src/domain/options.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A flexible object for the many "options" objects we throw around in gluegun. 3 | */ 4 | export interface Options { 5 | [key: string]: any 6 | } 7 | 8 | /** 9 | * More specific options for loading plugins. 10 | */ 11 | export interface GluegunLoadOptions { 12 | /** 13 | * Should we hide this plugin from showing up in the CLI? These types 14 | * of plugins will still be available to be called directly. 15 | */ 16 | hidden?: boolean 17 | 18 | /** 19 | * The file pattern to use when auto-detecting commands. The default is [`*.{js,ts}`, `!*.test.{js,ts}`]. 20 | * The second matcher excludes test files with that pattern. The `ts` extension is only needed for loading 21 | * in a TypeScript environment such as `ts-node`. 22 | */ 23 | commandFilePattern?: string[] 24 | 25 | /** 26 | * The file pattern is used when auto-detecting gluegun extensions. The default 27 | * is [`*.{js,ts}`, `!*.test.{js,ts}`]. The `ts` extension is only needed for loading 28 | * in a TypeScript environment such as `ts-node`. 29 | */ 30 | extensionFilePattern?: string[] 31 | 32 | /** 33 | * Specifies if the plugin is required to exist or not. If this is `true` and the plugin 34 | * doesn't exist, an Error will be thrown. 35 | */ 36 | required?: boolean 37 | 38 | /** 39 | * Overrides the name of the plugin. 40 | */ 41 | name?: string 42 | 43 | /** 44 | * Provides commands that are provided by the calling CLI rather than loaded from a file. 45 | */ 46 | preloadedCommands?: object[] 47 | } 48 | 49 | export interface GluegunMultiLoadOptions { 50 | /** 51 | * Filters the directories to those matching this glob-based pattern. The default 52 | * is `*` which is all the immediate sub-directories. Setting this to something 53 | * like `ignite-*` will only attempt to load plugins from directories that start 54 | * with `ignite-`. 55 | */ 56 | matching?: string 57 | } 58 | -------------------------------------------------------------------------------- /src/domain/plugin.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import { Plugin } from './plugin' 3 | 4 | test('default state', () => { 5 | const plugin = new Plugin() 6 | expect(plugin).toBeTruthy() 7 | expect(plugin.directory).toBeFalsy() 8 | expect(plugin.name).toBeFalsy() 9 | expect(plugin.hidden).toBe(false) 10 | expect(plugin.commands).toEqual([]) 11 | expect(plugin.extensions).toEqual([]) 12 | expect(plugin.defaults).toEqual({}) 13 | }) 14 | -------------------------------------------------------------------------------- /src/domain/plugin.ts: -------------------------------------------------------------------------------- 1 | import { Command } from './command' 2 | import { Extension } from './extension' 3 | import { Options } from './options' 4 | 5 | /** 6 | * Extends the environment with new commands. 7 | */ 8 | export class Plugin { 9 | /** The name of the plugin. */ 10 | public name?: string 11 | /** A description used in the cli. */ 12 | public description?: string 13 | /** Default configuration values. */ 14 | public defaults: Options 15 | /** The directory this plugin lives in. */ 16 | public directory?: string 17 | /** Should we hide this command from the cli? */ 18 | public hidden: boolean 19 | /** The commands in this plugin. */ 20 | public commands: Command[] 21 | /** The extensions in this plugin. */ 22 | public extensions: Extension[] 23 | 24 | constructor() { 25 | this.name = null 26 | this.description = null 27 | this.defaults = {} 28 | this.directory = null 29 | this.hidden = false 30 | /** 31 | * A list of commands. 32 | */ 33 | this.commands = [] 34 | this.extensions = [] 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/domain/toolbox.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import { Toolbox } from './toolbox' 3 | 4 | test('initial state', () => { 5 | const ctx = new Toolbox() 6 | expect(ctx.result).toBeFalsy() 7 | expect(ctx.config).toEqual({}) 8 | expect(ctx.parameters).toEqual({ 9 | options: {}, 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /src/domain/toolbox.ts: -------------------------------------------------------------------------------- 1 | import { Runtime } from '../runtime/runtime' 2 | import { Command } from './command' 3 | import { Options } from './options' 4 | import { Plugin } from './plugin' 5 | import { 6 | GluegunFilesystem, 7 | GluegunStrings, 8 | GluegunPrint, 9 | GluegunSystem, 10 | GluegunSemver, 11 | GluegunHttp, 12 | GluegunPatching, 13 | GluegunPrompt, 14 | GluegunTemplate, 15 | GluegunMeta, 16 | GluegunPackageManager, 17 | } from '..' 18 | 19 | export interface GluegunParameters { 20 | /* The command arguments as an array. */ 21 | array?: string[] 22 | /** 23 | * Any optional parameters. Typically coming from command-line 24 | * arguments like this: `--force -p tsconfig.json`. 25 | */ 26 | options: Options 27 | /* Just the first argument. */ 28 | first?: string 29 | /* Just the 2nd argument. */ 30 | second?: string 31 | /* Just the 3rd argument. */ 32 | third?: string 33 | /* Everything else after the command as a string. */ 34 | string?: string 35 | /* The raw command with any named parameters. */ 36 | raw?: any 37 | /* The original argv value. */ 38 | argv?: any 39 | /* The currently running plugin name. */ 40 | plugin?: string 41 | /* The currently running command name. */ 42 | command?: string 43 | } 44 | 45 | // Temporary toolbox while building 46 | export interface GluegunEmptyToolbox { 47 | [key: string]: any 48 | } 49 | 50 | // Final toolbox 51 | export interface GluegunToolbox extends GluegunEmptyToolbox { 52 | // known properties 53 | config: Options 54 | result?: any 55 | parameters: GluegunParameters 56 | plugin?: Plugin 57 | command?: Command 58 | pluginName?: string 59 | commandName?: string 60 | runtime?: Runtime 61 | 62 | // known extensions 63 | filesystem: GluegunFilesystem 64 | http: GluegunHttp 65 | meta: GluegunMeta 66 | patching: GluegunPatching 67 | print: GluegunPrint 68 | prompt: GluegunPrompt 69 | semver: GluegunSemver 70 | strings: GluegunStrings 71 | system: GluegunSystem 72 | template: GluegunTemplate 73 | generate: any 74 | packageManager: GluegunPackageManager 75 | } 76 | 77 | export class EmptyToolbox implements GluegunEmptyToolbox { 78 | [x: string]: any 79 | public config: Options & { loadConfig?: (name: string, src: string) => Options } = {} 80 | 81 | public result?: any = null 82 | public parameters?: GluegunParameters = { options: {} } 83 | public plugin?: Plugin = null 84 | public command?: Command = null 85 | public pluginName?: string = null 86 | public commandName?: string = null 87 | public runtime?: Runtime = null 88 | 89 | filesystem?: GluegunFilesystem 90 | http?: GluegunHttp 91 | meta?: GluegunMeta 92 | patching?: GluegunPatching 93 | print?: GluegunPrint 94 | prompt?: GluegunPrompt 95 | semver?: GluegunSemver 96 | strings?: GluegunStrings 97 | system?: GluegunSystem 98 | template?: GluegunTemplate 99 | generate?: any 100 | } 101 | 102 | export class Toolbox extends EmptyToolbox implements GluegunToolbox { 103 | public config: Options = {} 104 | public parameters: GluegunParameters = { options: {} } 105 | 106 | // known extensions 107 | filesystem: GluegunFilesystem 108 | http: GluegunHttp 109 | meta: GluegunMeta 110 | patching: GluegunPatching 111 | print: GluegunPrint 112 | prompt: GluegunPrompt 113 | semver: GluegunSemver 114 | strings: GluegunStrings 115 | system: GluegunSystem 116 | template: GluegunTemplate 117 | generate: any 118 | packageManager: GluegunPackageManager 119 | } 120 | 121 | // Toolbox used to be known as RunContext. This is for backwards compatibility. 122 | export type GluegunRunContext = GluegunToolbox 123 | export type RunContext = Toolbox 124 | -------------------------------------------------------------------------------- /src/filesystem.ts: -------------------------------------------------------------------------------- 1 | export { filesystem, GluegunFilesystem } from './toolbox/filesystem-tools' 2 | -------------------------------------------------------------------------------- /src/fixtures/bad-modules/blank.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/infinitered/gluegun/9332f599353785b20b64b322c59bb9e36137b497/src/fixtures/bad-modules/blank.js -------------------------------------------------------------------------------- /src/fixtures/bad-modules/number.js: -------------------------------------------------------------------------------- 1 | module.exports = 3 // eslint-line-disable 2 | -------------------------------------------------------------------------------- /src/fixtures/bad-modules/object.js: -------------------------------------------------------------------------------- 1 | // prettier-ignore 2 | { x: 1 } // eslint-disable-line 3 | -------------------------------------------------------------------------------- /src/fixtures/bad-modules/text.js: -------------------------------------------------------------------------------- 1 | hello // eslint-disable-line 2 | -------------------------------------------------------------------------------- /src/fixtures/bad-plugins/long-async/extensions/longAsyncExtension.ts: -------------------------------------------------------------------------------- 1 | module.exports = () => new Promise((resolve) => setTimeout(resolve, 11000)) 2 | -------------------------------------------------------------------------------- /src/fixtures/good-modules/async-function.js: -------------------------------------------------------------------------------- 1 | const after = (time) => { 2 | return new Promise((resolve) => { 3 | setTimeout(resolve, time) 4 | }) 5 | } 6 | 7 | async function hi() { 8 | await after(50) 9 | return 'hi' 10 | } 11 | 12 | module.exports = { hi } 13 | -------------------------------------------------------------------------------- /src/fixtures/good-modules/module-exports-fat-arrow-fn.js: -------------------------------------------------------------------------------- 1 | module.exports = { run: () => 'hi' } 2 | -------------------------------------------------------------------------------- /src/fixtures/good-modules/module-exports-function.js: -------------------------------------------------------------------------------- 1 | module.exports = function hi() { 2 | return 'hi' 3 | } 4 | -------------------------------------------------------------------------------- /src/fixtures/good-modules/module-exports-object.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | hi: function hi() { 3 | return 'hi' 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/args/args.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'args', 3 | defaults: { color: 'blue' }, 4 | } 5 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/args/commands/config.js: -------------------------------------------------------------------------------- 1 | function config(toolbox) { 2 | return toolbox.config.args.color || 'red' 3 | } 4 | 5 | module.exports = { 6 | name: 'config', 7 | run: config, 8 | } 9 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/args/commands/hello.js: -------------------------------------------------------------------------------- 1 | async function hello(toolbox) { 2 | const name = toolbox.parameters.string 3 | if (name) { 4 | return `hi ${name}` 5 | } else { 6 | return 'hi' 7 | } 8 | } 9 | 10 | module.exports = { name: 'hello', alias: 'h', run: hello } 11 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/async-extension/extensions/loadDataExtension.js: -------------------------------------------------------------------------------- 1 | const after = (time) => { 2 | return new Promise((resolve) => { 3 | setTimeout(resolve, time) 4 | }) 5 | } 6 | 7 | module.exports = async (toolbox) => { 8 | toolbox.asyncData = { a: 0 } 9 | await after(50) 10 | toolbox.asyncData = { a: 1 } 11 | } 12 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/auto-detect/commands/detectCommand.js: -------------------------------------------------------------------------------- 1 | module.exports = { name: 'detectCommand', run: async function (toolbox) {} } 2 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/auto-detect/extensions/detectExtension.js: -------------------------------------------------------------------------------- 1 | module.exports = function (toolbox) { 2 | toolbox.detectExtension = { 3 | auto: 'detect', 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/blank-name/blank-name.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { name: '' } 2 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/empty/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/infinitered/gluegun/9332f599353785b20b64b322c59bb9e36137b497/src/fixtures/good-plugins/empty/.gitkeep -------------------------------------------------------------------------------- /src/fixtures/good-plugins/excluded/commands/bar.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'bar', 3 | run: () => {}, 4 | } 5 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/excluded/commands/foo.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'foo', 3 | run: () => {}, 4 | } 5 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/excluded/commands/foo.test.js: -------------------------------------------------------------------------------- 1 | module.exports = () => { 2 | return `I'm not a command, ignore me.` 3 | } 4 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/excluded/extensions/baz-extension.js: -------------------------------------------------------------------------------- 1 | module.exports = (toolbox) => { 2 | toolbox.baz = true 3 | } 4 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/excluded/extensions/baz-extension.test.js: -------------------------------------------------------------------------------- 1 | module.exports = () => { 2 | return `I'm not an extension, ignore me.` 3 | } 4 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/front-matter/commands/full.js: -------------------------------------------------------------------------------- 1 | async function jimmy(toolbox) { 2 | return 123 3 | } 4 | 5 | module.exports = { name: 'full', run: jimmy } 6 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/front-matter/extensions/hello.js: -------------------------------------------------------------------------------- 1 | // @gluegunExtensionName hello 2 | 3 | /** 4 | * An extension that returns very little. 5 | */ 6 | module.exports = function (toolbox) { 7 | toolbox.hello = { 8 | very: 'little', 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/generate-build/build/commands/build/missing.js: -------------------------------------------------------------------------------- 1 | const uniqueTempDir = require('unique-temp-dir') 2 | 3 | async function missing(toolbox) { 4 | const template = 'missing.ejs' 5 | const dir = uniqueTempDir({ create: true }) 6 | const target = `${dir}/missing.txt` 7 | 8 | const result = await toolbox.template.generate({ template, target }) 9 | return result 10 | } 11 | 12 | module.exports = { name: 'missing', run: missing } 13 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/generate-build/build/commands/build/props.js: -------------------------------------------------------------------------------- 1 | const uniqueTempDir = require('unique-temp-dir') 2 | 3 | async function command(toolbox) { 4 | const template = 'props.ejs' 5 | const dir = uniqueTempDir({ create: true }) 6 | const target = `${dir}/props.txt` 7 | const props = { 8 | thing: 'world', 9 | colors: ['red', 'green', 'blue'], 10 | } 11 | 12 | return toolbox.template.generate({ template, target, props }) 13 | } 14 | 15 | module.exports = { name: 'props', run: command } 16 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/generate-build/build/commands/build/simple.js: -------------------------------------------------------------------------------- 1 | const uniqueTempDir = require('unique-temp-dir') 2 | 3 | async function simple(toolbox) { 4 | const template = 'simple.ejs' 5 | const dir = uniqueTempDir({ create: true }) 6 | const target = `${dir}/simple.txt` 7 | 8 | const result = await toolbox.template.generate({ template, target }) 9 | return result 10 | } 11 | 12 | module.exports = { name: 'simple', run: simple } 13 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/generate-build/build/commands/build/special.js: -------------------------------------------------------------------------------- 1 | const uniqueTempDir = require('unique-temp-dir') 2 | 3 | async function command(toolbox) { 4 | const template = 'special.ejs' 5 | const dir = uniqueTempDir({ create: true }) 6 | const target = `${dir}/special.txt` 7 | const props = { thing: toolbox.parameters.first } 8 | const directory = `${__dirname}/../../custom-directory` 9 | 10 | return toolbox.template.generate({ template, target, props, directory }) 11 | } 12 | 13 | module.exports = { name: 'special', run: command } 14 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/generate-build/build/custom-directory/special.ejs: -------------------------------------------------------------------------------- 1 | <%= props.thing %> 2 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/generate-build/build/templates/props.ejs: -------------------------------------------------------------------------------- 1 | <%= camelCase(parameters.first) %> <%= props.thing %> 2 | <%_ props.colors.forEach(function(color) { _%> 3 | <%= color %> 4 | <%_ }) _%> 5 | ***** 6 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/generate-build/build/templates/simple.ejs: -------------------------------------------------------------------------------- 1 | simple file 2 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/generate-build/gluegun.toml: -------------------------------------------------------------------------------- 1 | name = 'generate' 2 | 3 | [[commands]] 4 | description = 'Generates a simple file' 5 | 6 | [[commands]] 7 | description = 'Replaces props in a template.' 8 | 9 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/generate/commands/missing.js: -------------------------------------------------------------------------------- 1 | const uniqueTempDir = require('unique-temp-dir') 2 | 3 | async function missing(toolbox) { 4 | const template = 'missing.ejs' 5 | const dir = uniqueTempDir({ create: true }) 6 | const target = `${dir}/missing.txt` 7 | 8 | const result = await toolbox.template.generate({ template, target }) 9 | return result 10 | } 11 | 12 | module.exports = { name: 'missing', run: missing } 13 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/generate/commands/props.js: -------------------------------------------------------------------------------- 1 | const uniqueTempDir = require('unique-temp-dir') 2 | 3 | async function command(toolbox) { 4 | const template = 'props.ejs' 5 | const dir = uniqueTempDir({ create: true }) 6 | const target = `${dir}/props.txt` 7 | const props = { 8 | thing: 'world', 9 | colors: ['red', 'green', 'blue'], 10 | } 11 | 12 | return toolbox.template.generate({ template, target, props }) 13 | } 14 | 15 | module.exports = { name: 'props', run: command } 16 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/generate/commands/simple.js: -------------------------------------------------------------------------------- 1 | const uniqueTempDir = require('unique-temp-dir') 2 | 3 | async function simple(toolbox) { 4 | const template = 'simple.ejs' 5 | const dir = uniqueTempDir({ create: true }) 6 | const target = `${dir}/simple.txt` 7 | 8 | const result = await toolbox.template.generate({ template, target }) 9 | return result 10 | } 11 | 12 | module.exports = { name: 'simple', run: simple } 13 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/generate/commands/special.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const uniqueTempDir = require('unique-temp-dir') 3 | 4 | async function command(toolbox) { 5 | const template = 'special.ejs' 6 | const dir = uniqueTempDir({ create: true }) 7 | const target = `${dir}/special.txt` 8 | const props = { thing: toolbox.parameters.first } 9 | const directory = path.join(__dirname, '..', 'custom-directory') 10 | 11 | return toolbox.template.generate({ template, target, props, directory }) 12 | } 13 | 14 | module.exports = { name: 'special', run: command } 15 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/generate/custom-directory/special.ejs: -------------------------------------------------------------------------------- 1 | <%= props.thing %> 2 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/generate/gluegun.toml: -------------------------------------------------------------------------------- 1 | name = 'generate' 2 | 3 | [[commands]] 4 | description = 'Generates a simple file' 5 | 6 | [[commands]] 7 | description = 'Replaces props in a template.' 8 | 9 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/generate/templates/props.ejs: -------------------------------------------------------------------------------- 1 | <%= camelCase(parameters.first) %> <%= props.thing %> 2 | <%_ props.colors.forEach(function(color) { _%> 3 | <%= color %> 4 | <%_ }) _%> 5 | ***** 6 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/generate/templates/simple.ejs: -------------------------------------------------------------------------------- 1 | simple file 2 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/hidden/commands/hide.js: -------------------------------------------------------------------------------- 1 | async function hide() { 2 | return 1 3 | } 4 | 5 | module.exports = { name: 'hide', hidden: true, run: hide } 6 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/missing-name/commands/foo.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | run: () => true, 3 | } 4 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/missing-name/gluegun.toml: -------------------------------------------------------------------------------- 1 | test = true 2 | 3 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/nested-build/build/commands/implied/bar.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | run: () => 'implied', 3 | } 4 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/nested-build/build/commands/nested.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'nested', 3 | run: () => {}, 4 | } 5 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/nested-build/build/commands/thing/foo.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'foo', 3 | alias: 'f', 4 | run: toolbox => `nested thing foo in build folder has run with ${toolbox.nestedBuild}`, 5 | } 6 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/nested-build/build/commands/thing/thing.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'thing', 3 | alias: 't', 4 | run: () => {}, 5 | } 6 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/nested-build/build/extensions/nested-build-extension.js: -------------------------------------------------------------------------------- 1 | module.exports = toolbox => { 2 | toolbox.nestedBuild = "loaded extension" 3 | } -------------------------------------------------------------------------------- /src/fixtures/good-plugins/nested/commands/implied/bar.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | run: () => 'implied', 3 | } 4 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/nested/commands/nested.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'nested', 3 | run: () => {}, 4 | } 5 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/nested/commands/thing/foo.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'foo', 3 | alias: 'f', 4 | run: () => 'nested thing foo has run', 5 | } 6 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/nested/commands/thing/thing.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'thing', 3 | alias: 't', 4 | run: () => {}, 5 | } 6 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/simplest/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/infinitered/gluegun/9332f599353785b20b64b322c59bb9e36137b497/src/fixtures/good-plugins/simplest/.gitkeep -------------------------------------------------------------------------------- /src/fixtures/good-plugins/threepack/commands/one.js: -------------------------------------------------------------------------------- 1 | async function one() { 2 | return 1 3 | } 4 | 5 | module.exports = { name: 'one', alias: 'o', run: one } 6 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/threepack/commands/three.js: -------------------------------------------------------------------------------- 1 | async function three() { 2 | return [1, 2, 3] 3 | } 4 | 5 | module.exports = { name: 'three', run: three } 6 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/threepack/commands/two.js: -------------------------------------------------------------------------------- 1 | async function omgTwo(toolbox) { 2 | return 'two' 3 | } 4 | 5 | module.exports = { name: 'two', run: omgTwo } 6 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/threepack/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "threepack": { 3 | "name": "3pack", 4 | "defaults": { 5 | "numbers": 3 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/throws/commands/throw.js: -------------------------------------------------------------------------------- 1 | async function thrower(toolbox) { 2 | throw new Error('thrown an error!') 3 | } 4 | 5 | module.exports = { name: 'throw', run: thrower } 6 | -------------------------------------------------------------------------------- /src/fixtures/good-plugins/throws/gluegun.toml: -------------------------------------------------------------------------------- 1 | name = 'throws' 2 | 3 | [[commands]] 4 | description = 'Throws and error' 5 | 6 | -------------------------------------------------------------------------------- /src/http.ts: -------------------------------------------------------------------------------- 1 | export { http, GluegunHttp } from './toolbox/http-tools' 2 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import * as exported from './index' 3 | 4 | test('create', () => { 5 | expect(exported).toBeTruthy() 6 | expect(typeof exported.build).toBe('function') 7 | const { build } = exported 8 | const runtime = build('test').create() 9 | expect(runtime.brand).toBe('test') 10 | const runtime2 = build().brand('test2').create() 11 | expect(runtime2.brand).toBe('test2') 12 | }) 13 | 14 | test('print', () => { 15 | expect(typeof exported.print.printCommands).toBe('function') 16 | expect(typeof exported.print.info).toBe('function') 17 | }) 18 | 19 | test('strings', () => { 20 | expect(exported.strings.lowerCase('HI')).toBe('hi') 21 | }) 22 | 23 | test('filesystem', () => { 24 | expect(exported.filesystem).toBeTruthy() 25 | expect(exported.filesystem.eol).toBeTruthy() 26 | expect(exported.filesystem.separator).toBeTruthy() 27 | expect(exported.filesystem.cwd()).toBe(process.cwd()) 28 | }) 29 | 30 | test('system', () => { 31 | expect(exported.system).toBeTruthy() 32 | expect(exported.system.which('node')).toBeTruthy() 33 | }) 34 | 35 | test('prompt', () => { 36 | expect(exported.prompt).toBeTruthy() 37 | expect(typeof exported.prompt.confirm).toBeTruthy() 38 | }) 39 | 40 | test('http', () => { 41 | expect(exported.http).toBeTruthy() 42 | expect(typeof exported.http.create).toBeTruthy() 43 | const api = exported.http.create({ baseURL: 'https://api.github.com/v3' }) 44 | expect(typeof api.get).toBe('function') 45 | expect(typeof api.post).toBe('function') 46 | }) 47 | 48 | test('patching', () => { 49 | expect(exported.patching).toBeTruthy() 50 | expect(typeof exported.patching.exists).toBeTruthy() 51 | expect(typeof exported.patching.update).toBeTruthy() 52 | expect(typeof exported.patching.append).toBeTruthy() 53 | expect(typeof exported.patching.prepend).toBeTruthy() 54 | expect(typeof exported.patching.replace).toBeTruthy() 55 | expect(typeof exported.patching.patch).toBeTruthy() 56 | }) 57 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | 3 | // first, do a sniff test to ensure our dependencies are met 4 | const sniff = require('../sniff') 5 | 6 | // check the node version 7 | if (!sniff.isNewEnough) { 8 | console.log('Node.js 7.6+ is required to run. You have ' + sniff.nodeVersion + '. Womp, womp.') 9 | process.exit(1) 10 | } 11 | 12 | // we want to see real exceptions with backtraces and stuff 13 | process.removeAllListeners('unhandledRejection') 14 | process.on('unhandledRejection', (up) => { 15 | throw up 16 | }) 17 | 18 | // export the `build` command 19 | export { build } from './domain/builder' 20 | 21 | // export the Gluegun interface 22 | export { GluegunToolbox, GluegunRunContext, GluegunParameters } from './domain/toolbox' 23 | export { GluegunCommand } from './domain/command' 24 | 25 | // export the toolbox 26 | export { filesystem, GluegunFilesystem } from './toolbox/filesystem-tools' 27 | export { strings, GluegunStrings } from './toolbox/string-tools' 28 | export { print, GluegunPrint } from './toolbox/print-tools' 29 | export { system, GluegunSystem } from './toolbox/system-tools' 30 | export { semver, GluegunSemver } from './toolbox/semver-tools' 31 | export { http, GluegunHttp } from './toolbox/http-tools' 32 | export { patching, GluegunPatching, GluegunPatchingPatchOptions } from './toolbox/patching-tools' 33 | export { prompt, GluegunPrompt } from './toolbox/prompt-tools' 34 | export { packageManager, GluegunPackageManager } from './toolbox/package-manager-tools' 35 | 36 | // TODO: can't export these tools directly as they require the toolbox to run 37 | // need ideas on how to handle this 38 | export { GluegunTemplate } from './toolbox/template-types' 39 | export { GluegunMeta } from './core-extensions/meta-extension' 40 | 41 | // this adds the node_modules path to the "search path" 42 | // it's hacky, but it works well! 43 | require('app-module-path').addPath(path.join(__dirname, '..', 'node_modules')) 44 | require('app-module-path').addPath(process.cwd()) 45 | -------------------------------------------------------------------------------- /src/loaders/command-loader.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import { Toolbox } from '../domain/toolbox' 3 | import { loadCommandFromFile, loadCommandFromPreload } from './command-loader' 4 | 5 | test('loading from a missing file', async () => { 6 | await expect(() => loadCommandFromFile('foo.js')).toThrowError( 7 | "Error: couldn't load command (this isn't a file): foo.js", 8 | ) 9 | }) 10 | 11 | test('deals with weird input', async () => { 12 | await expect(() => loadCommandFromFile('')).toThrowError("Error: couldn't load command (file is blank): ") 13 | }) 14 | 15 | test('open a weird js file', async () => { 16 | const file = `${__dirname}/../fixtures/bad-modules/text.js` 17 | await expect(() => loadCommandFromFile(file)).toThrowError(`hello is not defined`) 18 | }) 19 | 20 | test('default but no run property exported', async () => { 21 | const file = `${__dirname}/../fixtures/good-modules/module-exports-object.js` 22 | await expect(() => loadCommandFromFile(file)).toThrowError( 23 | `Error: Couldn't load command module-exports-object -- needs a "run" property with a function.`, 24 | ) 25 | }) 26 | 27 | test('fat arrows', async () => { 28 | const file = `${__dirname}/../fixtures/good-modules/module-exports-fat-arrow-fn.js` 29 | await expect(() => loadCommandFromFile(file)).not.toThrow() 30 | }) 31 | 32 | test('load command from preload', async () => { 33 | const command = loadCommandFromPreload({ 34 | name: 'hello', 35 | description: 'yiss dream', 36 | alias: ['z'], 37 | dashed: true, 38 | run: (_toolbox) => 'ran!', 39 | }) 40 | 41 | expect(command.name).toBe('hello') 42 | expect(command.description).toBe('yiss dream') 43 | expect(command.hidden).toBe(false) 44 | expect(command.alias).toEqual(['z']) 45 | expect(command.run(new Toolbox())).toBe('ran!') 46 | expect(command.file).toBe(null) 47 | expect(command.dashed).toBe(true) 48 | expect(command.commandPath).toEqual(['hello']) 49 | }) 50 | -------------------------------------------------------------------------------- /src/loaders/command-loader.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import { isNil, last, reject, is, takeLast } from '../toolbox/utils' 3 | import { Command, GluegunCommand } from '../domain/command' 4 | import { filesystem } from '../toolbox/filesystem-tools' 5 | import { strings } from '../toolbox/string-tools' 6 | import { loadModule } from './module-loader' 7 | import { Options } from '../domain/options' 8 | 9 | /** 10 | * Loads the command from the given file. 11 | * 12 | * @param file The full path to the file to load. 13 | * @return The loaded command. 14 | */ 15 | export function loadCommandFromFile(file: string, options: Options = {}): Command { 16 | const command = new Command() 17 | 18 | // sanity check the input 19 | if (strings.isBlank(file)) { 20 | throw new Error(`Error: couldn't load command (file is blank): ${file}`) 21 | } 22 | 23 | // not a file? 24 | if (filesystem.isNotFile(file)) { 25 | throw new Error(`Error: couldn't load command (this isn't a file): ${file}`) 26 | } 27 | 28 | // remember the file 29 | command.file = file 30 | // default name is the name without the file extension 31 | 32 | command.name = (filesystem.inspect(file) as any).name.split('.')[0] 33 | 34 | // strip the extension from the end of the commandPath 35 | command.commandPath = (options.commandPath || last(file.split('commands' + path.sep)).split(path.sep)).map((f) => 36 | [`${command.name}.js`, `${command.name}.ts`].includes(f) ? command.name : f, 37 | ) 38 | 39 | // if the last two elements of the commandPath are the same, remove the last one 40 | const lastElems = takeLast(2, command.commandPath) 41 | if (lastElems.length === 2 && lastElems[0] === lastElems[1]) { 42 | command.commandPath = command.commandPath.slice(0, -1) 43 | } 44 | 45 | // require in the module -- best chance to bomb is here 46 | let commandModule = loadModule(file) 47 | 48 | // if they use `export default` rather than `module.exports =`, we extract that 49 | commandModule = commandModule.default || commandModule 50 | 51 | // is it a valid commandModule? 52 | const valid = commandModule && typeof commandModule === 'object' && typeof commandModule.run === 'function' 53 | 54 | if (valid) { 55 | command.name = commandModule.name || last(command.commandPath) 56 | command.description = commandModule.description 57 | command.hidden = Boolean(commandModule.hidden) 58 | command.alias = reject(isNil, is(Array, commandModule.alias) ? commandModule.alias : [commandModule.alias]) 59 | command.run = commandModule.run 60 | } else { 61 | throw new Error(`Error: Couldn't load command ${command.name} -- needs a "run" property with a function.`) 62 | } 63 | 64 | return command 65 | } 66 | 67 | export function loadCommandFromPreload(preload: GluegunCommand): Command { 68 | const command = new Command() 69 | command.name = preload.name 70 | command.description = preload.description 71 | command.hidden = Boolean(preload.hidden) 72 | command.alias = preload.alias 73 | command.run = preload.run 74 | command.file = null 75 | command.dashed = Boolean(preload.dashed) 76 | command.commandPath = preload.commandPath || [preload.name] 77 | return command 78 | } 79 | -------------------------------------------------------------------------------- /src/loaders/config-loader.ts: -------------------------------------------------------------------------------- 1 | import { cosmiconfigSync } from 'cosmiconfig' 2 | import { Options } from '../domain/options' 3 | 4 | /** 5 | * Loads the config for the app via CosmicConfig by searching in a few places. 6 | * 7 | * @param name The base name of the config to load. 8 | * @param src The directory to look in. 9 | */ 10 | export function loadConfig(name: string, src: string): Options { 11 | // attempt to load 12 | const cosmic: Options = cosmiconfigSync(name || '').search(src || '') 13 | 14 | // use what we found or fallback to an empty object 15 | const config = (cosmic && cosmic.config) || {} 16 | return config 17 | } 18 | -------------------------------------------------------------------------------- /src/loaders/extension-loader.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import { loadExtensionFromFile } from './extension-loader' 3 | 4 | test('loading from a missing file', async () => { 5 | await expect(() => loadExtensionFromFile('foo.js', 'extension')).toThrowError( 6 | `Error: couldn't load command (not a file): foo.js`, 7 | ) 8 | }) 9 | 10 | test('deals with wierd input', async () => { 11 | await expect(() => loadExtensionFromFile('')).toThrowError(`Error: couldn't load extension (file is blank): `) 12 | }) 13 | 14 | test('open a wierd js file', async () => { 15 | const file = `${__dirname}/../fixtures/bad-modules/text.js` 16 | await expect(() => loadExtensionFromFile(file, 'extension')).toThrowError(`hello is not defined`) 17 | }) 18 | 19 | test('default but none exported', async () => { 20 | const file = `${__dirname}/../fixtures/good-modules/module-exports-object.js` 21 | await expect(() => loadExtensionFromFile(file, 'extension')).toThrowError( 22 | `Error: couldn't load module-exports-object. Expected a function, got [object Object].`, 23 | ) 24 | }) 25 | 26 | test('has front matter', async () => { 27 | const file = `${__dirname}/../fixtures/good-plugins/front-matter/extensions/hello.js` 28 | const extension = loadExtensionFromFile(file, 'extension') 29 | expect(typeof extension.setup).toBe('function') 30 | expect(extension.name).toBe('hello') 31 | }) 32 | -------------------------------------------------------------------------------- /src/loaders/extension-loader.ts: -------------------------------------------------------------------------------- 1 | import { Extension } from '../domain/extension' 2 | import { filesystem } from '../toolbox/filesystem-tools' 3 | import { strings } from '../toolbox/string-tools' 4 | import { loadModule } from './module-loader' 5 | import { EmptyToolbox, Toolbox } from '../domain/toolbox' 6 | 7 | /** 8 | * Loads the extension from a file. 9 | * 10 | * @param file The full path to the file to load. 11 | * @param options Options, such as 12 | */ 13 | export function loadExtensionFromFile(file: string, _options = {}): Extension { 14 | const extension = new Extension() 15 | 16 | // sanity check the input 17 | if (strings.isBlank(file)) { 18 | throw new Error(`Error: couldn't load extension (file is blank): ${file}`) 19 | } 20 | 21 | extension.file = file 22 | 23 | // not a file? 24 | if (filesystem.isNotFile(file)) { 25 | throw new Error(`Error: couldn't load command (not a file): ${file}`) 26 | } 27 | 28 | // default is the name of the file without the extension 29 | extension.name = (filesystem.inspect(file) as any).name.split('.')[0] 30 | 31 | // require in the module -- best chance to bomb is here 32 | let extensionModule = loadModule(file) 33 | 34 | // if they use `export default` rather than `module.exports =`, we extract that 35 | extensionModule = extensionModule.default || extensionModule 36 | 37 | // should we try the default export? 38 | const valid = extensionModule && typeof extensionModule === 'function' 39 | 40 | if (valid) { 41 | extension.setup = (toolbox: EmptyToolbox) => extensionModule(toolbox as Toolbox) 42 | } else { 43 | throw new Error(`Error: couldn't load ${extension.name}. Expected a function, got ${extensionModule}.`) 44 | } 45 | 46 | return extension 47 | } 48 | -------------------------------------------------------------------------------- /src/loaders/module-loader.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import { loadModule } from './module-loader' 3 | 4 | test('handles weird input', () => { 5 | expect(() => loadModule('')).toThrow() 6 | expect(() => loadModule(1)).toThrow() 7 | expect(() => loadModule(1.1)).toThrow() 8 | expect(() => loadModule(true)).toThrow() 9 | expect(() => loadModule(false)).toThrow() 10 | expect(() => loadModule([])).toThrow() 11 | expect(() => loadModule({})).toThrow() 12 | expect(() => loadModule(() => null)).toThrow() 13 | }) 14 | 15 | test('detects missing file', () => { 16 | expect(() => loadModule(`${__dirname}/../fixtures/bad-modules/missing.js`)).toThrow() 17 | }) 18 | 19 | test('detects directory', () => { 20 | expect(() => loadModule(`${__dirname}/../fixtures/bad-modules`)).toThrow() 21 | }) 22 | 23 | test('handles blank files', () => { 24 | const m = loadModule(`${__dirname}/../fixtures/bad-modules/blank.js`) 25 | expect(typeof m).toBe('object') 26 | expect(Object.keys(m)).toEqual([]) 27 | }) 28 | 29 | test('handles files with just a number', () => { 30 | const m = loadModule(`${__dirname}/../fixtures/bad-modules/number.js`) 31 | expect(typeof m).toBe('number') 32 | expect(Object.keys(m)).toEqual([]) 33 | }) 34 | 35 | test('handles files with just text', () => { 36 | expect(() => loadModule(`${__dirname}/../fixtures/bad-modules/text.js`)).toThrow() 37 | }) 38 | 39 | test('handles files with an object', () => { 40 | const m = loadModule(`${__dirname}/../fixtures/bad-modules/object.js`) 41 | expect(typeof m).toBe('object') 42 | expect(Object.keys(m)).toEqual([]) 43 | }) 44 | 45 | test('export default function', () => { 46 | const m = loadModule(`${__dirname}/../fixtures/good-modules/module-exports-function.js`) 47 | expect(typeof m).toBe('function') 48 | expect(m()).toBe('hi') 49 | }) 50 | 51 | test('export default {}', async () => { 52 | const m = loadModule(`${__dirname}/../fixtures/good-modules/module-exports-object.js`) 53 | expect(typeof m).toBe('object') 54 | expect(await m.hi()).toBe('hi') 55 | }) 56 | 57 | test('module.exports fat arrow function', () => { 58 | const m = loadModule(`${__dirname}/../fixtures/good-modules/module-exports-fat-arrow-fn.js`) 59 | expect(typeof m.run).toBe('function') 60 | expect(m.run()).toBe('hi') 61 | }) 62 | 63 | test('async function', async () => { 64 | const m = loadModule(`${__dirname}/../fixtures/good-modules/async-function.js`) 65 | expect(typeof m).toBe('object') 66 | expect(await m.hi()).toBe('hi') 67 | }) 68 | 69 | test('deals with dupes', async () => { 70 | const m = loadModule(`${__dirname}/../fixtures/good-modules/async-function.js`) 71 | const n = loadModule(`${__dirname}/../fixtures/good-modules/../good-modules/async-function.js`) 72 | expect(m).toBe(n) 73 | }) 74 | -------------------------------------------------------------------------------- /src/loaders/module-loader.ts: -------------------------------------------------------------------------------- 1 | import { filesystem } from '../toolbox/filesystem-tools' 2 | import { strings } from '../toolbox/string-tools' 3 | 4 | // try loading this module 5 | export function loadModule(path) { 6 | if (strings.isBlank(path)) { 7 | throw new Error('path is required') 8 | } 9 | if (filesystem.isNotFile(path)) { 10 | throw new Error(`${path} is not a file`) 11 | } 12 | 13 | require.resolve(path) 14 | return require(path) 15 | } 16 | -------------------------------------------------------------------------------- /src/loaders/plugin-loader.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import * as jetpack from 'fs-jetpack' 3 | import { Plugin } from '../domain/plugin' 4 | import { Options } from '../domain/options' 5 | import { filesystem } from '../toolbox/filesystem-tools' 6 | import { strings } from '../toolbox/string-tools' 7 | import { loadCommandFromFile, loadCommandFromPreload } from './command-loader' 8 | import { loadConfig } from './config-loader' 9 | import { loadExtensionFromFile } from './extension-loader' 10 | 11 | /** 12 | * Loads a plugin from a directory. 13 | * 14 | * @param directory The full path to the directory to load. 15 | * @param options Additional options to customize the loading process. 16 | */ 17 | export function loadPluginFromDirectory(directory: string, options: Options = {}): Plugin { 18 | const plugin = new Plugin() 19 | 20 | const { 21 | brand = 'gluegun', 22 | commandFilePattern = [`*.{js,ts}`, `!*.test.{js,ts}`], 23 | extensionFilePattern = [`*.{js,ts}`, `!*.test.{js,ts}`], 24 | hidden = false, 25 | name, 26 | } = options 27 | 28 | plugin.hidden = Boolean(options.hidden) 29 | 30 | if (!strings.isBlank(name)) { 31 | plugin.name = name 32 | } 33 | 34 | // directory check 35 | if (filesystem.isNotDirectory(directory)) { 36 | throw new Error(`Error: couldn't load plugin (not a directory): ${directory}`) 37 | } 38 | 39 | plugin.directory = directory 40 | 41 | // the directory is the default name (unless we were told what it was) 42 | if (strings.isBlank(name)) { 43 | plugin.name = jetpack.inspect(directory).name 44 | } 45 | 46 | const jetpackPlugin = jetpack.cwd(plugin.directory) 47 | 48 | // load any default commands passed in 49 | plugin.commands = (options.preloadedCommands || []).map(loadCommandFromPreload) 50 | 51 | // load the commands found in the commands sub-directory 52 | const commandSearchDirectories = ['commands', 'build/commands'] 53 | commandSearchDirectories.forEach((dir) => { 54 | if (jetpackPlugin.exists(dir) === 'dir') { 55 | const commands = jetpackPlugin.cwd(dir).find({ matching: commandFilePattern, recursive: true }) 56 | 57 | plugin.commands = plugin.commands.concat( 58 | commands.map((file) => loadCommandFromFile(path.join(directory, dir, file))), 59 | ) 60 | } 61 | }) 62 | 63 | // load the extensions found in the extensions sub-directory 64 | const extensionSearchDirectories = ['extensions', 'build/extensions'] 65 | extensionSearchDirectories.forEach((dir) => { 66 | if (jetpackPlugin.exists(dir) === 'dir') { 67 | const extensions = jetpackPlugin.cwd(dir).find({ matching: extensionFilePattern, recursive: false }) 68 | 69 | plugin.extensions = plugin.extensions.concat( 70 | extensions.map((file) => loadExtensionFromFile(`${directory}/${dir}/${file}`)), 71 | ) 72 | } 73 | }) 74 | 75 | // load config using cosmiconfig 76 | const config = loadConfig(plugin.name, directory) 77 | 78 | // set the name if we have one (unless we were told what it was) 79 | plugin.name = config.name || plugin.name 80 | plugin[brand] = config[brand] 81 | plugin.defaults = config.defaults || {} 82 | plugin.description = config.description 83 | 84 | // set the hidden bit 85 | if (hidden) { 86 | plugin.commands.forEach((command) => (command.hidden = true)) 87 | } 88 | 89 | // set all commands to reference their parent plugin 90 | plugin.commands.forEach((c) => (c.plugin = plugin)) 91 | 92 | // sort plugin commands alphabetically 93 | plugin.commands = plugin.commands.sort((a, b) => (a.commandPath.join(' ') < b.commandPath.join(' ') ? -1 : 1)) 94 | 95 | return plugin 96 | } 97 | -------------------------------------------------------------------------------- /src/package-manager.ts: -------------------------------------------------------------------------------- 1 | export { packageManager, GluegunPackageManager } from './toolbox/package-manager-tools' 2 | -------------------------------------------------------------------------------- /src/patching.ts: -------------------------------------------------------------------------------- 1 | export { patching, GluegunPatching, GluegunPatchingPatchOptions } from './toolbox/patching-tools' 2 | -------------------------------------------------------------------------------- /src/print.ts: -------------------------------------------------------------------------------- 1 | export { print, GluegunPrint } from './toolbox/print-tools' 2 | -------------------------------------------------------------------------------- /src/prompt.ts: -------------------------------------------------------------------------------- 1 | export { prompt, GluegunPrompt } from './toolbox/prompt-tools' 2 | -------------------------------------------------------------------------------- /src/runtime/run.ts: -------------------------------------------------------------------------------- 1 | import { EmptyToolbox, GluegunToolbox } from '../domain/toolbox' 2 | import { createParams, parseParams } from '../toolbox/parameter-tools' 3 | import { Runtime } from './runtime' 4 | import { findCommand } from './runtime-find-command' 5 | import { Options } from '../domain/options' 6 | import { loadConfig } from '../loaders/config-loader' 7 | 8 | /** 9 | * Runs a command. 10 | * 11 | * @param rawCommand Command string or array of strings. 12 | * @param extraOptions Additional options use to execute a command. 13 | * @return The Toolbox object indicating what happened. 14 | */ 15 | export async function run( 16 | this: Runtime, 17 | rawCommand?: string | string[], 18 | extraOptions: Options = {}, 19 | ): Promise { 20 | // use provided rawCommand or process arguments if none given 21 | rawCommand = rawCommand || process.argv 22 | 23 | // prepare the run toolbox 24 | const toolbox = new EmptyToolbox() 25 | 26 | // attach the runtime 27 | toolbox.runtime = this 28 | 29 | // parse the parameters initially 30 | toolbox.parameters = parseParams(rawCommand, extraOptions) 31 | 32 | // find the command, and parse out aliases 33 | const { command, array } = findCommand(this, toolbox.parameters) 34 | 35 | // rebuild the parameters, now that we know the plugin and command 36 | toolbox.parameters = createParams({ 37 | plugin: command.plugin && command.plugin.name, 38 | command: command.name, 39 | array, 40 | options: toolbox.parameters.options, 41 | raw: rawCommand, 42 | argv: process.argv, 43 | }) 44 | 45 | // set a few properties 46 | toolbox.plugin = command.plugin || this.defaultPlugin 47 | toolbox.command = command 48 | toolbox.pluginName = toolbox.plugin && toolbox.plugin.name 49 | toolbox.commandName = command.name 50 | 51 | // setup the config 52 | toolbox.config = { ...this.config } 53 | if (toolbox.pluginName) { 54 | toolbox.config[toolbox.pluginName] = { 55 | ...toolbox.plugin.defaults, 56 | ...((this.defaults && this.defaults[toolbox.pluginName]) || {}), 57 | } 58 | } 59 | 60 | // expose cosmiconfig 61 | toolbox.config.loadConfig = loadConfig 62 | 63 | // allow extensions to attach themselves to the toolbox 64 | const extensionSetupPromises = this.extensions.map((extension) => { 65 | const setupResult = extension.setup(toolbox) 66 | return setupResult === undefined ? Promise.resolve(null) : Promise.resolve(setupResult) 67 | }) 68 | await Promise.all(extensionSetupPromises) 69 | 70 | // check for updates 71 | if (this.checkUpdate) { 72 | const updateAvailable = await toolbox.meta.checkForUpdate() 73 | if (updateAvailable) { 74 | console.log(`Update available: ${updateAvailable}`) 75 | } 76 | } 77 | 78 | // kick it off 79 | if (toolbox.command.run) { 80 | // run the command 81 | toolbox.result = await toolbox.command.run(toolbox as GluegunToolbox) 82 | } 83 | 84 | // recast it 85 | const finalToolbox = toolbox as GluegunToolbox 86 | 87 | return finalToolbox 88 | } 89 | -------------------------------------------------------------------------------- /src/runtime/runtime-config.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import { Runtime } from './runtime' 3 | 4 | test('can read from config', async () => { 5 | const r = new Runtime() 6 | r.addCoreExtensions() 7 | const plugin = r.addPlugin(`${__dirname}/../fixtures/good-plugins/args`) 8 | const toolbox = await r.run('config') 9 | 10 | expect(plugin.defaults).toBeTruthy() 11 | expect(plugin.defaults.color).toBe('blue') 12 | expect(toolbox.result).toBe('blue') 13 | }) 14 | 15 | test('project config trumps plugin config', async () => { 16 | const r = new Runtime() 17 | r.addCoreExtensions() 18 | r.defaults = { args: { color: 'red' } } 19 | const plugin = r.addPlugin(`${__dirname}/../fixtures/good-plugins/args`) 20 | const toolbox = await r.run('config') 21 | 22 | expect(plugin.defaults).toBeTruthy() 23 | expect(plugin.defaults.color).toBe('blue') 24 | expect(toolbox.result).toBe('red') 25 | }) 26 | -------------------------------------------------------------------------------- /src/runtime/runtime-extensions.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import { Runtime } from './runtime' 3 | 4 | test('loads the core extensions in the right order', () => { 5 | const r = new Runtime() 6 | r.addCoreExtensions() 7 | const list = r.extensions.map((x) => x.name).join(', ') 8 | 9 | expect(list).toBe( 10 | 'meta, strings, print, filesystem, semver, system, prompt, http, template, patching, package-manager', 11 | ) 12 | }) 13 | 14 | test('loads async extensions correctly', async () => { 15 | const r = new Runtime() 16 | r.addPlugin(`${__dirname}/../fixtures/good-plugins/async-extension`) 17 | r.addPlugin(`${__dirname}/../fixtures/good-plugins/threepack`) 18 | 19 | const toolbox = await r.run('three') 20 | expect(toolbox.asyncData).toBeDefined() 21 | expect(toolbox.asyncData.a).toEqual(1) 22 | }) 23 | -------------------------------------------------------------------------------- /src/runtime/runtime-find-command.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '../domain/command' 2 | import { Runtime } from './runtime' 3 | import { GluegunParameters, GluegunToolbox } from '../domain/toolbox' 4 | import { equals } from '../toolbox/utils' 5 | 6 | /** 7 | * This function performs some somewhat complex logic to find a command for a given 8 | * set of parameters and plugins. 9 | * 10 | * @param runtime The current runtime. 11 | * @param parameters The parameters passed in 12 | * @returns object with plugin, command, and array 13 | */ 14 | export function findCommand(runtime: Runtime, parameters: GluegunParameters): { command: Command; array: string[] } { 15 | // the commandPath, which could be something like: 16 | // > movie list actors 2015 17 | // [ 'list', 'actors', '2015' ] 18 | // here, the '2015' might not actually be a command, but it's part of it 19 | const commandPath = parameters.array 20 | 21 | // the part of the commandPath that doesn't match a command 22 | // in the above example, it will end up being [ '2015' ] 23 | let tempPathRest = commandPath 24 | let commandPathRest = tempPathRest 25 | 26 | // a fallback command 27 | const commandNotFound = new Command({ 28 | run: (_toolbox: GluegunToolbox) => { 29 | throw new Error(`Couldn't find that command, and no default command set.`) 30 | }, 31 | }) 32 | 33 | // the resolved command will live here 34 | // start by setting it to the default command, in case we don't find one 35 | let targetCommand: Command = runtime.defaultCommand || commandNotFound 36 | 37 | // if the commandPath is empty, it could be a dashed command, like --help 38 | if (commandPath.length === 0) { 39 | targetCommand = findDashedCommand(runtime.commands, parameters.options) || targetCommand 40 | } 41 | 42 | // store the resolved path as we go 43 | let resolvedPath: string[] = [] 44 | 45 | // we loop through each segment of the commandPath, looking for aliases among 46 | // parent commands, and expand those. 47 | commandPath.forEach((currName: string) => { 48 | // cut another piece off the front of the commandPath 49 | tempPathRest = tempPathRest.slice(1) 50 | 51 | // find a command that fits the previous path + currentName, which can be an alias 52 | const segmentCommand = runtime.commands 53 | .slice() // dup so we keep the original order 54 | .sort(sortCommands) 55 | .find((command) => equals(command.commandPath.slice(0, -1), resolvedPath) && command.matchesAlias(currName)) 56 | 57 | if (segmentCommand) { 58 | // found another candidate as the "endpoint" command 59 | targetCommand = segmentCommand 60 | 61 | // since we found a command, the "commandPathRest" gets updated to the tempPathRest 62 | commandPathRest = tempPathRest 63 | 64 | // add the current command to the resolvedPath 65 | resolvedPath = resolvedPath.concat([segmentCommand.name]) 66 | } else { 67 | // no command found, let's add the segment as-is to the command path 68 | resolvedPath = resolvedPath.concat([currName]) 69 | } 70 | }, []) 71 | 72 | return { command: targetCommand, array: commandPathRest } 73 | } 74 | 75 | // sorts shortest to longest commandPaths, so we always check the shortest ones first 76 | function sortCommands(a, b) { 77 | return a.commandPath.length < b.commandPath.length ? -1 : 1 78 | } 79 | 80 | // finds dashed commands 81 | function findDashedCommand(commands, options) { 82 | const dashedOptions = Object.keys(options).filter((k) => options[k] === true) 83 | return commands.filter((c) => c.dashed).find((c) => c.matchesAlias(dashedOptions)) 84 | } 85 | -------------------------------------------------------------------------------- /src/runtime/runtime-parameters.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import { Runtime } from './runtime' 3 | 4 | test('can pass arguments', async () => { 5 | const r = new Runtime() 6 | r.addCoreExtensions() 7 | r.addPlugin(`${__dirname}/../fixtures/good-plugins/args`) 8 | const { command, parameters } = await r.run('hello steve kellock', { caps: false }) 9 | 10 | expect(parameters.string).toBe('steve kellock') 11 | expect(parameters.first).toBe('steve') 12 | expect(parameters.second).toBe('kellock') 13 | expect(parameters.command).toBe('hello') 14 | expect(parameters.plugin).toBe('args') 15 | expect(parameters.string).toBe('steve kellock') 16 | expect(parameters.array).toEqual(['steve', 'kellock']) 17 | expect(parameters.options).toEqual({ caps: false }) 18 | expect(command.commandPath).toEqual(['hello']) 19 | }) 20 | 21 | test('can pass arguments, even with nested alias', async () => { 22 | const r = new Runtime() 23 | r.addCoreExtensions() 24 | r.addPlugin(`${__dirname}/../fixtures/good-plugins/nested`) 25 | const { command, parameters } = await r.run('t f jamon holmgren', { chocolate: true }) 26 | 27 | expect(parameters.string).toBe('jamon holmgren') 28 | expect(parameters.first).toBe('jamon') 29 | expect(parameters.second).toBe('holmgren') 30 | expect(parameters.command).toBe('foo') 31 | expect(parameters.plugin).toBe('nested') 32 | expect(parameters.string).toBe('jamon holmgren') 33 | expect(parameters.array).toEqual(['jamon', 'holmgren']) 34 | expect(parameters.options).toEqual({ chocolate: true }) 35 | expect(command.commandPath).toEqual(['thing', 'foo']) 36 | }) 37 | 38 | test('can pass arguments with mixed options', async () => { 39 | const r = new Runtime() 40 | r.addCoreExtensions() 41 | r.addPlugin(`${__dirname}/../fixtures/good-plugins/args`) 42 | const { command, parameters } = await r.run('--chocolate=true --foo -n 1 hello steve kellock') 43 | expect(command.commandPath).toEqual(['hello']) 44 | expect(parameters.string).toBe('steve kellock') 45 | expect(parameters.first).toBe('steve') 46 | expect(parameters.second).toBe('kellock') 47 | expect(parameters.command).toBe('hello') 48 | expect(parameters.options.foo).toBe(true) 49 | expect(parameters.options.n).toBe(1) 50 | expect(parameters.options.chocolate).toBe('true') 51 | }) 52 | 53 | test('properly infers the heirarchy from folder structure', async () => { 54 | const r = new Runtime() 55 | r.addCoreExtensions() 56 | r.addPlugin(`${__dirname}/../fixtures/good-plugins/nested`) 57 | const { command, parameters } = await r.run('implied bar thing --foo=1 --force') 58 | 59 | expect(command.commandPath).toEqual(['implied', 'bar']) 60 | expect(parameters.string).toBe('thing') 61 | expect(parameters.command).toBe('bar') 62 | expect(parameters.options.foo).toBe(1) 63 | expect(parameters.options.force).toBe(true) 64 | }) 65 | 66 | test('properly assembles parameters when command and first arg have same name', async () => { 67 | const r = new Runtime() 68 | r.addCoreExtensions() 69 | r.addPlugin(`${__dirname}/../fixtures/good-plugins/threepack`) 70 | const { command, parameters } = await r.run('one one two') 71 | 72 | expect(command.commandPath).toEqual(['one']) 73 | expect(parameters.string).toBe('one two') 74 | expect(parameters.command).toBe('one') 75 | expect(parameters.first).toBe('one') 76 | expect(parameters.second).toBe('two') 77 | }) 78 | -------------------------------------------------------------------------------- /src/runtime/runtime-plugin.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import { Runtime } from './runtime' 3 | 4 | const BAD_PLUGIN_PATH = `${__dirname}/../fixtures/does-not-exist` 5 | 6 | test('load a directory', () => { 7 | const r = new Runtime() 8 | r.addCoreExtensions() 9 | r.addPlugin(`${__dirname}/../fixtures/good-plugins/simplest`) 10 | r.addPlugin(`${__dirname}/../fixtures/good-plugins/threepack`) 11 | expect(r.plugins.length).toBe(2) 12 | }) 13 | 14 | test('hides commands', () => { 15 | const r = new Runtime() 16 | r.addCoreExtensions() 17 | r.addPlugin(`${__dirname}/../fixtures/good-plugins/threepack`, { hidden: true }) 18 | expect(r.plugins.length).toBe(1) 19 | expect(r.plugins[0].commands[2].hidden).toBe(true) 20 | }) 21 | 22 | test('silently ignore plugins with broken dirs', async () => { 23 | const r = new Runtime() 24 | r.addCoreExtensions() 25 | const error = r.addPlugin(BAD_PLUGIN_PATH) 26 | expect(undefined).toBe(error) 27 | }) 28 | 29 | test("throws error if plugin doesn't exist and required: true", async () => { 30 | const r = new Runtime() 31 | r.addCoreExtensions() 32 | await expect(() => r.addPlugin(BAD_PLUGIN_PATH, { required: true })).toThrowError( 33 | `Error: couldn't load plugin (not a directory): ${BAD_PLUGIN_PATH}`, 34 | ) 35 | }) 36 | -------------------------------------------------------------------------------- /src/runtime/runtime-plugins.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import { Runtime } from './runtime' 3 | 4 | test('loads all sub-directories', () => { 5 | const r = new Runtime() 6 | r.addCoreExtensions() 7 | r.addPlugins(`${__dirname}/../fixtures/good-plugins`) 8 | 9 | expect(16).toBe(r.plugins.length) 10 | }) 11 | 12 | test('matches sub-directories', () => { 13 | const r = new Runtime() 14 | r.addCoreExtensions() 15 | r.addPlugins(`${__dirname}/../fixtures/good-plugins`, { matching: 'blank-*' }) 16 | expect(1).toBe(r.plugins.length) 17 | }) 18 | 19 | test('hides commands', () => { 20 | const r = new Runtime() 21 | r.addCoreExtensions() 22 | r.addPlugins(`${__dirname}/../fixtures/good-plugins`, { 23 | matching: 'threepack', 24 | hidden: true, 25 | }) 26 | expect(r.plugins.length).toBe(1) 27 | expect(r.plugins[0].commands[2].hidden).toBe(true) 28 | }) 29 | 30 | test('addPlugins ignores bad directories', () => { 31 | const r = new Runtime() 32 | r.addCoreExtensions() 33 | r.addPlugins(__filename) 34 | r.addPlugins(null) 35 | r.addPlugins(undefined) 36 | r.addPlugins('') 37 | expect(0).toBe(r.plugins.length) 38 | }) 39 | 40 | test('commands and defaultCommand work properly even when multiple plugins are loaded', async () => { 41 | const r = new Runtime('default-command') 42 | r.addCoreExtensions() 43 | r.addDefaultPlugin(`${__dirname}/../fixtures/good-plugins/nested`) 44 | r.addCommand({ 45 | name: 'default-command', 46 | run: () => null, 47 | }) 48 | r.addPlugin(`${__dirname}/../fixtures/good-plugins/threepack`) 49 | 50 | expect(2).toBe(r.plugins.length) 51 | 52 | let toolbox = await r.run('') 53 | 54 | expect(toolbox.command.name).toBe('default-command') 55 | 56 | toolbox = await r.run('one') 57 | 58 | expect(toolbox.command.name).toBe('one') 59 | }) 60 | -------------------------------------------------------------------------------- /src/runtime/runtime-run-bad.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import { Runtime } from './runtime' 3 | 4 | test('cannot find a command', async () => { 5 | const r = new Runtime() 6 | r.addCoreExtensions() 7 | await expect(r.run('bloo blah')).rejects.toThrow(`Couldn't find that command, and no default command set.`) 8 | }) 9 | 10 | test('is fatally wounded by exceptions', async () => { 11 | const r = new Runtime() 12 | r.addCoreExtensions() 13 | r.addPlugin(`${__dirname}/../fixtures/good-plugins/throws`) 14 | 15 | // for some reason, t.throws doesn't work on this one ... 16 | try { 17 | await r.run('throw') 18 | } catch (e) { 19 | expect(e.message).toBe(`thrown an error!`) 20 | } 21 | }) 22 | -------------------------------------------------------------------------------- /src/runtime/runtime-run-good.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import { Runtime } from './runtime' 3 | 4 | test('runs a command', async () => { 5 | const r = new Runtime() 6 | r.addCoreExtensions() 7 | r.addPlugin(`${__dirname}/../fixtures/good-plugins/threepack`) 8 | const toolbox = await r.run('three') 9 | 10 | expect(toolbox.result).toEqual([1, 2, 3]) 11 | }) 12 | 13 | test('runs an aliased command', async () => { 14 | const r = new Runtime() 15 | r.addCoreExtensions() 16 | r.addPlugin(`${__dirname}/../fixtures/good-plugins/threepack`) 17 | const toolbox = await r.run('o') 18 | 19 | expect(toolbox.result).toBe(1) 20 | }) 21 | 22 | test('runs a nested command', async () => { 23 | const r = new Runtime() 24 | r.addCoreExtensions() 25 | r.addPlugin(`${__dirname}/../fixtures/good-plugins/nested`) 26 | const toolbox = await r.run('thing foo') 27 | expect(toolbox.command).toBeTruthy() 28 | expect(toolbox.command.name).toBe('foo') 29 | expect(toolbox.command.commandPath).toEqual(['thing', 'foo']) 30 | expect(toolbox.result).toBe('nested thing foo has run') 31 | }) 32 | 33 | test('runs a nested command in build folder', async () => { 34 | const r = new Runtime() 35 | r.addCoreExtensions() 36 | r.addPlugin(`${__dirname}/../fixtures/good-plugins/nested-build`) 37 | const toolbox = await r.run('thing foo') 38 | expect(toolbox.command).toBeTruthy() 39 | expect(toolbox.command.name).toBe('foo') 40 | expect(toolbox.command.commandPath).toEqual(['thing', 'foo']) 41 | expect(toolbox.result).toBe('nested thing foo in build folder has run with loaded extension') 42 | }) 43 | 44 | test('runs a command with no name prop', async () => { 45 | const r = new Runtime() 46 | r.addCoreExtensions() 47 | r.addPlugin(`${__dirname}/../fixtures/good-plugins/missing-name`) 48 | const toolbox = await r.run('foo') 49 | expect(toolbox.command).toBeTruthy() 50 | expect(toolbox.command.name).toBe('foo') 51 | }) 52 | -------------------------------------------------------------------------------- /src/runtime/runtime-src.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import { Runtime } from './runtime' 3 | 4 | test('runs a command explicitly', async () => { 5 | const r = new Runtime() 6 | r.addCoreExtensions() 7 | expect(r.defaultPlugin).toBeFalsy() 8 | r.addDefaultPlugin(`${__dirname}/../fixtures/good-plugins/threepack`) 9 | expect(r.defaultPlugin).toBeTruthy() 10 | const toolbox = await r.run('three') 11 | 12 | expect(toolbox.plugin).toBeTruthy() 13 | expect(toolbox.command).toBeTruthy() 14 | expect(toolbox.plugin.name).toBe('3pack') 15 | expect(toolbox.command.name).toBe('three') 16 | expect(toolbox.result).toEqual([1, 2, 3]) 17 | }) 18 | 19 | test('runs a command via passed in args', async () => { 20 | const r = new Runtime() 21 | r.addCoreExtensions() 22 | expect(r.defaultPlugin).toBeFalsy() 23 | r.addDefaultPlugin(`${__dirname}/../fixtures/good-plugins/threepack`) 24 | expect(r.defaultPlugin).toBeTruthy() 25 | const toolbox = await r.run('three') 26 | expect(toolbox.plugin).toBeTruthy() 27 | expect(toolbox.command).toBeTruthy() 28 | expect(toolbox.plugin.name).toBe('3pack') 29 | expect(toolbox.command.name).toBe('three') 30 | expect(toolbox.result).toEqual([1, 2, 3]) 31 | }) 32 | -------------------------------------------------------------------------------- /src/semver.ts: -------------------------------------------------------------------------------- 1 | export { semver, GluegunSemver } from './toolbox/semver-tools' 2 | -------------------------------------------------------------------------------- /src/strings.ts: -------------------------------------------------------------------------------- 1 | export { strings, GluegunStrings } from './toolbox/string-tools' 2 | -------------------------------------------------------------------------------- /src/system.ts: -------------------------------------------------------------------------------- 1 | export { system, GluegunSystem } from './toolbox/system-tools' 2 | -------------------------------------------------------------------------------- /src/toolbox/__snapshots__/print-tools.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`info 1`] = ` 4 | Array [ 5 | Array [ 6 | "info", 7 | undefined, 8 | ], 9 | Array [ 10 | "warning!", 11 | undefined, 12 | ], 13 | Array [ 14 | "success!!", 15 | undefined, 16 | ], 17 | Array [ 18 | "highlight", 19 | undefined, 20 | ], 21 | Array [ 22 | "muted", 23 | undefined, 24 | ], 25 | Array [ 26 | "error...", 27 | undefined, 28 | ], 29 | Array [ 30 | "vvv -----[ DEBUG ]----- vvv", 31 | undefined, 32 | ], 33 | Array [ 34 | "debugging...", 35 | undefined, 36 | ], 37 | Array [ 38 | "^^^ -----[ DEBUG ]----- ^^^", 39 | undefined, 40 | ], 41 | Array [ 42 | "vvv -----[ there ]----- vvv", 43 | undefined, 44 | ], 45 | Array [ 46 | "hi", 47 | undefined, 48 | ], 49 | Array [ 50 | "^^^ -----[ there ]----- ^^^", 51 | undefined, 52 | ], 53 | Array [ 54 | "fancyyyyy", 55 | undefined, 56 | ], 57 | Array [ 58 | "---------------------------------------------------------------", 59 | undefined, 60 | ], 61 | Array [ 62 | "", 63 | undefined, 64 | ], 65 | Array [ 66 | " liam 5 67 | matthew 2 ", 68 | undefined, 69 | ], 70 | Array [ 71 | "| liam | 5 | 72 | | ------- | - | 73 | | matthew | 2 |", 74 | undefined, 75 | ], 76 | Array [ 77 | "| liam | 5 | 78 | | ----------- | ----- | 79 | | matthew | 2 |", 80 | undefined, 81 | ], 82 | Array [ 83 | "┌─────────┬───┐ 84 | │ liam │ 5 │ 85 | │ matthew │ 2 │ 86 | └─────────┴───┘", 87 | undefined, 88 | ], 89 | ] 90 | `; 91 | -------------------------------------------------------------------------------- /src/toolbox/filesystem-tools.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import * as expect from 'expect' 3 | import { filesystem } from './filesystem-tools' 4 | 5 | test('isFile', () => { 6 | expect(filesystem.isFile(__filename)).toBe(true) 7 | expect(filesystem.isFile(__dirname)).toBe(false) 8 | }) 9 | 10 | test('isNotFile', () => { 11 | expect(filesystem.isNotFile(__filename)).toBe(false) 12 | expect(filesystem.isNotFile(__dirname)).toBe(true) 13 | }) 14 | 15 | test('isDirectory', () => { 16 | expect(filesystem.isDirectory(__dirname)).toBe(true) 17 | expect(filesystem.isDirectory(__filename)).toBe(false) 18 | }) 19 | 20 | test('isNotDirectory', () => { 21 | expect(filesystem.isNotDirectory(__dirname)).toBe(false) 22 | expect(filesystem.isNotDirectory(__filename)).toBe(true) 23 | }) 24 | 25 | test('subdirectories', () => { 26 | const dirs = filesystem.subdirectories(`${__dirname}/..`) 27 | expect(dirs.length).toBe(8) 28 | expect(dirs).toContain(path.join(__dirname, '..', 'toolbox')) 29 | }) 30 | 31 | test('blank subdirectories', () => { 32 | expect(filesystem.subdirectories('')).toEqual([]) 33 | expect(filesystem.subdirectories(__filename)).toEqual([]) 34 | }) 35 | 36 | test('relative subdirectories', () => { 37 | const dirs = filesystem.subdirectories(`${__dirname}/..`, true) 38 | expect(dirs.length).toBe(8) 39 | expect(dirs).toContain(`toolbox`) 40 | }) 41 | 42 | test('filtered subdirectories', () => { 43 | const dirs = filesystem.subdirectories(`${__dirname}/..`, true, 'to*') 44 | expect(1).toBe(dirs.length) 45 | expect(dirs).toContain(`toolbox`) 46 | }) 47 | 48 | test('path separator', () => { 49 | const sep = filesystem.separator 50 | expect(sep).toBe(require('path').sep) 51 | expect(['/', '\\']).toContain(sep) 52 | }) 53 | -------------------------------------------------------------------------------- /src/toolbox/filesystem-tools.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os' 2 | import * as pathlib from 'path' 3 | import * as jetpack from 'fs-jetpack' 4 | import { chmodSync } from 'fs' 5 | 6 | import { GluegunFilesystem } from './filesystem-types' 7 | 8 | /** 9 | * Is this a file? 10 | * 11 | * @param path The filename to check. 12 | * @returns `true` if the file exists and is a file, otherwise `false`. 13 | */ 14 | function isFile(path: string): boolean { 15 | return jetpack.exists(path) === 'file' 16 | } 17 | 18 | /** 19 | * Is this not a file? 20 | * 21 | * @param path The filename to check 22 | * @return `true` if the file doesn't exist. 23 | */ 24 | const isNotFile = (path: string): boolean => !isFile(path) 25 | 26 | /** 27 | * Is this a directory? 28 | * 29 | * @param path The directory to check. 30 | * @returns True/false -- does the directory exist? 31 | */ 32 | function isDirectory(path: string): boolean { 33 | return jetpack.exists(path) === 'dir' 34 | } 35 | 36 | /** 37 | * Is this not a directory? 38 | * 39 | * @param path The directory to check. 40 | * @return `true` if the directory does not exist, otherwise false. 41 | */ 42 | const isNotDirectory = (path: string): boolean => !isDirectory(path) 43 | 44 | /** 45 | * Gets the immediate subdirectories. 46 | * 47 | * @param path Path to a directory to check. 48 | * @param isRelative Return back the relative directory? 49 | * @param matching A jetpack matching filter 50 | * @return A list of directories 51 | */ 52 | function subdirectories(path: string, isRelative = false, matching = '*', _symlinks = false): string[] { 53 | const { strings } = require('./string-tools') 54 | if (strings.isBlank(path) || !isDirectory(path)) return [] 55 | 56 | const dirs = jetpack.cwd(path).find({ 57 | matching, 58 | directories: true, 59 | recursive: false, 60 | files: false, 61 | }) 62 | if (isRelative) { 63 | return dirs 64 | } else { 65 | return dirs.map((dir) => pathlib.join(path, dir)) 66 | } 67 | } 68 | 69 | const filesystem: GluegunFilesystem = { 70 | chmodSync, 71 | eol: os.EOL, // end of line marker 72 | homedir: os.homedir, // get home directory 73 | separator: pathlib.sep, // path separator 74 | subdirectories, // retrieve subdirectories 75 | isFile, 76 | isNotFile, 77 | isDirectory, 78 | isNotDirectory, 79 | resolve: pathlib.resolve, 80 | // and everything else in jetpack 81 | ...jetpack, 82 | } 83 | 84 | export { filesystem, GluegunFilesystem } 85 | -------------------------------------------------------------------------------- /src/toolbox/filesystem-types.ts: -------------------------------------------------------------------------------- 1 | import { FSJetpack } from 'fs-jetpack/types' 2 | 3 | export interface GluegunFilesystem extends FSJetpack { 4 | /** 5 | * Convenience property for `os.EOL`. 6 | */ 7 | eol: string 8 | 9 | /** 10 | * Convenience property for `path.sep`. 11 | */ 12 | separator: string 13 | 14 | /** 15 | * Convenience property for `os.homedir` function 16 | */ 17 | homedir: () => string 18 | 19 | /** 20 | * The right-most parameter is considered {to}. Other parameters are considered an array of {from}. 21 | * 22 | * Starting from leftmost {from} parameter, resolves {to} to an absolute path. 23 | * 24 | * If {to} isn't already absolute, {from} arguments are prepended in right to left order, 25 | * until an absolute path is found. If after using all {from} paths still no absolute path is found, 26 | * the current working directory is used as well. The resulting path is normalized, 27 | * and trailing slashes are removed unless the path gets resolved to the root directory. 28 | * 29 | * @param pathSegments string paths to join. Non-string arguments are ignored. 30 | */ 31 | chmodSync: typeof import('fs').chmodSync 32 | 33 | /** 34 | * The right-most parameter is considered {to}. Other parameters are considered an array of {from}. 35 | * 36 | * Starting from leftmost {from} parameter, resolves {to} to an absolute path. 37 | * 38 | * If {to} isn't already absolute, {from} arguments are prepended in right to left order, 39 | * until an absolute path is found. If after using all {from} paths still no absolute path is found, 40 | * the current working directory is used as well. The resulting path is normalized, 41 | * and trailing slashes are removed unless the path gets resolved to the root directory. 42 | * 43 | * @param pathSegments string paths to join. Non-string arguments are ignored. 44 | */ 45 | resolve: typeof import('path').resolve 46 | 47 | /** 48 | * Retrieves a list of subdirectories for a given path. 49 | */ 50 | subdirectories(path: string, isRelative?: boolean, matching?: string): string[] 51 | 52 | /** 53 | * Is this a file? 54 | */ 55 | isFile(path: string): boolean 56 | 57 | /** 58 | * Is this not a file? 59 | */ 60 | isNotFile(path: string): boolean 61 | 62 | /** 63 | * Is this a directory? 64 | */ 65 | isDirectory(path: string): boolean 66 | 67 | /** 68 | * Is this not a directory? 69 | */ 70 | isNotDirectory(path: string): boolean 71 | } 72 | 73 | // from https://github.com/Microsoft/TypeScript/blob/master/src/lib/dom.generated.d.ts#L12209-L12223 74 | // added manually so we don't have to import typescript's dom typings 75 | export interface URL { 76 | hash: string 77 | host: string 78 | hostname: string 79 | href: string 80 | readonly origin: string 81 | password: string 82 | pathname: string 83 | port: string 84 | protocol: string 85 | search: string 86 | username: string 87 | readonly searchParams: any 88 | toString(): string 89 | } 90 | -------------------------------------------------------------------------------- /src/toolbox/http-tools.ts: -------------------------------------------------------------------------------- 1 | import { GluegunHttp } from './http-types' 2 | 3 | const http: GluegunHttp = { create: (options) => require('apisauce').create(options) } 4 | 5 | export { http, GluegunHttp } 6 | -------------------------------------------------------------------------------- /src/toolbox/http-types.ts: -------------------------------------------------------------------------------- 1 | import { ApisauceInstance, ApisauceConfig } from 'apisauce' 2 | 3 | export interface GluegunHttp { 4 | /* An apisauce instance. */ 5 | create(options: ApisauceConfig): ApisauceInstance 6 | } 7 | -------------------------------------------------------------------------------- /src/toolbox/meta-tools.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import { Command } from '../domain/command' 3 | import { Plugin } from '../domain/plugin' 4 | import { Toolbox } from '../domain/toolbox' 5 | import { Runtime } from '../runtime/runtime' 6 | import { commandInfo } from './meta-tools' 7 | 8 | test('commandInfo', () => { 9 | const fakeToolbox = new Toolbox() 10 | const fakeCommand = new Command() 11 | const fakePlugin = new Plugin() 12 | 13 | fakeToolbox.runtime = new Runtime() 14 | fakeToolbox.runtime.addCoreExtensions() 15 | 16 | fakeCommand.name = 'foo' 17 | fakeCommand.description = 'foo is a command' 18 | fakeCommand.commandPath = ['foo'] 19 | fakeCommand.alias = ['f'] 20 | fakeCommand.plugin = fakePlugin 21 | 22 | fakePlugin.commands = [fakeCommand] 23 | 24 | const runtime = fakeToolbox.runtime as any 25 | runtime.plugins = [fakePlugin] 26 | runtime.commands = [fakeCommand] 27 | 28 | const info = commandInfo(fakeToolbox) 29 | expect(info).toEqual([['foo (f)', 'foo is a command']]) 30 | }) 31 | 32 | test('command name on nested command with name', () => { 33 | const fakeContext = new Toolbox() 34 | const fakeCommand = new Command() 35 | const fakePlugin = new Plugin() 36 | const commandDescription = 'ubi is a command' 37 | 38 | fakeContext.runtime = new Runtime() 39 | fakeContext.runtime.addCoreExtensions() 40 | 41 | fakeCommand.name = 'ubi' 42 | fakeCommand.description = commandDescription 43 | fakeCommand.commandPath = ['foo', 'bar', 'baz'] 44 | fakeCommand.alias = ['u'] 45 | fakeCommand.plugin = fakePlugin 46 | 47 | fakePlugin.commands = [fakeCommand] 48 | 49 | const fakeRuntime = fakeContext.runtime as any 50 | fakeRuntime.plugins = [fakePlugin] 51 | fakeRuntime.commands = [fakeCommand] 52 | 53 | const info = commandInfo(fakeContext) 54 | expect(info).toEqual([['foo bar ubi (u)', commandDescription]]) 55 | }) 56 | 57 | test('command name on nested command without name', () => { 58 | const fakeContext = new Toolbox() 59 | const fakeCommand = new Command() 60 | const fakePlugin = new Plugin() 61 | const commandDescription = 'baz is a command' 62 | 63 | fakeContext.runtime = new Runtime() 64 | fakeContext.runtime.addCoreExtensions() 65 | 66 | fakeCommand.description = commandDescription 67 | fakeCommand.commandPath = ['foo', 'bar', 'baz'] 68 | fakeCommand.alias = ['u'] 69 | fakeCommand.plugin = fakePlugin 70 | 71 | fakePlugin.commands = [fakeCommand] 72 | 73 | const fakeRuntime = fakeContext.runtime as any 74 | fakeRuntime.plugins = [fakePlugin] 75 | fakeRuntime.commands = [fakeCommand] 76 | 77 | const info = commandInfo(fakeContext) 78 | expect(info).toEqual([['foo bar baz (u)', commandDescription]]) 79 | }) 80 | -------------------------------------------------------------------------------- /src/toolbox/meta-types.ts: -------------------------------------------------------------------------------- 1 | export interface PackageJSON { 2 | name?: string 3 | version?: string 4 | description?: string 5 | keywords?: string[] 6 | homepage?: any 7 | bugs?: any 8 | license?: string 9 | author?: any 10 | contributors?: any[] 11 | maintainers?: any[] 12 | files?: string[] 13 | main?: string 14 | bin?: any 15 | types?: string 16 | typings?: string 17 | man?: string[] 18 | directories?: any 19 | repository?: any 20 | scripts?: { 21 | [k: string]: string 22 | } 23 | config?: { 24 | [k: string]: any 25 | } 26 | dependencies?: any 27 | devDependencies?: any 28 | optionalDependencies?: any 29 | peerDependencies?: any 30 | resolutions?: any 31 | engines?: { 32 | [k: string]: string 33 | } 34 | private?: boolean 35 | 36 | [k: string]: any 37 | } 38 | 39 | export type AbortSignals = 'SIGINT' | 'SIGQUIT' | 'SIGTERM' | 'SIGHUP' | 'SIGBREAK' 40 | -------------------------------------------------------------------------------- /src/toolbox/package-manager-tools.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GluegunPackageManager, 3 | GluegunPackageManagerOptions, 4 | GluegunPackageManagerResult, 5 | } from './package-manager-types' 6 | import { system } from './system-tools' 7 | 8 | let yarnpath 9 | 10 | const hasYarn = () => { 11 | if (yarnpath === undefined) { 12 | yarnpath = system.which('yarn') 13 | } 14 | return Boolean(yarnpath) 15 | } 16 | 17 | const concatPackages = (packageName) => (Array.isArray(packageName) ? packageName.join(' ') : packageName) 18 | 19 | const add = async ( 20 | packageName: string | string[], 21 | options: GluegunPackageManagerOptions, 22 | ): Promise => { 23 | const yarn = options.force === undefined ? hasYarn() : options.force === 'yarn' 24 | const dev = options.dev ? (yarn ? '--dev ' : '--save-dev ') : '' 25 | const folder = options.dir ? options.dir : '.' 26 | 27 | const command = `${yarn ? 'yarn add --cwd' : 'npm install --prefix'} ${folder} ${dev}${concatPackages(packageName)}` 28 | let stdout 29 | if (!options.dryRun) { 30 | stdout = await system.run(command) 31 | } 32 | return { success: true, command, stdout } 33 | } 34 | 35 | const remove = async ( 36 | packageName: string | string[], 37 | options: GluegunPackageManagerOptions, 38 | ): Promise => { 39 | const folder = options.dir ? options.dir : '.' 40 | const command = `${hasYarn() ? 'yarn remove --cwd' : 'npm uninstall --prefix'} ${folder} ${concatPackages( 41 | packageName, 42 | )}` 43 | let stdout 44 | if (!options.dryRun) { 45 | stdout = await system.run(command) 46 | } 47 | return { success: true, command, stdout } 48 | } 49 | 50 | const packageManager: GluegunPackageManager = { 51 | add, 52 | remove, 53 | hasYarn, 54 | } 55 | 56 | export { packageManager, GluegunPackageManager } 57 | -------------------------------------------------------------------------------- /src/toolbox/package-manager-types.ts: -------------------------------------------------------------------------------- 1 | export type GluegunPackageManagerOptions = { 2 | dev?: boolean 3 | dryRun?: boolean 4 | dir?: string 5 | force?: 'npm' | 'yarn' 6 | } 7 | export type GluegunPackageManagerResult = { 8 | success: boolean 9 | command: string 10 | stdout: string 11 | error?: string 12 | } 13 | export type GluegunPackageManager = { 14 | add: (packageName: string | string[], options: GluegunPackageManagerOptions) => Promise 15 | remove: ( 16 | packageName: string | string[], 17 | options: GluegunPackageManagerOptions, 18 | ) => Promise 19 | hasYarn: () => boolean 20 | } 21 | -------------------------------------------------------------------------------- /src/toolbox/package-manager.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import { packageManager } from './package-manager-tools' 3 | 4 | test('hasYarn', () => { 5 | // Tests should always be run with yarn installed 6 | expect(packageManager.hasYarn()).toBe(true) 7 | }) 8 | 9 | test('add', async () => { 10 | const result = await packageManager.add('infinite_red', { dryRun: true }) 11 | expect(result).toEqual({ 12 | success: true, 13 | command: 'yarn add --cwd . infinite_red', 14 | stdout: undefined, 15 | }) 16 | }) 17 | 18 | test('add', async () => { 19 | const result = await packageManager.add('infinite_red', { dryRun: true, dev: true }) 20 | expect(result).toEqual({ 21 | success: true, 22 | command: 'yarn add --cwd . --dev infinite_red', 23 | stdout: undefined, 24 | }) 25 | }) 26 | 27 | test('add', async () => { 28 | const result = await packageManager.add('infinite_red', { dryRun: true, dev: true, force: 'npm' }) 29 | expect(result).toEqual({ 30 | success: true, 31 | command: 'npm install --prefix . --save-dev infinite_red', 32 | stdout: undefined, 33 | }) 34 | }) 35 | 36 | test('add', async () => { 37 | const result = await packageManager.add(['infinite_red', 'infinite_blue'], { dryRun: true }) 38 | expect(result).toEqual({ 39 | success: true, 40 | command: 'yarn add --cwd . infinite_red infinite_blue', 41 | stdout: undefined, 42 | }) 43 | }) 44 | 45 | test('add', async () => { 46 | const result = await packageManager.add(['infinite_red', 'infinite_blue'], { dryRun: true, dir: 'test' }) 47 | expect(result).toEqual({ 48 | success: true, 49 | command: 'yarn add --cwd test infinite_red infinite_blue', 50 | stdout: undefined, 51 | }) 52 | }) 53 | 54 | test('remove', async () => { 55 | const result = await packageManager.remove('infinite_red', { dryRun: true }) 56 | expect(result).toEqual({ 57 | success: true, 58 | command: 'yarn remove --cwd . infinite_red', 59 | stdout: undefined, 60 | }) 61 | }) 62 | 63 | test('remove', async () => { 64 | const result = await packageManager.remove(['infinite_red', 'infinite_blue'], { dryRun: true }) 65 | expect(result).toEqual({ 66 | success: true, 67 | command: 'yarn remove --cwd . infinite_red infinite_blue', 68 | stdout: undefined, 69 | }) 70 | }) 71 | 72 | test('remove', async () => { 73 | const result = await packageManager.remove(['infinite_red', 'infinite_blue'], { dryRun: true, dir: 'test' }) 74 | expect(result).toEqual({ 75 | success: true, 76 | command: 'yarn remove --cwd test infinite_red infinite_blue', 77 | stdout: undefined, 78 | }) 79 | }) 80 | -------------------------------------------------------------------------------- /src/toolbox/parameter-tools.ts: -------------------------------------------------------------------------------- 1 | import { GluegunParameters } from '../domain/toolbox' 2 | import { Options } from '../domain/options' 3 | import { equals, is } from './utils' 4 | 5 | const COMMAND_DELIMITER = ' ' 6 | 7 | /** 8 | * Parses given command arguments into a more useful format. 9 | * 10 | * @param commandArray Command string or list of command parts. 11 | * @param extraOpts Extra options. 12 | * @returns Normalized parameters. 13 | */ 14 | export function parseParams(commandArray: string | string[], extraOpts: Options = {}): GluegunParameters { 15 | const yargsParse = require('yargs-parser') 16 | 17 | // use the command line args if not passed in 18 | if (is(String, commandArray)) { 19 | commandArray = (commandArray as string).split(COMMAND_DELIMITER) 20 | } 21 | 22 | // we now know it's a string[], so keep TS happy 23 | commandArray = commandArray as string[] 24 | 25 | // remove the first 2 args if it comes from process.argv 26 | if (equals(commandArray, process.argv)) { 27 | commandArray = commandArray.slice(2) 28 | } 29 | 30 | // chop it up yargsParse! 31 | const parsed = yargsParse(commandArray) 32 | const array = parsed._.slice() 33 | delete parsed._ 34 | const options = { ...parsed, ...extraOpts } 35 | return { array, options } 36 | } 37 | 38 | /** 39 | * Constructs the parameters object. 40 | * 41 | * @param params Provided parameters 42 | * @return An object with normalized parameters 43 | */ 44 | export function createParams(params: any): GluegunParameters { 45 | // make a copy of the args so we can mutate it 46 | const array: string[] = params.array.slice() 47 | 48 | const [first, second, third] = array 49 | 50 | // the string is the rest of the words 51 | const finalString = array.join(' ') 52 | 53 | // :shipit: 54 | return { 55 | ...params, 56 | array, 57 | first, 58 | second, 59 | third, 60 | string: finalString, 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/toolbox/patching-types.ts: -------------------------------------------------------------------------------- 1 | export interface GluegunPatchingPatchOptions { 2 | /* String to be inserted */ 3 | insert?: string 4 | /* Insert before this string */ 5 | before?: string | RegExp 6 | /* Insert after this string */ 7 | after?: string | RegExp 8 | /* Replace this string */ 9 | replace?: string | RegExp 10 | /* Delete this string */ 11 | delete?: string | RegExp 12 | /* Write even if it already exists */ 13 | force?: boolean 14 | } 15 | 16 | export interface GluegunPatching { 17 | /** 18 | * Checks if a string or pattern exists in a file. 19 | */ 20 | exists(filename: string, findPattern: string | RegExp): Promise 21 | /** 22 | * Updates a file. 23 | */ 24 | update(filename: string, callback: (contents: any) => any): Promise 25 | /** 26 | * Appends to the end of a file. 27 | */ 28 | append(filename: string, contents: string): Promise 29 | /** 30 | * Prepends to the start of a files. 31 | */ 32 | prepend(filename: string, contents: string): Promise 33 | /** 34 | * Replaces part of a file. 35 | */ 36 | replace(filename: string, searchFor: string, replaceWith: string): Promise 37 | /** 38 | * Makes a patch inside file. 39 | */ 40 | patch(filename: string, ...options: GluegunPatchingPatchOptions[]): Promise 41 | } 42 | -------------------------------------------------------------------------------- /src/toolbox/print-tools.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import { print } from './print-tools' 3 | const stripANSI = require('strip-ansi') 4 | 5 | // hijack the console 6 | const log = console.log 7 | let spyLogger = [] 8 | console.log = (x, y) => spyLogger.push([stripANSI(x), stripANSI(y)]) 9 | 10 | afterAll(() => { 11 | spyLogger = [] 12 | console.log = log 13 | }) 14 | 15 | test('info', () => { 16 | print.info('info') 17 | print.warning('warning!') 18 | print.success('success!!') 19 | print.highlight('highlight') 20 | print.muted('muted') 21 | print.error('error...') 22 | print.debug('debugging...') 23 | const title = 'there' 24 | print.debug('hi', title) 25 | print.fancy('fancyyyyy') 26 | print.divider() 27 | print.newline() 28 | print.table([ 29 | ['liam', '5'], 30 | ['matthew', '2'], 31 | ]) 32 | print.table( 33 | [ 34 | ['liam', '5'], 35 | ['matthew', '2'], 36 | ], 37 | { format: 'markdown' }, 38 | ) 39 | print.table( 40 | [ 41 | ['liam', '5'], 42 | ['matthew', '2'], 43 | ], 44 | { format: 'markdown', style: { 'padding-left': 1, 'padding-right': 3 } }, 45 | ) 46 | print.table( 47 | [ 48 | ['liam', '5'], 49 | ['matthew', '2'], 50 | ], 51 | { format: 'lean', style: { compact: true } }, 52 | ) 53 | 54 | expect(spyLogger).toMatchSnapshot() 55 | }) 56 | 57 | test('spin', () => { 58 | expect(typeof print.spin).toBe('function') 59 | const spinner = print.spin() 60 | expect(typeof spinner.stop).toBe('function') 61 | }) 62 | 63 | test('colors', () => { 64 | expect(typeof print.colors.highlight).toBe('function') 65 | expect(typeof print.colors.info).toBe('function') 66 | expect(typeof print.colors.warning).toBe('function') 67 | expect(typeof print.colors.success).toBe('function') 68 | expect(typeof print.colors.error).toBe('function') 69 | expect(typeof print.colors.line).toBe('function') 70 | expect(typeof print.colors.muted).toBe('function') 71 | }) 72 | -------------------------------------------------------------------------------- /src/toolbox/print-tools.test.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `src/toolbox/print-tools.test.ts` 2 | 3 | The actual snapshot is saved in `print-tools.test.ts.snap`. 4 | 5 | Generated by [AVA](https://ava.li). 6 | 7 | ## info 8 | 9 | > Snapshot 1 10 | 11 | [ 12 | [ 13 | 'info', 14 | undefined, 15 | ], 16 | [ 17 | 'warning!', 18 | undefined, 19 | ], 20 | [ 21 | 'success!!', 22 | undefined, 23 | ], 24 | [ 25 | 'highlight', 26 | undefined, 27 | ], 28 | [ 29 | 'muted', 30 | undefined, 31 | ] 32 | [ 33 | 'error...', 34 | undefined, 35 | ], 36 | [ 37 | 'vvv -----[ DEBUG ]----- vvv', 38 | undefined, 39 | ], 40 | [ 41 | 'debugging...', 42 | undefined, 43 | ], 44 | [ 45 | '^^^ -----[ DEBUG ]----- ^^^', 46 | undefined, 47 | ], 48 | [ 49 | 'vvv -----[ there ]----- vvv', 50 | undefined, 51 | ], 52 | [ 53 | 'hi', 54 | undefined, 55 | ], 56 | [ 57 | '^^^ -----[ there ]----- ^^^', 58 | undefined, 59 | ], 60 | [ 61 | 'fancyyyyy', 62 | undefined, 63 | ], 64 | [ 65 | '---------------------------------------------------------------', 66 | undefined, 67 | ], 68 | [ 69 | '', 70 | undefined, 71 | ], 72 | [ 73 | ` liam 5 ␊ 74 | matthew 2 `, 75 | undefined, 76 | ], 77 | [ 78 | `| liam | 5 |␊ 79 | | ------- | - |␊ 80 | | matthew | 2 |`, 81 | undefined, 82 | ], 83 | ] 84 | -------------------------------------------------------------------------------- /src/toolbox/print-types.ts: -------------------------------------------------------------------------------- 1 | import { GluegunToolbox } from '../index' 2 | import * as CLITable from 'cli-table3' 3 | import * as importedColors from 'colors' 4 | import { Toolbox } from '../domain/toolbox' 5 | import ora = require('ora') 6 | 7 | export type GluegunPrintColors = typeof importedColors & { 8 | highlight: (t: string) => string 9 | info: (t: string) => string 10 | warning: (t: string) => string 11 | success: (t: string) => string 12 | error: (t: string) => string 13 | line: (t: string) => string 14 | muted: (t: string) => string 15 | } 16 | 17 | export type TableStyle = Partial 18 | 19 | export interface GluegunPrintTableOptions { 20 | format?: 'markdown' | 'lean' | 'default' 21 | style?: TableStyle 22 | } 23 | 24 | export interface GluegunPrint { 25 | /* Colors as seen from colors.js. */ 26 | colors: GluegunPrintColors 27 | /* A green checkmark. */ 28 | checkmark: string 29 | /* A red X marks the spot. */ 30 | xmark: string 31 | /* Prints a message to stdout. */ 32 | info: (message: any) => void 33 | /* Prints a warning-colored message. */ 34 | warning: (message: any) => void 35 | /* Prints a success-colored message. */ 36 | success: (message: any) => void 37 | /* Prints a highlighted (cyan) message. */ 38 | highlight: (message: any) => void 39 | /* Prints a muted (grey) message. */ 40 | muted: (message: any) => void 41 | /* Prints an error-colored message. */ 42 | error: (message: any) => void 43 | /* Prints debug information about any data, with an optional title. */ 44 | debug: (value: any, title?: string) => void 45 | /* DEPRECATED: prints a normal line of text. */ 46 | fancy: (value: string) => void 47 | /* Prints a divider. */ 48 | divider: () => void 49 | /* Finds the column widths for a table */ 50 | findWidths: (cliTable: CLITable) => number[] 51 | /* Returns column header dividers for a table */ 52 | columnHeaderDivider: (cliTable: CLITable, style: TableStyle) => string[] 53 | /* Prints a newline. */ 54 | newline: () => void 55 | /* Prints a table of data (usually a 2-dimensional array). */ 56 | table: (data: string[][], options?: GluegunPrintTableOptions) => void 57 | /* An `ora`-powered spinner. */ 58 | spin(options?: ora.Options | string): ora.Ora 59 | /* Print help info for known CLI commands. */ 60 | printCommands(toolbox: Toolbox, commandRoot?: string[]): void 61 | /* Prints help info, including version and commands. */ 62 | printHelp(toolbox: GluegunToolbox): void 63 | } 64 | -------------------------------------------------------------------------------- /src/toolbox/prompt-tools.ts: -------------------------------------------------------------------------------- 1 | import { GluegunEnquirer, GluegunPrompt } from './prompt-types' 2 | 3 | function getEnquirer(): GluegunEnquirer { 4 | const Enquirer: GluegunEnquirer = require('enquirer') 5 | 6 | return new Enquirer() 7 | } 8 | 9 | /** 10 | * A yes/no question. 11 | * 12 | * @param message The message to display to the user. 13 | * @returns The true/false answer. 14 | */ 15 | const confirm = async (message: string, initial?: boolean): Promise => { 16 | const { yesno } = await getEnquirer().prompt({ 17 | name: 'yesno', 18 | type: 'confirm', 19 | message, 20 | initial, 21 | }) 22 | return yesno 23 | } 24 | 25 | /** 26 | * We're replicating the interface of Enquirer in order to 27 | * "lazy load" the package only if and when we actually are asked for it. 28 | * This results in a significant speed increase. 29 | */ 30 | const prompt: GluegunPrompt = { 31 | confirm, 32 | ask: async (questions) => { 33 | if (Array.isArray(questions)) { 34 | // Because Enquirer 2 breaks backwards compatility (and we don't want to) 35 | // we are translating the previous API to the current equivalent. 36 | questions = questions.map((q) => { 37 | // if q is a function, run it to get the actual question object 38 | if (typeof q === 'function') q = q() 39 | 40 | if (q.type === 'rawlist' || q.type === 'list') q.type = 'select' 41 | if (q.type === 'expand') q.type = 'autocomplete' 42 | if (q.type === 'checkbox') q.type = 'multiselect' 43 | if (q.type === 'radio') q.type = 'select' 44 | if (q.type === 'question') q.type = 'input' 45 | return q 46 | }) 47 | } 48 | return getEnquirer().prompt(questions) 49 | }, 50 | separator: () => getEnquirer().separator(), 51 | } 52 | 53 | export { prompt, GluegunPrompt } 54 | -------------------------------------------------------------------------------- /src/toolbox/prompt-types.ts: -------------------------------------------------------------------------------- 1 | import { PromptOptions, Choice } from './prompt-enquirer-types' 2 | 3 | const Enquirer = require('enquirer') 4 | export type GluegunEnquirer = typeof Enquirer 5 | export const GluegunEnquirer = Enquirer 6 | 7 | export interface GluegunAskResponse { 8 | [key: string]: string 9 | } 10 | 11 | export interface GluegunPrompt { 12 | /* Prompts with a confirm message. */ 13 | confirm(message: string, initial?: boolean): Promise 14 | /* Prompts with a set of questions. */ 15 | ask( 16 | questions: 17 | | PromptOptions 18 | | ((this: GluegunEnquirer) => PromptOptions) 19 | | (PromptOptions | ((this: GluegunEnquirer) => PromptOptions))[], 20 | ): Promise 21 | /* Returns a separator. */ 22 | separator(): string 23 | } 24 | 25 | export type GluegunQuestionChoices = Choice 26 | 27 | export type GluegunQuestionType = PromptOptions 28 | -------------------------------------------------------------------------------- /src/toolbox/semver-tools.ts: -------------------------------------------------------------------------------- 1 | import { GluegunSemver } from './semver-types' 2 | 3 | /** 4 | * We're replicating the interface of semver in order to 5 | * "lazy load" the package only if and when we actually are asked for it. 6 | * This results in a significant speed increase. 7 | */ 8 | const semver: GluegunSemver = { 9 | valid: (...args) => require('semver').valid(...args), 10 | clean: (...args) => require('semver').clean(...args), 11 | satisfies: (...args) => require('semver').satisfies(...args), 12 | gt: (...args) => require('semver').gt(...args), 13 | lt: (...args) => require('semver').lt(...args), 14 | validRange: (...args) => require('semver').validRange(...args), 15 | } 16 | 17 | export { semver, GluegunSemver } 18 | -------------------------------------------------------------------------------- /src/toolbox/semver-types.ts: -------------------------------------------------------------------------------- 1 | export interface GluegunSemver { 2 | /* Checks if a version is a valid semver string */ 3 | valid(version: string): string | null 4 | /* Removes extraneous characters from a semver string */ 5 | clean(version: string): string | null 6 | /* Checks if a version is in a semver range */ 7 | satisfies(version: string, inVersion: string): boolean 8 | /* Checks if a version is greater than another version */ 9 | gt(version: string, isGreaterThanVersion: string): boolean 10 | /* Checks if a version is less than another version */ 11 | lt(version: string, isLessThanVersion: string): boolean 12 | /* Checks if a range string is valid */ 13 | validRange(range: string): boolean | null 14 | } 15 | -------------------------------------------------------------------------------- /src/toolbox/string-tools.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import { strings } from './string-tools' 3 | 4 | const { 5 | identity, 6 | isBlank, 7 | isNotString, 8 | camelCase, 9 | kebabCase, 10 | snakeCase, 11 | upperCase, 12 | lowerCase, 13 | startCase, 14 | upperFirst, 15 | lowerFirst, 16 | pascalCase, 17 | pad, 18 | padStart, 19 | padEnd, 20 | trim, 21 | trimStart, 22 | trimEnd, 23 | repeat, 24 | pluralize, 25 | plural, 26 | singular, 27 | addPluralRule, 28 | addSingularRule, 29 | addIrregularRule, 30 | addUncountableRule, 31 | isPlural, 32 | isSingular, 33 | } = strings 34 | 35 | test('isBlank', () => { 36 | expect(isBlank(null)).toBe(true) 37 | expect(isBlank('')).toBe(true) 38 | expect(isBlank(' ')).toBe(true) 39 | expect(isBlank('s')).toBe(false) 40 | }) 41 | 42 | test('isNotString', () => { 43 | expect(isNotString('')).toBe(false) 44 | expect(isNotString(2)).toBe(true) 45 | expect(isNotString(null)).toBe(true) 46 | expect(isNotString(undefined)).toBe(true) 47 | expect(isNotString([])).toBe(true) 48 | expect(isNotString({})).toBe(true) 49 | }) 50 | 51 | test('camelCase', () => { 52 | expect(camelCase('this here')).toBe('thisHere') 53 | }) 54 | 55 | test('kebabCase', () => { 56 | expect(kebabCase('fun times')).toBe('fun-times') 57 | expect(kebabCase('FunTimes')).toBe('fun-times') 58 | }) 59 | 60 | test('snakeCase', () => { 61 | expect(snakeCase('a b c')).toBe('a_b_c') 62 | expect(snakeCase('AlwaysBeClosing')).toBe('always_be_closing') 63 | }) 64 | 65 | test('upperCase', () => { 66 | expect(upperCase('lol')).toBe('LOL') 67 | }) 68 | 69 | test('lowerCase', () => { 70 | expect(lowerCase('ROFL')).toBe('rofl') 71 | }) 72 | 73 | test('startCase', () => { 74 | expect(startCase('hello there')).toBe('Hello There') 75 | }) 76 | 77 | test('upperFirst', () => { 78 | expect(upperFirst('hello world')).toBe('Hello world') 79 | }) 80 | 81 | test('lowerFirst', () => { 82 | expect(lowerFirst('BOOM')).toBe('bOOM') 83 | }) 84 | 85 | test('pascalCase', () => { 86 | expect(pascalCase('check it out')).toBe('CheckItOut') 87 | expect(pascalCase('checkIt-out')).toBe('CheckItOut') 88 | }) 89 | 90 | test('pad', () => { 91 | expect(pad('a', 3)).toBe(' a ') 92 | }) 93 | 94 | test('padStart', () => { 95 | expect(padStart('a', 3)).toBe(' a') 96 | }) 97 | 98 | test('padEnd', () => { 99 | expect(padEnd('a', 3)).toBe('a ') 100 | }) 101 | 102 | test('trim', () => { 103 | expect(trim(' sloppy ')).toBe('sloppy') 104 | }) 105 | 106 | test('trimStart', () => { 107 | expect(trimStart(' ! ')).toBe('! ') 108 | }) 109 | 110 | test('trimEnd', () => { 111 | expect(trimEnd(' ! ')).toBe(' !') 112 | }) 113 | 114 | test('repeat', () => { 115 | expect(repeat('a', 4)).toBe('aaaa') 116 | }) 117 | 118 | test('identity', () => { 119 | expect(identity('x')).toBe('x') 120 | }) 121 | 122 | test('pluralize', () => { 123 | expect(pluralize('test', 1, true)).toBe('1 test') 124 | expect(pluralize('test', 5, true)).toBe('5 tests') 125 | }) 126 | 127 | test('plural', () => { 128 | expect(plural('bug')).toBe('bugs') 129 | }) 130 | 131 | test('singular', () => { 132 | expect(singular('bugs')).toBe('bug') 133 | }) 134 | 135 | test('addPluralRule', () => { 136 | addPluralRule(/gex$/i, 'gexii') 137 | expect(plural('regex')).toBe('regexii') 138 | }) 139 | 140 | test('addSingularRule', () => { 141 | addSingularRule(/bugs$/i, 'bugger') 142 | expect(singular('bugs')).toBe('bugger') 143 | }) 144 | 145 | test('addIrregularRule', () => { 146 | addIrregularRule('octopus', 'octopodes') 147 | expect(plural('octopus')).toBe('octopodes') 148 | }) 149 | 150 | test('addUncountableRule', () => { 151 | addUncountableRule('paper') 152 | expect(plural('paper')).toBe('paper') 153 | }) 154 | 155 | test('isPlural', () => { 156 | expect(isPlural('bug')).toBe(false) 157 | expect(isPlural('bugs')).toBe(true) 158 | }) 159 | 160 | test('isSingular', () => { 161 | expect(isSingular('bug')).toBe(true) 162 | expect(isSingular('bugs')).toBe(false) 163 | }) 164 | -------------------------------------------------------------------------------- /src/toolbox/string-tools.ts: -------------------------------------------------------------------------------- 1 | import { GluegunStrings } from './strings-types' 2 | import { is } from './utils' 3 | 4 | const camelCase = (...args) => require('lodash.camelcase')(...args) 5 | const kebabCase = (...args) => require('lodash.kebabcase')(...args) 6 | const lowerCase = (...args) => require('lodash.lowercase')(...args) 7 | const lowerFirst = (...args) => require('lodash.lowerfirst')(...args) 8 | const pad = (...args) => require('lodash.pad')(...args) 9 | const padEnd = (...args) => require('lodash.padend')(...args) 10 | const padStart = (...args) => require('lodash.padstart')(...args) 11 | const repeat = (...args) => require('lodash.repeat')(...args) 12 | const snakeCase = (...args) => require('lodash.snakecase')(...args) 13 | const startCase = (...args) => require('lodash.startcase')(...args) 14 | const trim = (...args) => require('lodash.trim')(...args) 15 | const trimEnd = (...args) => require('lodash.trimend')(...args) 16 | const trimStart = (...args) => require('lodash.trimstart')(...args) 17 | const upperCase = (...args) => require('lodash.uppercase')(...args) 18 | const upperFirst = (...args) => require('lodash.upperfirst')(...args) 19 | 20 | const pluralize = (word: string, count?: number, inclusive?: boolean) => require('pluralize')(word, count, inclusive) 21 | pluralize.plural = (word: string) => require('pluralize').plural(word) 22 | pluralize.singular = (word: string) => require('pluralize').singular(word) 23 | pluralize.addPluralRule = (rule: string | RegExp, replacement: string) => 24 | require('pluralize').addPluralRule(rule, replacement) 25 | pluralize.addSingularRule = (rule: string | RegExp, replacement: string) => 26 | require('pluralize').addSingularRule(rule, replacement) 27 | 28 | pluralize.addIrregularRule = (single: string, plural: string) => require('pluralize').addIrregularRule(single, plural) 29 | pluralize.addUncountableRule = (word: string | RegExp) => require('pluralize').addUncountableRule(word) 30 | pluralize.isPlural = (word: string) => require('pluralize').isPlural(word) 31 | pluralize.isSingular = (word: string) => require('pluralize').isSingular(word) 32 | 33 | /** 34 | * Is this not a string? 35 | * 36 | * @param value The value to check 37 | * @return True if it is not a string, otherwise false 38 | */ 39 | function isNotString(value: any): boolean { 40 | return !is(String, value) 41 | } 42 | 43 | /** 44 | * Is this value a blank string? 45 | * 46 | * @param value The value to check. 47 | * @returns True if it was, otherwise false. 48 | */ 49 | function isBlank(value: any): boolean { 50 | return isNotString(value) || trim(value) === '' 51 | } 52 | 53 | /** 54 | * Returns the value it is given 55 | * 56 | * @param value 57 | * @returns the value. 58 | */ 59 | function identity(value: any): any { 60 | return value 61 | } 62 | 63 | /** 64 | * Converts the value ToPascalCase. 65 | * 66 | * @param value The string to convert 67 | * @returns PascalCase string. 68 | */ 69 | function pascalCase(value: string): string { 70 | return upperFirst(camelCase(value)) 71 | } 72 | 73 | export { GluegunStrings } 74 | 75 | export const strings: GluegunStrings = { 76 | isNotString, 77 | isBlank, 78 | identity, 79 | pascalCase, 80 | camelCase, 81 | kebabCase, 82 | lowerCase, 83 | lowerFirst, 84 | pad, 85 | padEnd, 86 | padStart, 87 | repeat, 88 | snakeCase, 89 | startCase, 90 | trim, 91 | trimEnd, 92 | trimStart, 93 | upperCase, 94 | upperFirst, 95 | pluralize, 96 | plural: pluralize.plural, 97 | singular: pluralize.singular, 98 | addPluralRule: pluralize.addPluralRule, 99 | addSingularRule: pluralize.addSingularRule, 100 | addIrregularRule: pluralize.addIrregularRule, 101 | addUncountableRule: pluralize.addUncountableRule, 102 | isPlural: pluralize.isPlural, 103 | isSingular: pluralize.isSingular, 104 | } 105 | -------------------------------------------------------------------------------- /src/toolbox/strings-types.ts: -------------------------------------------------------------------------------- 1 | export interface GluegunStrings { 2 | /** 3 | * Returns itself. 4 | */ 5 | identity(value: string): string 6 | /** 7 | * Is this string blank, null, or otherwise empty? 8 | */ 9 | isBlank(value: string): boolean 10 | /** 11 | * This is not a string? Are you not entertained? 12 | */ 13 | isNotString(value: any): boolean 14 | /** 15 | * Converts a string toCamelCase. 16 | */ 17 | camelCase(value: string): string 18 | /** 19 | * Converts a string to-kebab-case. 20 | */ 21 | kebabCase(value: string): string 22 | /** 23 | * Converts a string to_snake_case. 24 | */ 25 | snakeCase(value: string): string 26 | /** 27 | * Converts a string TO UPPER CASE. 28 | */ 29 | upperCase(value: string): string 30 | /** 31 | * Converts a string to lower case. 32 | */ 33 | lowerCase(value: string): string 34 | /** 35 | * Converts a string To start case. 36 | */ 37 | startCase(value: string): string 38 | /** 39 | * Converts the first character Of Every Word To Upper. 40 | */ 41 | upperFirst(value: string): string 42 | /** 43 | * Converts the first character oF eVERY wORD tO lOWER. 44 | */ 45 | lowerFirst(value: string): string 46 | /** 47 | * Converts a string ToPascalCase. 48 | */ 49 | pascalCase(value: string): string 50 | /** 51 | * Pads a string with `chars` (spaces default) to `length` characters long, effectively centering the string. 52 | */ 53 | pad(sourceString: string, length: number, chars?: string): string 54 | /** 55 | * Pads the start of `string` with `chars` (spaces default) to `length` characters. 56 | */ 57 | padStart(sourceString: string, length: number, chars?: string): string 58 | /** 59 | * Pads the end of `string` with `chars` (spaces default) to `length` characters. 60 | */ 61 | padEnd(sourceString: string, length: number, chars?: string): string 62 | /** 63 | * Strips whitespace from a string. 64 | */ 65 | trim(sourceString: string, chars?: string): string 66 | /** 67 | * Strips whitespace from the start of a string. 68 | */ 69 | trimStart(sourceString: string, chars?: string): string 70 | /** 71 | * Strips whitespace from the end of a string. 72 | */ 73 | trimEnd(sourceString: string, chars?: string): string 74 | /** 75 | * Repeats a `string` a `numberOfTimes`. 76 | */ 77 | repeat(sourceString: string, numberOfTimes: number): string 78 | /** 79 | * Pluralize or singularize a word based on the passed in count. 80 | */ 81 | pluralize(word: string, count?: number, inclusive?: boolean): string 82 | /** 83 | * Pluralize a word based. 84 | */ 85 | plural(word: string): string 86 | 87 | /** 88 | * Singularize a word based. 89 | */ 90 | singular(word: string): string 91 | 92 | /** 93 | * Add a pluralization rule to the collection. 94 | */ 95 | addPluralRule(rule: string | RegExp, replacement: string): void 96 | 97 | /** 98 | * Add a singularization rule to the collection. 99 | */ 100 | addSingularRule(rule: string | RegExp, replacement: string): void 101 | 102 | /** 103 | * Add an irregular word definition. 104 | */ 105 | addIrregularRule(single: string, plural: string): void 106 | 107 | /** 108 | * Add an uncountable word rule. 109 | */ 110 | addUncountableRule(word: string | RegExp): void 111 | 112 | /** 113 | * Test if provided word is plural. 114 | */ 115 | isPlural(word: string): boolean 116 | 117 | /** 118 | * Test if provided word is singular. 119 | */ 120 | isSingular(word: string): boolean 121 | } 122 | -------------------------------------------------------------------------------- /src/toolbox/system-tools.test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import { system } from './system-tools' 3 | 4 | test('which - existing package', () => { 5 | const result = system.which('node') 6 | expect(result).not.toBe(null) 7 | }) 8 | 9 | test('which - non-existing package', () => { 10 | const result = system.which('non-existing-package') 11 | expect(result).toBe(null) 12 | }) 13 | 14 | test('run - should reject if the command does not exist', async () => { 15 | try { 16 | await system.run('echo "hi" && non-existing-command') 17 | } catch (e) { 18 | expect(e.stdout).toContain('hi') 19 | if (process.platform === 'win32') { 20 | expect(e.stderr).toContain('is not recognized as an internal or external command') 21 | } else { 22 | expect(e.stderr).toContain('not found') 23 | } 24 | } 25 | }) 26 | 27 | test('run - should resolve if the command exists', async () => { 28 | // `echo` should be a general command for both *nix and windows 29 | await expect(system.run('echo gluegun', { trim: true })).resolves.toBe('gluegun') 30 | }) 31 | -------------------------------------------------------------------------------- /src/toolbox/system-tools.ts: -------------------------------------------------------------------------------- 1 | import { Options } from '../domain/options' 2 | import { GluegunSystem, GluegunError, StringOrBuffer } from './system-types' 3 | import { head, tail, isNil } from './utils' 4 | 5 | /** 6 | * Executes a commandline program asynchronously. 7 | * 8 | * @param commandLine The command line to execute. 9 | * @param options Additional child_process options for node. 10 | * @returns Promise with result. 11 | */ 12 | async function run(commandLine: string, options: Options = {}): Promise { 13 | const trimmer = options && options.trim ? (s) => s.trim() : (s) => s 14 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 15 | const { trim, ...nodeOptions } = options 16 | 17 | return new Promise((resolve, reject) => { 18 | const { exec } = require('child_process') 19 | exec(commandLine, nodeOptions, (error: GluegunError, stdout: StringOrBuffer, stderr: StringOrBuffer) => { 20 | if (error) { 21 | error.stdout = stdout 22 | error.stderr = stderr 23 | return reject(error) 24 | } 25 | resolve(trimmer(stdout || '')) 26 | }) 27 | }) 28 | } 29 | 30 | /** 31 | * Executes a commandline via execa. 32 | * 33 | * @param commandLine The command line to execute. 34 | * @param options Additional child_process options for node. 35 | * @returns Promise with result. 36 | */ 37 | async function exec(commandLine: string, options: Options = {}): Promise { 38 | return new Promise((resolve, reject) => { 39 | const args = commandLine.split(' ') 40 | require('execa')(head(args), tail(args), options) 41 | .then((result) => resolve(result.stdout)) 42 | .catch((error) => reject(error)) 43 | }) 44 | } 45 | 46 | /** 47 | * Uses cross-spawn to run a process. 48 | * 49 | * @param commandLine The command line to execute. 50 | * @param options Additional child_process options for node. 51 | * @returns The response code. 52 | */ 53 | async function spawn(commandLine: string, options: Options = {}): Promise { 54 | return new Promise((resolve, _reject) => { 55 | const args = commandLine.split(' ') 56 | const spawned = require('cross-spawn')(head(args), tail(args), options) 57 | const result = { 58 | stdout: null, 59 | status: null, 60 | error: null, 61 | } 62 | if (spawned.stdout) { 63 | spawned.stdout.on('data', (data) => { 64 | if (isNil(result.stdout)) { 65 | result.stdout = data 66 | } else { 67 | result.stdout += data 68 | } 69 | }) 70 | } 71 | spawned.on('close', (code) => { 72 | result.status = code 73 | resolve(result) 74 | }) 75 | spawned.on('error', (err) => { 76 | result.error = err 77 | resolve(result) 78 | }) 79 | }) 80 | } 81 | 82 | /** 83 | * Finds the location of the path. 84 | * 85 | * @param command The name of program you're looking for. 86 | * @return The full path or null. 87 | */ 88 | function which(command: string): string | null { 89 | return require('which').sync(command, { nothrow: true }) 90 | } 91 | 92 | /** 93 | * Starts a timer used for measuring durations. 94 | * 95 | * @return A function that when called will return the elapsed duration in milliseconds. 96 | */ 97 | function startTimer(): () => number { 98 | const started = process.uptime() 99 | return () => Math.floor((process.uptime() - started) * 1000) // uptime gives us seconds 100 | } 101 | 102 | const system: GluegunSystem = { exec, run, spawn, which, startTimer } 103 | 104 | export { system, GluegunSystem } 105 | -------------------------------------------------------------------------------- /src/toolbox/system-types.ts: -------------------------------------------------------------------------------- 1 | export interface GluegunSystem { 2 | /** 3 | * Executes a command via execa. 4 | */ 5 | exec(command: string, options?: any): Promise 6 | /** 7 | * Runs a command and returns stdout as a trimmed string. 8 | */ 9 | run(command: string, options?: any): Promise 10 | /** 11 | * Spawns a command via crosspawn. 12 | */ 13 | spawn(command: string, options?: any): Promise 14 | /** 15 | * Uses node-which to find out where the command lines. 16 | */ 17 | which(command: string): string | void 18 | /** 19 | * Returns a timer function that starts from this moment. Calling 20 | * this function will return the number of milliseconds from when 21 | * it was started. 22 | */ 23 | startTimer(): GluegunTimer 24 | } 25 | 26 | /** 27 | * Returns the number of milliseconds from when the timer started. 28 | */ 29 | export type GluegunTimer = () => number 30 | 31 | export type StringOrBuffer = string | Buffer 32 | 33 | export interface GluegunError extends Error { 34 | stdout?: StringOrBuffer 35 | stderr?: StringOrBuffer 36 | } 37 | -------------------------------------------------------------------------------- /src/toolbox/template-tools.ts: -------------------------------------------------------------------------------- 1 | import { replace } from './utils' 2 | import { Options } from '../domain/options' 3 | import { filesystem } from '../toolbox/filesystem-tools' 4 | import { strings } from '../toolbox/string-tools' 5 | import { GluegunToolbox } from '../domain/toolbox' 6 | 7 | function buildGenerate(toolbox: GluegunToolbox): (opts: Options) => Promise { 8 | const { plugin } = toolbox 9 | 10 | /** 11 | * Generates a file from a template. 12 | * 13 | * @param opts Generation options. 14 | * @return The generated string. 15 | */ 16 | async function generate(opts: Options = {}): Promise { 17 | const ejs = require('ejs') 18 | // required 19 | const template = opts.template 20 | 21 | // optional 22 | const target = opts.target 23 | const props = opts.props || {} 24 | 25 | // add some goodies to the environment so templates can read them 26 | const data = { 27 | config: toolbox && toolbox.config, 28 | parameters: toolbox && toolbox.parameters, 29 | props, 30 | filename: '', 31 | ...strings, // add our string tools to the filters available 32 | } 33 | 34 | // check the base directory for templates 35 | const baseDirectory = plugin && plugin.directory 36 | let templateDirectory = opts.directory || `${baseDirectory}/templates` 37 | let pathToTemplate = `${templateDirectory}/${template}` 38 | 39 | // check ./build/templates too, if that doesn't exist 40 | if (!filesystem.isFile(pathToTemplate)) { 41 | templateDirectory = opts.directory || `${baseDirectory}/build/templates` 42 | pathToTemplate = `${templateDirectory}/${template}` 43 | } 44 | 45 | // bomb if the template doesn't exist 46 | if (!filesystem.isFile(pathToTemplate)) { 47 | throw new Error(`template not found ${pathToTemplate}`) 48 | } 49 | 50 | // add template path to support includes 51 | data.filename = pathToTemplate 52 | 53 | // read the template 54 | const templateContent = filesystem.read(pathToTemplate) 55 | 56 | // render the template 57 | const content = ejs.render(templateContent, data) 58 | 59 | // save it to the file system 60 | if (!strings.isBlank(target)) { 61 | // prep the destination directory 62 | const dir = replace(/$(\/)*/g, '', target) 63 | const dest = filesystem.path(dir) 64 | 65 | filesystem.write(dest, content) 66 | } 67 | 68 | // send back the rendered string 69 | return content 70 | } 71 | 72 | return generate 73 | } 74 | 75 | export { buildGenerate } 76 | -------------------------------------------------------------------------------- /src/toolbox/template-types.ts: -------------------------------------------------------------------------------- 1 | export interface GluegunTemplate { 2 | generate(options: GluegunTemplateGenerateOptions): Promise 3 | } 4 | 5 | export interface GluegunTemplateGenerateOptions { 6 | /** 7 | * Path to the EJS template relative from the plugin's `template` directory. 8 | */ 9 | template: string 10 | /** 11 | * Path to create the file relative from the user's working directory. 12 | */ 13 | target?: string 14 | /** 15 | * Additional props to provide to the EJS template. 16 | */ 17 | props?: { [name: string]: any } 18 | /** 19 | * An absolute path of where to find the templates (if not default). 20 | */ 21 | directory?: string 22 | } 23 | -------------------------------------------------------------------------------- /src/toolbox/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Internal tools for use within Gluegun 3 | */ 4 | 5 | const head = (a: T[]): T => a[0] 6 | const tail = (a: T[]): T[] => a.slice(1) 7 | const identity = (a) => a 8 | const isNil = (a: any): boolean => a === null || a === undefined 9 | const split = (b: string, a: string): string[] => a.split(b) 10 | const trim = (a: string): string => a.trim() 11 | const forEach = (f: (i: T) => void, a: T[]) => a.forEach(f) 12 | const keys = (a: T): string[] => (Object(a) !== a ? [] : Object.keys(a)) 13 | const replace = (b: string | RegExp, c: string, a: string): string => a.replace(b, c) 14 | const last = (a: T[]): T => a[a.length - 1] 15 | const reject = (f: (i: T) => boolean, a: T[]): T[] => a.filter((b) => !f(b)) 16 | const is = (Ctor: any, val: any): boolean => (val != null && val.constructor === Ctor) || val instanceof Ctor 17 | const takeLast = (n: number, a: T[]): T[] => a.slice(-1 * n) 18 | const equals = (a: string[], b: string[]) => a.length === b.length && a.every((v, i) => v === b[i]) 19 | const times = (fn: (i: any) => any, n: number) => { 20 | const list = new Array(n) 21 | for (let i = 0; i < n; i++) list[i] = fn(i) 22 | return list 23 | } 24 | const prop = (p: string, obj: unknown) => obj[p] 25 | 26 | export { 27 | head, 28 | identity, 29 | isNil, 30 | split, 31 | tail, 32 | trim, 33 | forEach, 34 | keys, 35 | replace, 36 | last, 37 | reject, 38 | is, 39 | takeLast, 40 | equals, 41 | times, 42 | prop, 43 | } 44 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "experimentalDecorators": true, 5 | "lib": ["es2016", "es2016.array.include", "scripthost"], 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noImplicitThis": true, 10 | "noUnusedLocals": true, 11 | "sourceMap": true, 12 | "outDir": "build", 13 | // Emits types for others to consume 14 | "declaration": true, 15 | "declarationDir": "build/types", 16 | "strict": false, 17 | "target": "es5", 18 | "pretty": true, 19 | "skipLibCheck": true 20 | }, 21 | "include": ["src/**/*.ts"], 22 | "exclude": ["src/fixtures", "src/**/*.test.ts"] 23 | } 24 | --------------------------------------------------------------------------------