diff --git a/.eslintrc.yml b/.eslintrc.yml index 04c34f1..eea21a9 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -2,7 +2,7 @@ root: true parserOptions: - ecmaVersion: 7 + ecmaVersion: 8 env: node: true diff --git a/HISTORY.md b/HISTORY.md index 4547cf3..9e03796 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,79 @@ # ModClean History +## 3.0.0-beta.1 (5/21/2018) +First beta release of ModClean 3.0.0. + +* **IMPROVEMENT:** Remove "ignore" patterns from that matching patterns array to prevent race condition in node-glob (#19). +* **NEW:** Added ability to send in no pattern definitions. This will allow custom patterns to be quickly used (without a definition module) by using `options.additionalPatterns`. +* Make `additionalPatterns` option `null` by default to match other options. +* Refactored how file objects are generated by moving functionality to its own function. +* Misc. cleanup and refactoring. + +## 3.0.0-alpha.6 (5/18/2018) +* **FIX:** Fixed old variable reference in CLI (#24) + +## 3.0.0-alpha.5 (5/17/2018) +* **IMPROVEMENT:** Better checking whether a directory is a module by ensuring the parent directory is equal to `options.modulesDir`. + +## 3.0.0-alpha.4 (5/17/2018) +* **FIX:** Fix typo that caused all directories to be marked as a module. + +## 3.0.0-alpha.3 (5/16/2018) +* **NEW:** Added `modclean.version` and `ModClean.version`. + +## 3.0.0-alpha.2 (5/16/2018) +* **NEW:** Added `isDirectory` as a property of file objects. + +## 3.0.0-alpha.1 (5/10/2018) +* Updated dependencies: + - `chalk` @ 2.4.1 + - `empty-dir` @ 1.0.0 + - `glob` @ 7.1.2 + - `subdirs` @ 1.0.1 +* Removed dependencies: + - `async-each-series` + - `clui` + - `modclean-patterns-default` + - `update-notifier` +* Added dependencies: + - `await-handler` + - `ora` + - `progress` + +#### ModClean API Changes +* **BREAKING:** Switch from callbacks to promises. +* **BREAKING:** Utilizes async/await which will require Node 8+. +* **BREAKING:** ModClean constructor no longer calls `clean()` by default, it must be manually called. +* **BREAKING:** Stored errors in `errors` is now the pure `Error` with additional properties attached. +* **BREAKING:** Renamed and namespaced most emitted events for clarity: + - `start` is now `clean:start` + - `complete` is now `clean:complete` + - `deleted` is now `file:deleted` + - `process` is now `file:list` + - `finish` is now `process:done` + - `beforeFind` is now `file:find` + - `beforeEmptyDirs` is now `emptydir:start` + - `afterEmptyDirs` is now `emptydir:done` + - `emptyDirs` is now `emptydir:list` + - `deletedEmptyDir` is now `emptydir:deleted` +* **BREAKING:** Removed `finish` event. +* **BREAKING:** Empty directory errors no longer emit event `emptyDirError`, but emit standard `error` event instead. +* **BREAKING:** File deletion errors no longer emit event `fileError`, but emit standard `error` event instead. +* **BREAKING:** `cleanEmptyDirs()` results are now an object containing `empty` (array of all empty directories) and `deleted` (array of all removed directories). +* **BREAKING:** Files are now objects instead of strings. The new file objects contain more information about a file/directory being removed. _This does not apply to empty directories._ +* **BREAKING:** Removed `process` option in favor for new `filter` option. +* **NEW:** Added `globOptions` to options object. +* **NEW:** Added `filter` function to options object. +* **NEW:** Added `skipModules` to options object. Will skip deletion of a directory if determined it is a module. +* **NEW:** Added `file:skipped` event when a file/directory is skipped due to `filter` result. +* **NEW:** Added `process:start` event when file processing starts. +* **PERFORMANCE:** Reduced old and outdated dependencies to bring ModCleans dependency weight from over 9MB down to 1.2MB. +* **PERFORMANCE:** Files are now deleted in parallel instead of series. + +#### ModClean CLI Changes +* **BREAKING:** Removed `-i, --interactive` option. This option provided no real gain and added overhead which slows down the entire process. You should run in test mode if you want to see the files/folders being deleted prior. +* **NEW:** Added `-l, --log` option. Outputs log files for deleted and skipped files along with errors in the current working directory on completion. + ## 2.1.2 (11/6/2017) * Update package.json to use latest version of `modclean-patterns-default` diff --git a/README.md b/README.md index e6bd6aa..45948a8 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,22 @@ [![npm version](https://img.shields.io/npm/v/modclean.svg)](https://www.npmjs.com/package/modclean) ![NPM Dependencies](https://david-dm.org/ModClean/modclean.svg) [![NPM Downloads](https://img.shields.io/npm/dm/modclean.svg)](https://www.npmjs.com/package/modclean) [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/ModClean/modclean/master/LICENSE) [![GitHub issues](https://img.shields.io/github/issues/ModClean/modclean.svg)](https://github.com/ModClean/modclean/issues) [![Package Quality](http://npm.packagequality.com/shield/modclean.svg)](http://packagequality.com/#?package=modclean) -### This documentation is for ModClean 2.x which requires Node v6.9+, if you need to support older versions, use [ModClean 1.3.0](https://github.com/ModClean/modclean/tree/1.x) instead. +### This documentation is for ModClean 3.x which requires Node v6.9+, if you need to support older versions, use: +**Node v6.9+:** [ModClean 2.x](https://github.com/ModClean/modclean/tree/2.x) +**Older:** [ModClean 1.x](https://github.com/ModClean/modclean/tree/1.x) + ModClean is a utility that finds and removes unnecessary files and folders from your `node_modules` directory based on [predefined](https://github.com/ModClean/modclean-patterns-default) and [custom](https://github.com/ModClean/modclean/wiki/Custom-Pattern-Plugins) [glob](https://github.com/isaacs/node-glob) patterns. This utility comes with both a CLI and a programmatic API to provide customization for your environment. ModClean is used and tested in an Enterprise environment on a daily basis. +## What's New in ModClean 3? +This has been a complete overhaul of ModClean and the CLI to provide more performance and new features. With any major change, there have been a lot of breaking changes as well (see the [Migration Guide]() for more info). Here are some of the new notable features: + +1. **Moved away from callbacks to Promises.** With Promise support, ModClean utilizes async/await in order to have cleaner and more performant code. +2. **New options for more customization.** Now you can customize ModClean more including options passed into `glob`. +3. **NPM Module detection support.** ModClean now has the ability to detect if a matched directory is a NPM module and prevent deleting it. This allows stricter patterns without the worry of potentially deleting a module. +4. **New event names.** Events are now properly named and namespaced for easier use and understanding. +5. **Dependency reduction.** Reduced the dependencies and brought in newer and more usable dependencies that brought ModClean from over 9MBs to 1.2MB. + ## Why? There are a few different reasons why you would want to use ModClean: @@ -20,7 +32,7 @@ There are a few different reasons why you would want to use ModClean: The :cake: is a lie, but the [Benchmarks](https://github.com/ModClean/modclean/wiki/Benchmarks) are not. ## How? -**New!** In ModClean 2.0.0, patterns are now provided by plugins instead of a static `patterns.json` file as part of the module. By default, ModClean comes with [modclean-patterns-default](https://github.com/ModClean/modclean-patterns-default) installed, providing the same patterns as before. You now have the ability to create your own patterns plugins and use multiple plugins to clean your modules. This allows flexibility with both the programmatic API and CLI. +**Note:** In ModClean 2+, patterns are now provided by plugins instead of a static `patterns.json` file as part of the module. By default, ModClean comes with [modclean-patterns-default](https://github.com/ModClean/modclean-patterns-default) installed, providing the same patterns as before. You now have the ability to create your own patterns plugins and use multiple plugins to clean your modules. This allows flexibility with both the programmatic API and CLI. ModClean scans the `node_modules` directory of your choosing, finding all files and folders that match the defined patterns and deleting them. Both the CLI and the programmatic API provides all the options needed to customize this process to your requirements. Depending on the number of modules your app requires, files can be reduced anywhere from hundreds to thousands and disk space can be reduced considerably. @@ -29,37 +41,39 @@ _(File and disk space reduction can also be different between the version of NPM **IMPORTANT** This module has been heavily tested in an enterprise environment used for large enterprise applications. The provided patterns in [modclean-patterns-default](https://github.com/ModClean/modclean-patterns-default) have worked very well when cleaning up useless files in many popular modules. There are hundreds of thousands of modules in NPM and I cannot simply cover them all. If you are using ModClean for the first time on your application, you should create a copy of the application so you can ensure it still runs properly after running ModClean. The patterns are set in a way to ensure no crutial module files are removed, although there could be one-off cases where a module could be affected and that's why I am stressing that testing and backups are important. If you find any files that should be removed, please create a pull request to [modclean-patterns-default](https://github.com/ModClean/modclean-patterns-default) or create your own patterns plugin to share with the community. +In ModClean 3+, module detection has been added. This will help prevent modules that have names matched by the patterns from being removed. + ## Removal Benchmark So how well does this module work? If we `npm install sails` and run ModClean on it, here are the results: -_All tests ran on macOS 10.12.3 with Node v6.9.1 and NPM v4.0.5_ +_All tests ran on macOS 10.13.5 with Node v8.7.0 and NPM v6.0.1_ #### Using Default Safe Patterns `modclean -n default:safe` or `modclean` -| | Total Files | Total Folders | Total Size | -| --------------- | ----------- | ------------- | ------------ | -| Before ModClean | 16,179 | 1,941 | 71.24 MB | -| After ModClean | 12,192 | 1,503 | 59.35 MB | -| Reduced | **3,987** | **438** | **11.88 MB** | +| | Total Files | Total Folders | Total Size | +| --------------- | ----------- | ------------- | ----------- | +| Before ModClean | 11,112 | 857 | 32.40 MB | +| After ModClean | 9,721 | 705 | 27.62 MB | +| Reduction | **1,391** | **152** | **4.77 MB** | #### Using Safe and Caution Patterns `modclean -n default:safe,default:caution` -| | Total Files | Total Folders | Total Size | -| --------------- | ----------- | ------------- | ------------ | -| Before ModClean | 16,179 | 1,941 | 71.24 MB | -| After ModClean | 11,941 | 1,473 | 55.28 MB | -| Reduced | **4,238** | **468** | **15.95 MB** | +| | Total Files | Total Folders | Total Size | +| --------------- | ----------- | ------------- | ----------- | +| Before ModClean | 11,112 | 857 | 32.40 MB | +| After ModClean | 9,689 | 705 | 26.78 MB | +| Reduction | **1,423** | **152** | **5.62 MB** | #### Using Safe, Caution and Danger Patterns `modclean --patterns="default:*"` -| | Total Files | Total Folders | Total Size | -| --------------- | ----------- | ------------- | ------------ | -| Before ModClean | 16,179 | 1,941 | 71.24 MB | -| After ModClean | 11,684 | 1,444 | 51.76 MB | -| Reduced | **4,495** | **497** | **19.47 MB** | +| | Total Files | Total Folders | Total Size | +| --------------- | ----------- | ------------- | ----------- | +| Before ModClean | 11,112 | 857 | 32.40 MB | +| After ModClean | 9,628 | 701 | 25.89 MB | +| Reduction | **1,484** | **156** | **6.50 MB** | That makes a huge difference in the amount of files and disk space. diff --git a/bin/modclean.js b/bin/modclean.js index 2bb1242..f809a08 100755 --- a/bin/modclean.js +++ b/bin/modclean.js @@ -3,18 +3,14 @@ const chalk = require('chalk'); const program = require('commander'); -const notifier = require('update-notifier'); -const clui = require('clui'); +const util = require('util'); const path = require('path'); -const os = require('os'); -const pkg = require('../package.json'); +const fs = require('fs'); const utils = require('./utils'); const modclean = require('../lib/modclean'); const ModClean = modclean.ModClean; -notifier({ pkg }).notify(); - function list(val) { return val.split(','); } @@ -25,36 +21,39 @@ process.on('SIGINT', function() { }); program - .version(pkg.version) + .version(modclean.version) .description('Remove unwanted files and directories from your node_modules folder') - .usage('modclean [options]') - .option('-t, --test', 'Run modclean and return results without deleting files') + .usage('[options]') .option('-p, --path ', 'Path to run modclean on (defaults to current directory)') .option('-D, --modules-dir ', 'Modules directory name (defaults to "node_modules")') + .option('-n, --patterns ', 'Patterns plugins/rules to use (defaults to "default:safe")', list) + .option('-a, --additional-patterns ', 'Additional glob patterns to search for', list) + .option('-I, --ignore ', 'Comma separated list of patterns to ignore', list) + .option('-t, --test', 'Run modclean and return results without deleting files') .option('-s, --case-sensitive', 'Matches are case sensitive') - .option('-i, --interactive', 'Each deleted file/folder requires confirmation') .option('-P, --no-progress', 'Hide progress bar') .option('-e, --error-halt', 'Halt script on error') .option('-v, --verbose', 'Run in verbose mode') .option('-f, --follow-symlink', 'Clean symlinked packages as well') .option('-r, --run', 'Run immediately without warning') - .option('-n, --patterns ', 'Patterns plugins/rules to use (defaults to "default:safe")', list) - .option('-a, --additional-patterns ', 'Additional glob patterns to search for', list) - .option('-I, --ignore ', 'Comma separated list of patterns to ignore', list) .option('--no-dirs', 'Exclude directories from being removed') .option('--no-dotfiles', 'Exclude dot files from being removed') .option('-k, --keep-empty', 'Keep empty directories') + .option('-m, --modules', 'Delete modules that are matched by patterns') + .option('-l, --log', 'Output log files') .parse(process.argv); class ModClean_CLI { constructor() { this.log = utils.initLog(program.verbose); - + + // Add a space at the top + console.log("\n"); + // Display CLI header console.log( - "\n" + chalk.yellow.bold('MODCLEAN ') + - chalk.gray(' Version ' + pkg.version) + "\n" + chalk.gray(' Version ' + modclean.version) + "\n" ); // Display "running in test mode" message @@ -71,7 +70,7 @@ class ModClean_CLI { chalk.red.bold('WARNING:') + "\n" + chalk.gray(utils.warningMsg) + "\n" ); - + return utils.confirm('Are you sure you want to continue?', (res) => { if(!res) return process.exit(0); this.start(); @@ -82,19 +81,19 @@ class ModClean_CLI { } start() { - let self = this, - platform = os.platform(); - this.stats = { - current: 0, total: 0, - skipped: [], - currentEmpty: 0, + deleted: 0, + skipped: 0, + deletedEmpty: 0, totalEmpty: 0 }; - - // Disable progress bar in interactive mode - if(program.interactive) program.progress = false; + + this.logs = { + deleted: [], + skipped: [], + errors: [] + }; let options = { cwd: program.path || process.cwd(), @@ -109,176 +108,172 @@ class ModClean_CLI { ignoreCase: !program.caseSensitive, test: !!program.test, followSymlink: !!program.followSymlink, - process: function(file, cb) { - self.stats.current += 1; - if(!program.interactive) return cb(true); - - let name = path.relative(options.cwd, file); - if(platform === 'win32') name = name.replace(/\\+/g, '/'); - - utils.confirm( - chalk.gray(`(${self.stats.current}/${self.stats.total})`) + - `${name} ${chalk.gray(' - ')} ${chalk.yellow.bold('Delete File?')}`, - function(res) { - if(!res) self.stats.skipped.push(file); - cb(res); - }); - } + skipModules: !program.modules }; this.modclean = new ModClean(options); - + this.initEvents(); - this.modclean.clean(this.done.bind(this)); + + this.modclean.clean() + .then(this.done.bind(this)) + .catch(this.fail.bind(this)); } initEvents() { - var inst = this.modclean; - - let progressBar = new clui.Progress(40), - spinner = new clui.Spinner('Loading...'), - showProgress = true; - - if(!process.stdout.isTTY || program.interactive || !program.progress || program.verbose) showProgress = false; - - function updateProgress(current, total) { - if(showProgress) { - process.stdout.cursorTo(0); - process.stdout.write(progressBar.update(current, total)); - process.stdout.clearLine(1); - } - } - - function showSpinner(msg) { - if(!process.stdout.isTTY || program.verbose || !program.progress) { - console.log(msg); - } else { - spinner.message(msg); - spinner.start(); - } - } - - // Start Event (searching for files) - inst.on('start', () => { - this.log('event', 'start'); + var inst = this.modclean, + showProgress = true, + spinner, progress; + + if(!process.stdout.isTTY || !program.progress || program.verbose) showProgress = false; + + inst.on('clean:start', () => { + this.log('event', 'clean:start'); this.log('verbose', '\n' + JSON.stringify(inst.options, null, 4)); - - showSpinner(`Searching for files in ${inst.options.cwd}...`); + + spinner = utils.spinner(`Searching for files in ${inst.options.cwd}...`); }); - - // Files Event (file list after searching complete) - inst.on('files', (files) => { - this.log('event', 'files'); - if(process.stdout.isTTY) spinner.stop(); - + + inst.on('file:find', () => { + this.log('event', 'file:find'); + }); + + inst.on('file:list', files => { + this.log('event', 'file:list'); this.stats.total = files.length; - - console.log(`Found ${chalk.green.bold(files.length)} files/folders to remove\n`); + + spinner.succeed(`Found ${files.length} files to remove`); }); - - inst.on('process', (files) => { - this.log('event', 'process'); - - if(!showProgress && !program.interactive && !program.verbose) - console.log('Deleting files, please wait...'); - - updateProgress(0, files.length); + + inst.on('process:start', files => { + this.log('event', 'process:start'); + + if(!showProgress) console.log('Deleting files, please wait...'); + if(showProgress) progress = utils.progressBar(files.length); }); - - // Deleted Event (called for each file deleted) - inst.on('deleted', (file) => { - updateProgress(this.stats.current, this.stats.total); - + + inst.on('file:skipped', file => { + this.log('event', 'file:skipped'); + this.stats.skipped += 1; + this.logs.skipped.push(file.fullPath); + this.log( 'verbose', - `${chalk.yellow.bold('DELETED')} (${this.stats.current}/${this.stats.total}) ${chalk.gray(file)}` + `${chalk.magenta.bold('SKIPPED')} ${chalk.gray(file.path)}` ); + + if(showProgress) progress.tick(1); }); - - inst.on('beforeEmptyDirs', () => { - this.log('event', 'beforeEmptyDirs'); - - showSpinner(`Searching for empty directories in ${inst.options.cwd}...`); + + inst.on('file:deleted', file => { + this.log('event', 'file:deleted'); + this.stats.deleted += 1; + this.logs.deleted.push(file.fullPath); + + this.log( + 'verbose', + `${chalk.yellow.bold('DELETED')} ${chalk.gray(file.path)}` + ); + + if(showProgress) progress.tick(1); }); - - inst.on('emptyDirs', (dirs) => { - this.log('event', 'emptyDirs'); - - if(process.stdout.isTTY) spinner.stop(); - + + inst.on('process:done', () => { + this.log('event', 'process:done'); + if(showProgress) progress.terminate(); + }); + + inst.on('emptydir:start', () => { + this.log('event', 'emptydir:start'); + spinner = utils.spinner(`Searching for empty directories in ${inst.options.cwd}...`); + }); + + inst.on('emptydir:list', dirs => { + this.log('event', 'emptydir:list'); + spinner.succeed(`Found ${dirs.length} empty directories to remove`); + this.stats.totalEmpty = dirs.length; - console.log(`\nFound ${chalk.green.bold(dirs.length)} empty directories to remove\n`); if(!dirs.length) return; - if(!showProgress && !program.interactive && !program.verbose) - console.log('Deleting empty directories, please wait...'); - - updateProgress(0, dirs.length); + if(!showProgress) console.log('Deleting empty directories, please wait...'); + if(showProgress) progress = utils.progressBar(dirs.length); }); - - inst.on('deletedEmptyDir', (dir) => { - this.stats.currentEmpty += 1; - updateProgress(this.stats.currentEmpty, this.stats.totalEmpty); - + + inst.on('emptydir:deleted', dir => { + this.log('event', 'emptydir:deleted'); + this.stats.deletedEmpty += 1; + this.logs.deleted.push(dir); + this.log( 'verbose', - `${chalk.yellow.bold('DELETED EMPTY DIR')} (${this.stats.currentEmpty}/${this.stats.totalEmpty}) ${chalk.gray(dir)}` + `${chalk.yellow.bold('DELETED EMPTY DIR')} ${chalk.gray(dir)}` ); + + if(showProgress) progress.tick(1); }); - - inst.on('afterEmptyDirs', () => { - if(showProgress) process.stdout.write('\n'); - this.log('event', 'afterEmptyDirs'); + + inst.on('emptydir:done', () => { + this.log('event', 'emptydir:done'); + if(showProgress) progress.terminate(); }); - - // Error Event (called as soon as an error is encountered) + inst.on('error', (err) => { this.log('event', 'error'); - this.log('error', err.error); - }); - - // FileError Event (called when there was an error deleting a file) - inst.on('fileError', (err) => { - this.log('event', 'fileError'); - this.log('error', `${chalk.red.bold('FILE ERROR:')} ${err.error}\n${chalk.gray(err.file)}`); + this.log('error', err.message); + + this.logs.errors.push({ + message: err.message, + method: err.method, + payload: err.payload + }); }); - - // Finish Event (once processing/deleting all files is complete) - inst.on('finish', (results) => { - if(showProgress) process.stdout.write('\n'); - this.log('event', 'finish'); - + + inst.on('clean:complete', res => { + this.log('event', 'clean:complete'); + this.log( 'verbose', - `${chalk.green('FINISH')} Deleted ${chalk.yellow.bold(results.length)} files/folders of ${chalk.yellow.bold(this.stats.total)}` + `${chalk.green('FINISH')} Deleted ${chalk.yellow.bold(res.deleted.length)} files/folders of ${chalk.yellow.bold(this.stats.total + this.stats.totalEmpty)}` ); }); - - // Complete Event (once everything has completed) - inst.on('complete', () => { - this.log('event', 'complete'); - }); } - - done(err, results) { + + async writeLogs() { + if(!program.log) return; + let writeFile = util.promisify(fs.writeFile); + + try { + await writeFile(path.join(process.cwd(), 'modclean-deleted.log'), JSON.stringify(this.logs.deleted)); + await writeFile(path.join(process.cwd(), 'modclean-skipped.log'), JSON.stringify(this.logs.skipped)); + await writeFile(path.join(process.cwd(), 'modclean-errors.log'), JSON.stringify(this.logs.errors)); + } catch(e) { + console.error('Error while creating log files:'); + console.error(e); + } + + return true; + } + + async fail(err) { + this.log('error', err); + + await this.writeLogs(); + process.stdin.destroy(); + return process.exit(1); + } + + async done(results) { console.log( "\n" + chalk.green.bold('FILES/FOLDERS DELETED') + "\n" + - ` ${chalk.yellow.bold('Total: ')} ${results.length}\n` + - ` ${chalk.yellow.bold('Skipped: ')} ${this.stats.skipped.length}\n` + - ` ${chalk.yellow.bold('Empty: ')} ${this.stats.totalEmpty}\n` + ` ${chalk.yellow.bold('Total: ')} ${results.deleted.length}\n` + + ` ${chalk.yellow.bold('Skipped: ')} ${this.stats.skipped}\n` + + ` ${chalk.yellow.bold('Empty: ')} ${this.stats.deletedEmpty}\n` ); - setTimeout(() => { - process.stdin.destroy(); - - if(err) { - this.log('error', err); - return process.exit(1); - } - - process.exit(0); - }, 500); + await this.writeLogs(); + process.stdin.destroy(); + return process.exit(0); } } diff --git a/bin/utils.js b/bin/utils.js index 1c0f787..3bdc82f 100644 --- a/bin/utils.js +++ b/bin/utils.js @@ -3,7 +3,9 @@ */ "use strict"; const readline = require('readline'); -const chalk = require('chalk'); +const chalk = require('chalk'); +const ora = require('ora'); +const ProgressBar = require('progress'); exports.warningMsg = ` This module deletes files from the filesystem and cannot be recovered. @@ -12,9 +14,8 @@ exports.warningMsg = work correctly after running this script. You can easily restore your modules by deleting your "node_modules" folder and running "npm install" again. If you have any concerns with deleting files, you should run this utility with the - "--test" flag first or with the "-i" flag to make the process interactive. The - author or contributors shall not be held responsible for damages this module - might cause. Please see the README for more information.\n\n`; + "--test" flag first. The author or contributors shall not be held responsible + for damages this module might cause. Please see the README for more information.\n\n`; exports.confirm = function(msg, cb) { process.stdin.setEncoding('utf8'); @@ -30,6 +31,17 @@ exports.confirm = function(msg, cb) { }); }; +exports.progressBar = function(total) { + return new ProgressBar('[:bar] :percent (:current/:total) :etas', { + total: total, + incomplete: ' ', + width: 30 + }); +}; + +exports.spinner = function(msg) { + return ora(msg).start(); +}; exports.initLog = function(verbose) { let types = { diff --git a/lib/modclean.js b/lib/modclean.js index 0b70aef..246a95c 100644 --- a/lib/modclean.js +++ b/lib/modclean.js @@ -4,16 +4,22 @@ * @author Kyle Ross */ "use strict"; -const glob = require('glob'); -const rimraf = require('rimraf'); +const pkg = require('../package.json'); + +const fs = require('fs'); const path = require('path'); +const util = require('util'); + +const on = require('await-handler'); const subdirs = require('subdirs'); -const emptyDir = require('empty-dir'); -const each = require('async-each-series'); -const fs = require('fs'); +const glob = util.promisify(require('glob')); +const rm = util.promisify(require('rimraf')); +const emptyDir = util.promisify(require('empty-dir')); + +const stat = util.promisify(fs.stat); +const readdir = util.promisify(fs.readdir); -const Utils = require('./utils.js'); -const EventEmitter = require('events').EventEmitter; +const ModClean_Utils = require('./utils.js'); let defaults = { /** @@ -28,12 +34,12 @@ let defaults = { patterns: ['default:safe'], /** * Array of additional patterns to use. - * @type {Array} + * @type {?Array} */ - additionalPatterns: [], + additionalPatterns: null, /** * Ignore the provided glob patterns - * @type {Array|null} + * @type {?Array} */ ignorePatterns: null, /** @@ -51,18 +57,22 @@ let defaults = { * @type {Boolean} */ dotFiles: true, - /** - * Function (async or sync) to call before each file is deleted to give ability to prevent deletion. (Optional, default `null`) - * If called with 3 arguments (file, files, cb) it's async and `cb` must be called with a result (`false` skips file). - * If called with 1 or 2 arguments (file, files) it's sync and `return false` will skip the file. - * @type {Function|null} - */ - process: null, /** * The folder name used for storing modules. Used to append to `options.cwd` if running in parent directory (default `"node_modules"`) * @type {String} */ modulesDir: 'node_modules', + /** + * Filter function to run for each file/directory found. File object is passed in as a single argument. Return `true` to mark file for removal, + * `false` to keep it. Optionally may return `Promise` for async support. + * @type {?Function} + */ + filter: null, + /** + * If the directory being deleted is a module, skip it's deletion. + * @type {Boolean} + */ + skipModules: true, /** * Remove empty directories as part of the cleanup process (default `true`) * @type {Boolean} @@ -70,14 +80,17 @@ let defaults = { removeEmptyDirs: true, /** * Filter function used when checking if a directory is empty - * @param {String} file File name to filter against + * @param {String} file File name in directory to filter against * @return {Boolean} `true` if is a valid file, `false` if invalid file */ emptyDirFilter: function(file) { - if(/Thumbs\.db$/i.test(file) || /\.DS_Store$/i.test(file)) return false; - - return true; + return !/(Thumbs\.db|\.DS_Store)$/i.test(file); }, + /** + * Custom options to pass into `glob` to further customize file searching. Will override existing options. + * @type {?Object} + */ + globOptions: {}, /** * Whether file deletion errors should halt the module from running and return the error to the callback (default `false`) * @type {Boolean} @@ -97,262 +110,289 @@ let defaults = { /** * @class ModClean - * @extends {EventEmitter} + * @extends {ModClean_Utils} */ -class ModClean extends EventEmitter { +class ModClean extends ModClean_Utils { /** - * Initalizes ModClean class with provided options. If `cb` is provided, it will start `clean()`. - * @param {Object} options Options to configure ModClean - * @param {Function} cb Optional callback function, if provided, `clean()` is automatically called. + * Initalizes ModClean class with provided options. + * @constructor + * @param {?Object} [options={}] Options to configure ModClean */ - constructor(options, cb) { + constructor(options={}) { super(); - - this.utils = new Utils(this); - this.errors = []; - - if(typeof options === 'function') cb = options; - this.options = Object.assign({}, modclean.defaults, options && typeof options === 'object' ? options : {}); - - this._patterns = this.utils.initPatterns(this.options); + this.version = pkg.version; + + this.options = Object.assign({}, modclean.defaults, options || {}); + this._patterns = this.initPatterns(); if(this.options.modulesDir !== false && path.basename(this.options.cwd) !== this.options.modulesDir) this.options.cwd = path.join(this.options.cwd, this.options.modulesDir); - - if(cb) this.clean(cb); } /** * Automated clean process that finds and deletes items based on the ModClean options. - * @param {Function} cb Callback to call once process is complete with `err` and `results`. - * @return {ModClean} Instance of ModClean + * @async + * @public + * @return {Promise} Promise that resolves an `results` object or rejects with an `Error`. */ - clean(cb) { - let opts = this.options; - - this.emit('start', this); - - let done = (err, results) => { - this.emit('complete', err, results); - if(typeof cb === 'function') cb(err, results); - }; - - this._find((err, files) => { - if(err) return done(err); - - this._process(files, (err, results) => { - if(err || !opts.removeEmptyDirs) return done(err, results); - - this.cleanEmptyDirs((err, dirs) => { - return done(err, Array.isArray(dirs)? results.concat(dirs) : results); - }); - }); - }); - - return this; + async clean() { + let files, dirs, results; + this.emit('clean:start', this); + + try { + files = await this._find(); + results = await this._process(files); + dirs = await this.cleanEmptyDirs(); + + if(dirs) { + results.deleted = results.deleted.concat(dirs.deleted); + results.empty = dirs.empty; + } + } catch(err) { + throw err; + } + + this.emit('clean:complete', results); + + return results; } /** * Finds files/folders based on the ModClean options. + * @async * @private - * @param {Function} cb Callback function to call once complete. + * @return {Promise} Resolves with array of file objects found. */ - _find(cb) { - let opts = this.options, - globOpts = { - cwd: opts.cwd, - dot: opts.dotFiles, - nocase: opts.ignoreCase, - ignore: this._patterns.ignore, - nodir: opts.noDirs - }; - - this.emit('beforeFind', this._patterns.allow, globOpts); - - glob(`**/@(${this._patterns.allow.join('|')})`, globOpts, (err, files) => { - if(err) this.utils.error(err, '_find'); - else this.emit('files', files); - cb(err, files); - }); + async _find() { + let defaultGlobOpts = { + cwd: this.options.cwd, + dot: this.options.dotFiles, + nocase: this.options.ignoreCase, + ignore: this._patterns.ignore, + nodir: this.options.noDirs, + follow: this.options.followSymlink + }; + + let globOpts = Object.assign({}, defaultGlobOpts, this.options.globOptions); + + this.emit('file:find', this._patterns.allow, globOpts); + + let [err, results] = await on(glob(`**/@(${this._patterns.allow.join('|')})`, globOpts)); + if(err) throw this.error(err, '_find'); + + let [e, files] = await on( + Promise.all( + results.map(async file => await this._buildFileObject(file)) + ) + ); + + if(e) throw e; + + files = files.filter(obj => !!obj); + + this.emit('file:list', files); + return files; } - + /** - * Processes the found files and deletes the ones that pass `options.process`. + * Creates file object for the specified `file` path + * @async * @private - * @param {Array} files List of file paths to process. - * @param {Function} cb Callback function to call once complete. + * @param {String} file File path to create object for + * @return {Object} Object with all file properties */ - _process(files, cb) { - let self = this, - opts = this.options, - processFn = typeof opts.process === 'function'? opts.process : function() { return true; }, - results = []; - - if(!files.length) return cb(null, []); - - this.emit('process', files); - - each(files, function(file, callback) { - let next = () => { - if(processFn.length <= 1) { - // If processFn has 0 or 1 argument (file), then assume sync - if(processFn(file) !== false) self._deleteFile(file, (err) => { - if(!err) results.push(file); - callback(err); - }); - else callback(); - } else { - // If processFn more than 1 argument (file, cb), then assume async - processFn(file, function(result) { - if(result !== false) self._deleteFile(file, (err) => { - if(!err) results.push(file); - callback(err); - }); - else callback(); - }); - } - }; + async _buildFileObject(file) { + let obj = { + path: file, + fullPath: path.join(this.options.cwd, file), + dir: path.join(this.options.cwd, path.parse(file).dir), + name: path.basename(file), + isModule: false, + isDirectory: null + }; + + try { + let stats = await stat(obj.fullPath), + isDir = stats.isDirectory(); + + obj.isDirectory = isDir; - if(opts.followSymlink) { - next(); - } else { - fs.lstat(path.join(opts.cwd, path.parse(file).dir), (err, stat) => { - if(err || !stat.isSymbolicLink()) { - next(); - return; - } - callback(); - }); + if(isDir) { + let list = await readdir(obj.fullPath), + parent = path.basename(path.join(obj.fullPath, '../')); + + if(list.indexOf('package.json') !== -1 && parent === (this.options.modulesDir || 'node_modules')) obj.isModule = true; } - }, function(err) { - /** - * @event finish - * @property {Array} results List of files successfully deleted - */ - self.emit('finish', results); - cb(err, results); - }); + + obj.stat = stats; + } catch(error) { + this.error(error, '_buildFileObject'); + return null; + } + + return obj; } /** - * Deletes a single file/folder from the filesystem. + * Processes the found files and deletes them in parallel. + * @async * @private - * @param {String} file File path to delete. - * @param {Function} cb Callback function to call once complete. + * @param {Array} files List of file paths to process and delete. + * @return {Promise} Resolves with `results` object. */ - _deleteFile(file, cb) { - let self = this, - opts = this.options; - - function done() { - self.emit('deleted', file); - return cb(null, file); - } - - // If test mode is enabled, just return the file. - if(opts.test) return done(); - - rimraf(path.join(opts.cwd, file), (err) => { - if(err) { - this.utils.error(err, '_deleteFile', { file }, 'fileError'); - return opts.errorHalt? cb(err, file) : cb(); - } + async _process(files) { + let results = { + files, + deleted: [] + }; - return done(); - }); + if(!files.length) return results; + + this.emit('process:start', files); + + let [err] = await on( + Promise.all(files.map(async file => { + let [e, res] = await on(this._deleteFile(file)); + if(e) throw e; + + if(res) results.deleted.push(file.fullPath); + return res; + })) + ); + + if(err) throw err; + + this.emit('process:done', results.deleted); + + return results; } /** - * Finds and removes all empty directories. - * @param {Function} cb Callback to call once complete. + * Deletes a single provided file/folder from the filesystem. + * @async + * @private + * @param {Object} file File object to delete. + * @return {Promise} Resolves with boolean if file was deleted. */ - cleanEmptyDirs(cb) { - let self = this, - opts = this.options, - results = []; - // If test mode is enabled or removeEmptyDirs is disabled, just return. - if(opts.test || !opts.removeEmptyDirs) return cb(); - - this.emit('beforeEmptyDirs'); - - function done(err, res) { - self.emit('afterEmptyDirs', results); - return cb(err, res); + async _deleteFile(file) { + let shouldDelete = true; + + if(typeof this.options.filter === 'function') { + let res = this.options.filter(file); + if(res instanceof Promise) shouldDelete = await res; + else shouldDelete = !!res; } + + if(this.options.skipModules && file.isModule) shouldDelete = false; + + if(!shouldDelete) { + this.emit('file:skipped', file); + return false; + } + + if(this.options.test) { + this.emit('file:deleted', file); + return true; + } + + let [err] = await on(rm(file.fullPath)); + if(err && err.code !== 'ENOENT') { + err = this.error(err, '_deleteFile', { file }); + if(this.options.errorHalt) throw err; + } else { + this.emit('file:deleted', file); + } + + return !err; + } - this._findEmptyDirs((err, dirs) => { - if(err) return done(err); - - this._removeEmptyDirs(dirs, (err, res) => { - return done(err, res); - }); - }); + /** + * Finds and removes all empty directories automatically. + * @async + * @public + * @return {Promise} Resolves with `results` object. + */ + async cleanEmptyDirs() { + if(!this.options.removeEmptyDirs) return false; + + this.emit('emptydir:start'); + + + let [error, dirs] = await on(this._findEmptyDirs()); + if(error) throw error; + + let results = await this._removeEmptyDirs(dirs); + + this.emit('emptydir:done', results); + + return { + empty: dirs, + deleted: results + }; } /** * Finds all empty directories within `options.cwd`. + * @async * @private - * @param {Function} cb Callback to call once complete. + * @return {Promise} Resolves with array of empty directory paths found. */ - _findEmptyDirs(cb) { - let self = this, - results = []; - - subdirs(this.options.cwd, function(err, dirs) { - if(err) self.utils.error(err, '_findEmptyDirs'); - if(err || !Array.isArray(dirs)) return cb(err, []); - - each(dirs, (dir, dCb) => { - emptyDir(dir, self.options.emptyDirFilter || function() { return true; }, (err, isEmpty) => { - if(err) self.utils.error(err, '_findEmptyDirs'); - if(err || !isEmpty) return dCb(); - results.push(dir); - dCb(); - }); - }, (err) => { - self.emit('emptyDirs', results); - cb(err, results); - }); - }); + async _findEmptyDirs() { + let results = []; + + let [err, dirs] = await on(subdirs(this.options.cwd)); + if(err) throw this.error(err, '_findEmptyDirs'); + if(!Array.isArray(dirs)) return []; + + for(let dir of dirs) { + let [e, isEmpty] = await on(emptyDir(dir, this.options.emptyDirFilter)); + if(e) this.error(err, '_findEmptyDirs', { dir }); + else if(isEmpty) results.push(dir); + } + + this.emit('emptydir:list', results); + return results; } /** * Removes all empty directories provided in `dirs`. + * @async * @private - * @param {Array} dirs List of empty directories to remove. - * @param {Function} cb Callback function to call once complete. + * @param {Array} dirs List of empty directories to remove. + * @return {Promise} Resolves with array of empty directories in which were successfully removed. */ - _removeEmptyDirs(dirs, cb) { - let self = this, - results = []; - - each(dirs, (dir, dCb) => { - rimraf(dir, (err) => { - if(err) self.utils.error(err, '_removeEmptyDirs', { dir }, 'emptyDirError'); - else { - results.push(dir); - self.emit('deletedEmptyDir', dir); - } - - dCb(); - }); - }, (err) => { - cb(err, results); - }); + async _removeEmptyDirs(dirs) { + let results = []; + + // Return all empty directories if in test mode + if(this.options.test) return dirs; + + for(let dir in dirs) { + let [err] = await on(rm(dir)); + if(err) this.error(err, '_removeEmptyDirs', { dir }); + else { + results.push(dir); + this.emit('emptydir:deleted', dir); + } + } + + return results; } } -// export modclean +/** + * Exports a wrapper for the ModClean @class . + * @type {Function} + */ module.exports = modclean; /** - * Shortcut for calling `new ModClean(options, cb).clean()` - * @param {Object} options Options to set for ModClean (Optional) - * @param {Function} cb Callback function to call once completed or if error - * @return {Object} New ModClean instance + * Shortcut for calling `new ModClean(options, cb)` + * @param {?Object} options Options to set for ModClean + * @return {ModClean} New ModClean instance */ -function modclean(options, cb) { - return new ModClean(options, cb); +function modclean(options) { + return new ModClean(options); } /** @@ -361,5 +401,14 @@ function modclean(options, cb) { */ modclean.defaults = defaults; -// Export ModClean class +/** + * Static property with access to the ModClean class. + * @type {Class} + */ modclean.ModClean = ModClean; + +/** + * The ModClean version number. + * @type {String} + */ +modclean.version = pkg.version; diff --git a/lib/utils.js b/lib/utils.js index e72360b..9efcd02 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -2,62 +2,73 @@ const uniq = require('lodash.uniq'); const path = require('path'); +const EventEmitter = require('events').EventEmitter; -class ModClean_Utils { - constructor(inst) { - this._inst = inst; +/** + * @class ModClean_Utils + * @extends {EventEmitter} + */ +class ModClean_Utils extends EventEmitter { + /** + * Constructor function + * @constructor + */ + constructor() { + super(); + this.errors = []; } /** * Initializes patterns based on the configuration - * @param {Object} opts Options object for ModClean * @return {Object} The compiled and loaded patterns */ - initPatterns(opts) { - let patDefs = opts.patterns, + initPatterns() { + let patDefs = this.options.patterns, patterns = [], ignore = []; - if(!Array.isArray(patDefs)) patDefs = [patDefs]; - - patDefs.forEach((def) => { - def = def.split(':'); - let mod = def[0], - name = def[1], - loader = this._loadPatterns(mod), - results; - - mod = loader.module; - results = loader.patterns; - - let all = Object.keys(results).filter(function(val) { - return val[0] !== '$'; - }); - - if(!name) { - if(results.$default) name = results.$default; - else name = all[0]; - } + if(patDefs) { + if(!Array.isArray(patDefs)) patDefs = [patDefs]; - if(name === '*') name = all; - - let rules = Array.isArray(name)? name : name.split(','); - - rules.forEach(function(rule) { - if(!results.hasOwnProperty(rule)) throw new Error(`Module "${mod}" does not contain rule "${rule}"`); - let obj = results[rule]; + patDefs.forEach((def) => { + def = def.split(':'); + let mod = def[0], + name = def[1], + loader = this._loadPatterns(mod), + results; - if(Array.isArray(obj)) return patterns = patterns.concat(obj); + mod = loader.module; + results = loader.patterns; - if(typeof obj === 'object') { - if(obj.hasOwnProperty('patterns')) patterns = patterns.concat(obj.patterns); - if(obj.hasOwnProperty('ignore')) ignore = ignore.concat(obj.ignore); + let all = Object.keys(results).filter(function(val) { + return val[0] !== '$'; + }); + + if(!name) { + if(results.$default) name = results.$default; + else name = all[0]; } + + if(name === '*') name = all; + + let rules = Array.isArray(name)? name : name.split(','); + + rules.forEach(function(rule) { + if(!results.hasOwnProperty(rule)) throw new Error(`Module "${mod}" does not contain rule "${rule}"`); + let obj = results[rule]; + + if(Array.isArray(obj)) return patterns = patterns.concat(obj); + + if(typeof obj === 'object') { + if(obj.hasOwnProperty('patterns')) patterns = patterns.concat(obj.patterns); + if(obj.hasOwnProperty('ignore')) ignore = ignore.concat(obj.ignore); + } + }); }); - }); + } - let addlPats = opts.additionalPatterns, - addlIgnore = opts.ignorePatterns; + let addlPats = this.options.additionalPatterns, + addlIgnore = this.options.ignorePatterns; if(Array.isArray(addlPats) && addlPats.length) patterns = patterns.concat(addlPats); if(Array.isArray(addlIgnore) && addlIgnore.length) ignore = ignore.concat(addlIgnore); @@ -65,6 +76,8 @@ class ModClean_Utils { patterns = uniq(patterns); ignore = uniq(ignore); + patterns = patterns.filter(pat => ignore.indexOf(pat) === -1); + if(!patterns.length) throw new Error('No patterns have been loaded, nothing to check against'); return { @@ -89,7 +102,7 @@ class ModClean_Utils { if(ext === '.js' || ext === '.json') patterns = require(module); else throw new Error(`Invalid pattern module "${def}" provided`); } else { - if(module.match(/modclean\-patterns\-/) === null) module = 'modclean-patterns-' + module; + if(module.match(/modclean-patterns-/) === null) module = 'modclean-patterns-' + module; try { patterns = require(module); @@ -109,22 +122,21 @@ class ModClean_Utils { /** * Stores error details and emits error event - * @param {Error} err Error object - * @param {String} method Method in which the error occurred - * @param {Object} obj Optional object to combine into the stored error object - * @param {String} event Event name to emit, `false` disables - * @return {Object} The compiled error object + * @param {Error} err Error object + * @param {String} method Method in which the error occurred + * @param {?Object} obj Optional object to combine into the stored error object + * @param {?String} event Event name to emit, `false` disables + * @return {Error} The compiled error object */ error(err, method, obj={}, event='error') { - let errObj = Object.assign({ - error: err, - method: method - }, obj || {}); + if(typeof err !== 'object') err = new Error(err); + err.method = method; + err.payload = obj; - this._inst.errors.push(errObj); - if(event !== false) this._inst.emit(event, errObj); + this.errors.push(err); + if(event !== false) this.emit(event, err); - return errObj; + return err; } } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b248b17 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,466 @@ +{ + "name": "modclean", + "version": "3.0.0-beta.1", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=" + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "^1.9.0" + } + }, + "assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true + }, + "await-handler": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/await-handler/-/await-handler-1.1.0.tgz", + "integrity": "sha512-AYY1IVVWacfpAOzdWs2vrHzavhnZa2C+lRfiAiNWf73/XmUDz5AMKrQl50GXcIhA0R+Q5U67TRlXQCqvccr5BA==" + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true + }, + "chai": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.1.2.tgz", + "integrity": "sha1-D2RYS6ZC8PKs4oBiefTwbKI61zw=", + "dev": true, + "requires": { + "assertion-error": "^1.0.1", + "check-error": "^1.0.1", + "deep-eql": "^3.0.0", + "get-func-name": "^2.0.0", + "pathval": "^1.0.0", + "type-detect": "^4.0.0" + } + }, + "chalk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "check-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", + "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", + "dev": true + }, + "cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", + "requires": { + "restore-cursor": "^2.0.0" + } + }, + "cli-spinners": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-1.3.1.tgz", + "integrity": "sha512-1QL4544moEsDVH9T/l6Cemov/37iv1RtoKf7NJ04A60+4MREXNfx/QvavbH6QoGdsD4N4Mwy49cmaINR/o2mdg==" + }, + "clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=" + }, + "color-convert": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.1.tgz", + "integrity": "sha512-mjGanIiwQJskCC18rPR6OmrZ6fm2Lc7PeGFYwCmy5J34wC6F1PzdGL6xeMfmgicfYcNLGuVFA3WzXtIDCQSZxQ==", + "requires": { + "color-name": "^1.1.1" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + }, + "commander": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", + "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==" + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "deep-eql": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", + "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", + "dev": true, + "requires": { + "type-detect": "^4.0.0" + } + }, + "defaults": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", + "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", + "requires": { + "clone": "^1.0.2" + } + }, + "diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "dev": true + }, + "empty-dir": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/empty-dir/-/empty-dir-1.0.0.tgz", + "integrity": "sha512-97qcDM6mUA1jAeX6cktw7akc5awIGA+VIkA5MygKOKA+c2Vseo/xwKN0JNJTUhZUtPwZboKVD2p1xu+sV/F4xA==" + }, + "es6-promise": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", + "integrity": "sha1-oIzd6EzNvzTQJ6FFG8kdS80ophM=" + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" + }, + "fs-extra": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-6.0.1.tgz", + "integrity": "sha512-GnyIkKhhzXZUWFCaJzvyDLEEgDkPfb4/TPvJCJVuS8MWZgoSsErf++QpiAlDnKFcqhRlm+tIOcencCjyJE6ZCA==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, + "gar": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/gar/-/gar-1.0.3.tgz", + "integrity": "sha512-zDpwk/l3HbhjVAvdxNUTJFzgXiNy0a7EmE/50XT38o1z+7NJbFhp+8CDsv1Qgy2adBAwUVYlMpIX2fZUbmlUJw==", + "dev": true + }, + "get-folder-size": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/get-folder-size/-/get-folder-size-2.0.0.tgz", + "integrity": "sha512-5h4efQY/sHvf9ZuwOan1HgNaRyApKnJjZ1ZdTOPkpTjIHZNqeMTabBU/LLN6lU9jncBwxJKFcG9cuqiGhu47uQ==", + "dev": true, + "requires": { + "gar": "^1.0.2", + "tiny-each-async": "2.0.3" + } + }, + "get-func-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", + "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", + "dev": true + }, + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "graceful-fs": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz", + "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=", + "dev": true + }, + "growl": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", + "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" + }, + "he": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", + "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6" + } + }, + "lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=" + }, + "log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "requires": { + "chalk": "^2.0.1" + } + }, + "mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==" + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "mocha": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-5.2.0.tgz", + "integrity": "sha512-2IUgKDhc3J7Uug+FxMXuqIyYzH7gJjXECKe/w43IGgQHTSj3InJi+yAA7T24L9bQMRKiUEHxEX37G5JpVUGLcQ==", + "dev": true, + "requires": { + "browser-stdout": "1.3.1", + "commander": "2.15.1", + "debug": "3.1.0", + "diff": "3.5.0", + "escape-string-regexp": "1.0.5", + "glob": "7.1.2", + "growl": "1.10.5", + "he": "1.1.1", + "minimatch": "3.0.4", + "mkdirp": "0.5.1", + "supports-color": "5.4.0" + } + }, + "modclean-patterns-default": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/modclean-patterns-default/-/modclean-patterns-default-1.1.1.tgz", + "integrity": "sha1-UvCFIbosOVPt9XEdKEeYipRuHGQ=" + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1" + } + }, + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "ora": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-2.1.0.tgz", + "integrity": "sha512-hNNlAd3gfv/iPmsNxYoAPLvxg7HuPozww7fFonMZvL84tP6Ox5igfk5j/+a9rtJJwqMgKK+JgWsAQik5o0HTLA==", + "requires": { + "chalk": "^2.3.1", + "cli-cursor": "^2.1.0", + "cli-spinners": "^1.1.0", + "log-symbols": "^2.2.0", + "strip-ansi": "^4.0.0", + "wcwidth": "^1.0.1" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + }, + "pathval": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz", + "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=", + "dev": true + }, + "progress": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.0.tgz", + "integrity": "sha1-ihvjZr+Pwj2yvSPxDG/pILQ4nR8=" + }, + "restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", + "requires": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + } + }, + "rimraf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.2.tgz", + "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==", + "requires": { + "glob": "^7.0.5" + } + }, + "semver-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-2.0.0.tgz", + "integrity": "sha512-mUdIBBvdn0PLOeP3TEkMH7HHeUP3GjsXCwKarjv/kGmUFOYg1VqEemKhoQpWMu6X2I8kHeuVdGibLGkVK+/5Qw==", + "dev": true + }, + "signal-exit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=" + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "requires": { + "ansi-regex": "^3.0.0" + } + }, + "subdirs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/subdirs/-/subdirs-1.0.1.tgz", + "integrity": "sha1-1lJkeHR25Mr378VJj7dAxp9ibUg=", + "requires": { + "es6-promise": "^3.0.2" + } + }, + "supports-color": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", + "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", + "requires": { + "has-flag": "^3.0.0" + } + }, + "tiny-each-async": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tiny-each-async/-/tiny-each-async-2.0.3.tgz", + "integrity": "sha1-jru/1tYpXxNwAD+7NxYq/loKUdE=", + "dev": true + }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, + "universalify": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.1.tgz", + "integrity": "sha1-+nG63UQ3r0wUiEHjs7Fl+enlkLc=", + "dev": true + }, + "wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=", + "requires": { + "defaults": "^1.0.3" + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + } + } +} diff --git a/package.json b/package.json index 9fce442..9629f33 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,17 @@ { "name": "modclean", - "version": "2.1.2", + "version": "3.0.0-beta.1", "description": "Remove unwanted files and directories from your node_modules folder", "main": "index.js", "bin": { "modclean": "./bin/modclean.js" }, "scripts": { - "test": "npm install && mocha test.js" + "test": "./node_modules/.bin/mocha -S" }, "repository": { "type": "git", - "url": "https://github.com/ModClean/modclean" + "url": "https://github.com/ModClean/modclean/tree/3.0.0-dev" }, "keywords": [ "module", @@ -25,7 +25,11 @@ "limit", "less", "files", - "folders" + "folders", + "prune", + "cruft", + "delete", + "npm" ], "author": "Kyle Ross ", "contributors": [ @@ -35,22 +39,29 @@ "bugs": { "url": "https://github.com/ModClean/modclean/issues" }, - "homepage": "https://github.com/ModClean/modclean", + "homepage": "https://github.com/ModClean/modclean/tree/3.0.0-dev", "dependencies": { - "async-each-series": "^1.1.0", - "chalk": "^1.1.3", - "clui": "^0.3.1", + "await-handler": "^1.1.0", + "chalk": "^2.4.1", "commander": "^2.9.0", - "empty-dir": "^0.2.1", - "glob": "^7.1.1", + "empty-dir": "^1.0.0", + "glob": "^7.1.2", "lodash.uniq": "^4.5.0", "modclean-patterns-default": "latest", + "ora": "^2.1.0", + "progress": "^2.0.0", "rimraf": "^2.5.4", - "subdirs": "^1.0.0", - "update-notifier": "^1.0.3" + "subdirs": "^1.0.1" }, "engines": { - "node": ">=6.9.0" + "node": ">=8.0.0" }, - "engineStrict": true + "engineStrict": true, + "devDependencies": { + "chai": "^4.1.2", + "fs-extra": "^6.0.1", + "get-folder-size": "^2.0.0", + "mocha": "^5.2.0", + "semver-regex": "^2.0.0" + } } diff --git a/test/0-exports.js b/test/0-exports.js new file mode 100644 index 0000000..2e4edae --- /dev/null +++ b/test/0-exports.js @@ -0,0 +1,29 @@ +const assert = require('chai').assert; +const semverRegex = require('semver-regex'); +const modclean = require('../'); + +describe ('Exports', () => { + it('should export a function', () => { + assert.isFunction(modclean); + }); + + it('should export default options', () => { + assert.isObject(modclean.defaults); + }); + + it ('should export access to ModClean class', () => { + assert.isFunction(modclean.ModClean); + }); + + it ('should export the version', () => { + assert.isString(modclean.version); + assert.match(modclean.version, semverRegex()); + }); + + describe ('Constructor Shortcut', () => { + it('should return new instance of ModClean', () => { + let mc = modclean(); + assert.instanceOf(mc, modclean.ModClean); + }); + }); +}); diff --git a/test/1-defaults.js b/test/1-defaults.js new file mode 100644 index 0000000..f7b8952 --- /dev/null +++ b/test/1-defaults.js @@ -0,0 +1,123 @@ +const assert = require('chai').assert; +const modclean = require('../'); + +describe ('Default Options', () => { + let def = modclean.defaults; + + it('should have all of the options', () => { + assert.hasAllKeys(modclean.defaults, [ + "cwd", + "patterns", + "additionalPatterns", + "ignorePatterns", + "noDirs", + "ignoreCase", + "dotFiles", + "modulesDir", + "filter", + "skipModules", + "removeEmptyDirs", + "emptyDirFilter", + "globOptions", + "errorHalt", + "test", + "followSymlink" + ]); + }); + + it ('should have defaults.cwd', () => { + assert.isString(def.cwd); + assert.equal(def.cwd, process.cwd()); + }); + + it ('should have defaults.patterns', () => { + assert.isArray(def.patterns); + assert.deepEqual(def.patterns, ['default:safe']); + }); + + it ('should have defaults.additionalPatterns', () => { + assert.isNull(def.additionalPatterns); + }); + + it ('should have defaults.ignorePatterns', () => { + assert.isNull(def.ignorePatterns); + }); + + it ('should have defaults.noDirs', () => { + assert.isBoolean(def.noDirs); + assert.isFalse(def.noDirs); + }); + + it ('should have defaults.ignoreCase', () => { + assert.isBoolean(def.ignoreCase); + assert.isTrue(def.ignoreCase); + }); + + it ('should have defaults.dotFiles', () => { + assert.isBoolean(def.dotFiles); + assert.isTrue(def.dotFiles); + }); + + it ('should have defaults.modulesDir', () => { + assert.isString(def.modulesDir); + assert.equal(def.modulesDir, 'node_modules'); + }); + + it ('should have defaults.filter', () => { + assert.isNull(def.filter); + }); + + it ('should have defaults.skipModules', () => { + assert.isBoolean(def.skipModules); + assert.isTrue(def.skipModules); + }); + + it ('should have defaults.removeEmptyDirs', () => { + assert.isBoolean(def.removeEmptyDirs); + assert.isTrue(def.removeEmptyDirs); + }); + + it ('should have defaults.emptyDirFilter', () => { + assert.isFunction(def.emptyDirFilter); + }); + + it ('should have defaults.globOptions', () => { + assert.isObject(def.globOptions); + assert.isEmpty(def.globOptions); + }); + + it ('should have defaults.errorHalt', () => { + assert.isBoolean(def.errorHalt); + assert.isFalse(def.errorHalt); + }); + + it ('should have defaults.test', () => { + assert.isBoolean(def.test); + assert.isFalse(def.test); + }); + + it ('should have defaults.followSymlink', () => { + assert.isBoolean(def.followSymlink); + assert.isFalse(def.followSymlink); + }); + + describe ('defaults.emptyDirFilter', () => { + it('should return true for valid file', () => { + assert.isTrue( + def.emptyDirFilter('/path/to/valid.txt') + ); + }); + + it ('should return false for invalid file (.DS_Store)', () => { + assert.isFalse( + def.emptyDirFilter('/path/to/.DS_Store') + ); + }); + + it('should return false for invalid file (Thumbs.db)', () => { + assert.isFalse( + def.emptyDirFilter('/path/to/Thumbs.db') + ); + }); + }); +}); diff --git a/test/2-class.js b/test/2-class.js new file mode 100644 index 0000000..6a4afa0 --- /dev/null +++ b/test/2-class.js @@ -0,0 +1,82 @@ +const assert = require('chai').assert; +const path = require('path'); +const semverRegex = require('semver-regex'); +const modclean = require('../'); + +describe ('ModClean Class', () => { + let mc = modclean(); + + it ('should have version property', () => { + assert.isString(mc.version); + assert.match(mc.version, semverRegex()); + }); + + it ('should have EventEmitter', () => { + assert.isFunction(mc.emit); + assert.isFunction(mc.on); + }); + + describe ('Default Options', () => { + let mc = modclean(); + + it('should set options.cwd', () => { + assert.equal(mc.options.cwd, path.join(process.cwd(), mc.options.modulesDir)); + }); + + it ('should set _patterns', () => { + assert.isObject(mc._patterns); + assert.hasAllKeys(mc._patterns, ['allow', 'ignore']); + + assert.isArray(mc._patterns.allow); + assert.isArray(mc._patterns.ignore); + }); + }); + + + describe ('Custom Options', () => { + let mc = modclean({ + patterns: ['default:*'], + additionalPatterns: ['custom.file'], + ignorePatterns: ['ignore.file', 'cname'], + modulesDir: false, + filter: function() { + return true; + }, + globOptions: { + dot: true + } + }); + + it('should just use options.cwd without modulesDir', () => { + assert.equal(mc.options.cwd, process.cwd()); + }); + + it('should set _patterns', () => { + assert.isObject(mc._patterns); + assert.hasAllKeys(mc._patterns, ['allow', 'ignore']); + + assert.isArray(mc._patterns.allow); + assert.isArray(mc._patterns.ignore); + }); + + it ('should add allowed patterns', () => { + assert.include(mc._patterns.allow, 'custom.file'); + }); + + it ('should add ignored patterns', () => { + assert.include(mc._patterns.ignore, 'ignore.file'); + }); + + it ('should remove ignored patterns from allowed patterns', () => { + assert.notInclude(mc._patterns.allow, 'cname'); + }); + + it ('should have a filter function', () => { + assert.isFunction(mc.options.filter); + }); + + it ('should have globOptions', () => { + assert.deepEqual(mc.options.globOptions, { dot: true }); + }); + }); +}); diff --git a/test/support/fixtures.js b/test/support/fixtures.js new file mode 100644 index 0000000..d969480 --- /dev/null +++ b/test/support/fixtures.js @@ -0,0 +1,42 @@ +const fs = require('fs-extra'); +const cp = require('child_process'); +const util = require('util'); +const path = require('path'); + +const execFile = util.promisify(cp.execFile); + +class ModClean_Fixtures { + constructor(opts = {}) { + this.opts = Object.assign({ + path: path.join(process.cwd(), 'test/fixtures'), + modules: ['express', 'lodash'] + }, opts); + + this.fixtures = this.opts.path; + } + + async setupDir() { + await fs.mkdirp(this.fixtures).catch(e => { throw e; }); + return this.fixtures; + } + + async execNPM(args) { + let options = { + cwd: this.fixtures + }; + + return await execFile('npm', args, options); + } + + async installModules(modules) { + await this.execNPM(['install'].concat(modules)).catch(e => { throw e; }); + return true; + } + + async cleanup() { + await fs.remove(this.fixtures).catch(e => { throw e; }); + return true; + } +} + +module.exports = ModClean_Fixtures;