diff --git a/LICENSE b/LICENSE index b79a620..f2b948b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 Akseli Palén +Copyright (c) 2024 Akseli Palén Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 037d832..238099c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # genversion -![Logo](doc/logo.png?raw=true "Abracadabra...and behold!") +![Logo](doc/genversion-logo-halo.png?raw=true "Abracadabra...and behold!") [![Travis build status](https://img.shields.io/travis/com/axelpale/genversion)](https://app.travis-ci.com/github/axelpale/genversion) [![npm version](https://img.shields.io/npm/v/genversion?color=green)](https://www.npmjs.com/package/genversion) @@ -27,53 +27,65 @@ Voilà! The new `version.js`: // Generated by genversion. module.exports = '1.2.3' -Use [flags](#command-line-api) to match your coding style. `$ genversion --es6 --semi version.js` creates: +Use [flags](#command-line-api) to match your coding style. `$ genversion --esm --semi version.js` creates: // Generated by genversion. export const version = '1.2.3'; +Need more data? Try `$ genversion --property name,version version.js`: + + // Generated by genversion. + exports.name = 'yourmodule'; + exports.version = '1.2.3'; + [Node API](#node-api) is also available: > const gv = require('genversion') > gv.generate('lib/version.js', { useSemicolon: true }, (err) => { ... }) -See API documentation below for details. +See [API documentation](#command-line-api) below for details. ## Integrate to your build +The following describes the basic way to use genversion as a part of your workflow. See [Advanced integration](#advanced-integration) for alternative ways that might suit you better. + First install via [npm](https://www.npmjs.com/package/genversion). $ npm install genversion --save-dev -Genversion works by reading the current version from your package.json and then generating a simple CommonJS module file that exports the version string. For safety, the version file begins with a signature that tells genversion that it is safe to overwrite the file in the future. +Genversion works by reading the current version from your package.json and then generating a module file that exports the version tag. For clarity, the file begins with a signature that tells both genversion and human readers that the file may be overwritten in future: // Generated by genversion. module.exports = '1.2.3' -Therefore, your job is to 1) choose a target path for the version file, 2) require() the new file into your module, and 3) add genversion to your build or release pipeline. For example, let us choose the path 'lib/version.js' and require it in yourmodule/index.js: +Therefore, your job is to 1) choose a target path for the module file, 2) import the new module into your project, and 3) add genversion to your build or release pipeline. For example, let us choose the path `lib/version.js` and expose it via `yourmodule/index.js` as follows: ... exports.version = require('./lib/version') - ... -If you use `--es6` flag: +If your project uses ECMAScript modules, you need to call genversion with `--esm`. In this case expose the version in `yourmodule/index.js` like this: ... import { version } from './lib/version' export const version - ... -Then, let us integrate genversion into your build task. +Then, let's add genversion into your build pipeline in `package.json` for example like this: "scripts": { "build": "genversion lib/version.js && other build stuff" } -The target path is given as the first argument. If the file already exists and has been previously created by genversion, it is replaced with the new one. If the file exists but is not by genversion, you will see a warning and the file stays untouched. +Altenatively, you might prefer adding genversion as a script and run it only upon a release: -Genversion reads the version from the `package.json` nearest to the target path. In case your project contains multiple `package.json` files along the path you can specify the one with `--source ` parameter. + "scripts": { + ... + "gv": "genversion lib/version.js", + "release": "npm run gv && npm run build && npm test && npm publish" + } -Finished! Now your module has a version property that matches with package.json and is updated every time you build the project. +If you use [npm version](https://docs.npmjs.com/cli/v10/commands/npm-version) for your version increments and like to run genversion as a `postversion` script, see [advanced integration](#advanced-integration). + +Finished! Now your module has a version property that matches with your package.json and is updated every time you build or release the project. > var yourmodule = require('yourmodule') > yourmodule.version @@ -86,25 +98,30 @@ Great! Having a version property in your module is very convenient for debugging Directly from `$ genversion --help`: - Usage: genversion [options] - - Generates a version module at the target filepath. - - Options: - -V, --version output genversion's own version - -v, --verbose increased output verbosity - -s, --semi use semicolons in generated code - -d, --double use double quotes in generated code - -b, --backtick use backticks in generated code - -e, --es6 use es6 syntax in generated code - -u, --strict add "use strict" in generated code - -p, --source search for package.json along a custom path - -c, --check-only check if the version module is up to date - -h, --help display help for command - -### -V, --version +``` +Usage: genversion [options] + +Generates a version module at the target filepath. + +Options: + -s, --semi use semicolons in generated code + -d, --double use double quotes in generated code + -b, --backtick use backticks in generated code + -e, --esm use ESM exports in generated code + --es6 alias for --esm flag + -u, --strict add "use strict" in generated code + -c, --check-only check if target is up to date + -f, --force force file rewrite upon generation + -p, --source search for package.json along a custom path + -P, --property select properties; default is "version" + -t, --template generate with a custom template + --template-engine select template engine; default is "ejs" + -v, --verbose increased output verbosity + -V, --version output genversion's own version + -h, --help display help for command +``` -Output the genversion's own version number. +The `target` path is given as the first argument after options. If the file already exists and has been previously created by genversion, it will be replaced. If the file exists but it looks like it was not created by genversion, you will see a warning, genversion exits with the exit code 1, and the file stays untouched unless you use `--force`. ### -s, --semi @@ -118,18 +135,14 @@ Use double quotes `"` instead of single quotes `'` as required by some style gui Use backticks `` ` `` instead of single `'` or double `"` quotes as required by some style guides. -### -e, --es6 +### -e, --esm, --es6 -Use ECMAScript 6 `export const` statement instead of `module.exports` in the generated code. +Use ECMAScript module export statement syntax `export const` instead of `module.exports` in the generated code. Prefer `--esm` because `--es6` might be deprecated in future. ### -u, --strict Prepend each generated file with `'use strict'` as required by some style guides. -### -p, --source - -Search for the package.json along a custom path up to the system root. Defaults to the target filepath. - ### -c, --check-only When `--check-only` flag is used, only the existence and validity of the version module is checked. No files are generated or modified. The flag is useful for pre-commit hooks and similar. @@ -141,6 +154,50 @@ The command exits with the exit code: The command with `--check-only` does not produce any output by default. Use `-v` to increase its verbosity. +### -f, --force + +Force rewrite of possibly pre-existing file at the target path. Otherwise genversion will rewrite the file only if it looks like it was created by genversion. + +### -p, --source + +By default, genversion finds and reads `package.json` nearest to the target path. In case your `package.json` is located somewhere else or you have multiple `package.json` files along the path, you can select the one with `--source ` parameter. + +### -P, --property + +Select which property or properties will be picked from package.json and inserted into the generated module. Specify multiple properties with a comma separated list without spaces, for example `-P name,version`. Note that with two or more properties and without `--esm` flag the module is generated with `exports` instead of `module.exports`. + +### -t, --template + +Use a custom template instead of the default template when generating the module. The template is called with two parameters `pkg` and `options` where the former is an object containing a set of properties picked from package.json and the latter is an object containing the formatting options. The template is free to respect or neglect the parameters provided. + +For example, consider the following `template.ejs`: + +``` +export default '<%= pkg.version %>'; +``` + +Calling `genversion --template template.ejs lib/version.js` would generate: + +``` +export default '1.2.3'; +``` + +Use `--properties` to control which package properties will be passed to the template in `pkg` object. Only `version` is passed by default. + +The default template engine is [EJS](https://github.com/mde/ejs). You can select another templating engine with `--template-engine`, given that genversion supports it. + +### --template-engine + +Select which template engine to use in order to process the template file selected with `--template`. Supported template engines are: `ejs`. + +### -v, --verbose + +By default, genversion prints output only upon a warning or error. Use `-v` to increase verbosity. + +### -V, --version + +Output the genversion's own version tag. + ## Node API @@ -153,17 +210,21 @@ The available properties and functions are listed below. ### genversion.check(targetPath, opts, callback) -Check if it is possible to generate the version module into `targetPath`. +Check if it is possible to generate the version module into `targetPath`. Check also does the file need an update. **Parameters:** - *targetPath:* string. An absolute or relative file path. Relative to `process.cwd()`. - *opts:* optional options. Available keys are: + - *properties:* optional array of strings. These properties will be picked from `package.json` and rendered. Defaults to `['version']`. - *source:* optional string. An absolute or relative path to a file or directory. Genversion searches for the source package.json along this path. Defaults to the value of `targetPath`. + - *template:* optional string. An absolute or relative path to a custom template file. + - *templateEngine:* optional string. Defaults to `ejs`. - *useSemicolon:* optional boolean. Defaults to `false`. - *useDoubleQuotes:* optional boolean. Defaults to `false`. - *useBackticks:* optional boolean. Defaults to `false`. - - *useEs6Syntax:* optional boolean. Defaults to `false`. + - *useEs6Syntax:* deprecated alias of `useEsmSyntax`. + - *useEsmSyntax:* optional boolean. Use ECMAScript modules. Defaults to `false`. - *useStrict:* optional boolean. Defaults to `false`. - *callback:* function (err, doesExist, isByGenversion, isUpToDate), where: - *doesExist:* boolean. True if a file at targetPath already exists. @@ -192,11 +253,15 @@ Read the version property from the nearest `package.json` along the `targetPath` - *targetPath:* string. An absolute or relative file path. Relative to `process.cwd()`. - *opts:* optional options. Available keys are: + - *properties:* optional array of strings. These properties will be picked from `package.json` and rendered. Defaults to `['version']`. - *source:* optional string. An absolute or relative path to a file or directory. Genversion searches for the source package.json along this path. Defaults to the value of `targetPath`. + - *template:* optional string. An absolute or relative path to a custom template file. + - *templateEngine:* optional string. Defaults to `ejs`. - *useSemicolon:* optional boolean. Defaults to `false`. - *useDoubleQuotes:* optional boolean. Defaults to `false`. - *useBackticks:* optional boolean. Defaults to `false`. - - *useEs6Syntax:* optional boolean. Defaults to `false`. + - *useEs6Syntax:* deprecated alias of `useEsmSyntax`. + - *useEsmSyntax:* optional boolean. Use ECMAScript modules. Defaults to `false`. - *useStrict:* optional boolean. Defaults to `false`. - *callback:* function (err, version). Parameter *version* is the version string read from `package.json`. Parameter *err* is non-null if `package.json` cannot be found, its version is not a string, or writing the version module fails. @@ -209,7 +274,7 @@ Read the version property from the nearest `package.json` along the `targetPath` console.log('Sliding into', version, 'like a sledge.'); }); - gv.generate('src/v.js', { useSemicolon: true }, function (err) { + gv.generate('src/v.js', { useSemicolon: true }, (err) => { if (err) { throw err } console.log('Generated version file with a semicolon.') }) @@ -218,11 +283,13 @@ Read the version property from the nearest `package.json` along the `targetPath` ### genversion.version -The version string of the genversion module in [semantic versioning](http://semver.org/) format. Generated with genversion itself, of course ;) +This property is a string that contains genversion's own version tag in [semantic versioning](http://semver.org/) format. Generated with genversion itself, of course ;) ## Projects using genversion +Following projects are using genversion and can work as examples of how to integrate genversion to your project. + - [genversion](https://www.npmjs.com/package/genversion) - [poisson-process](https://www.npmjs.com/package/poisson-process) - [tapspace](https://www.npmjs.com/package/tapspace) @@ -232,6 +299,8 @@ Do you use genversion in your project? We are happy to mention it in the list. J ## Related projects +If genversion fails to fit your use case, you might want to check out the following projects by others. + - [versiony](https://github.com/ciena-blueplanet/versiony) for version increments - [package-json-versionify](https://github.com/nolanlawson/package-json-versionify) for browserify builds - [browserify-versionify](https://www.npmjs.com/package/browserify-versionify) for browserify builds @@ -241,9 +310,17 @@ Do you use genversion in your project? We are happy to mention it in the list. J ## Contribute -Pull requests and [bug reports](https://github.com/axelpale/genversion/issues) are highly appreciated. Please test your contribution with the following scripts: +Pull requests and [bug reports](https://github.com/axelpale/genversion/issues) are highly appreciated. + +Clone the repository: + + $ git clone git@github.com:axelpale/genversion.git -Run test suite: +Install development tooling: + + $ cd genversion; npm install + +Please test your contribution. Run the test suite: $ npm run test @@ -251,10 +328,12 @@ Run only linter: $ npm run lint +Thank you. + ### Visual Studio Code integration -To configure VSCode debugger for genversion development, create a file `.vscode/launch.json` with the following contents and adjust to your liking: +If you are about to contribute using VS Code editor, you might want to configure VS Code debugger for genversion development. Create a file `.vscode/launch.json` with the following contents and adjust `args` to your liking: ``` { @@ -271,10 +350,8 @@ To configure VSCode debugger for genversion development, create a file `.vscode/ "args": [ "--semi", "--double", - "--backtick", - "--es6", + "--esm", "--strict", - "--check-only", "--verbose", "target.js" ] @@ -283,10 +360,43 @@ To configure VSCode debugger for genversion development, create a file `.vscode/ } ``` +## Advanced integration + +Consider the following tips to further integrate genversion to your workflow. + +### Integrate with npm version + +You might already use the `npm version ` command to increase your package version and automatically commit the change to the version control. If so, you can integrate genversion to this workflow by hooking to a `postversion` script that will be executed whenever you run the `npm version`. Here are the steps to do this: + +1. Install genversion as a dev dependency +2. Create a `postversion.sh` file with the following contents: +```sh +#!/bin/sh + +lastTag=$(git describe --exact-match --abbrev=0) +genversion lib/version.js +git add lib/version.js +git commit --amend --no-edit +git tag -fa $lastTag -m $lastTag +``` +3. Run `chmod +x postversion.sh` to make the script executable +4. Assign this script as the "postversion" script in `package.json` +``` +{ + ... + "scripts": { + "postversion": "./postversion.sh" + ... + } +} +``` + +Then, when you run `npm version major/minor/patch`, genversion is automatically called and correctly amends the version commit as well. + ## License -[MIT](LICENSE) +The genversion source code is released under [MIT](LICENSE) license. ## Artwork diff --git a/bin/genversion.js b/bin/genversion.js index bdb515c..c066e8e 100755 --- a/bin/genversion.js +++ b/bin/genversion.js @@ -1,7 +1,7 @@ #!/usr/bin/env node -const gv = require('../lib/genversion') -const v = require('../lib/version') +const gv = require('../index') +const csvToArray = require('../lib/csvToArray') const commander = require('commander') const path = require('path') @@ -9,45 +9,71 @@ const path = require('path') const program = commander.program program - .version(v, '-V, --version', 'output genversion\'s own version') .arguments('') .description('Generates a version module at the target filepath.') - .option('-v, --verbose', 'increased output verbosity') .option('-s, --semi', 'use semicolons in generated code') .option('-d, --double', 'use double quotes in generated code') .option('-b, --backtick', 'use backticks in generated code') - .option('-e, --es6', 'use es6 syntax in generated code') + .option('-e, --esm', 'use ESM exports in generated code') + .option(' --es6', 'alias for --esm flag') .option('-u, --strict', 'add "use strict" in generated code') + .option('-c, --check-only', 'check if target is up to date') + .option('-f, --force', 'force file rewrite upon generation') .option('-p, --source ', 'search for package.json along a custom path') - .option('-c, --check-only', 'check if the version module is up to date') + .option('-P, --property ', 'select properties; default is "version"') + .option('-t, --template ', 'generate with a custom template') + .option(' --template-engine ', + 'select template engine; default is "ejs"') + .option('-v, --verbose', 'increased output verbosity') + .version(gv.version, '-V, --version', 'output genversion\'s own version') .action((target, cliOpts) => { if (typeof target !== 'string' || target === '') { console.error('Missing argument: target') - return process.exit(1) + process.exitCode = 1 + return } + // Short alias for basename used a lot in log output + const targetBase = path.basename(target) // Short alias for verbosity as we use it a lot const verbose = cliOpts.verbose + + // Read properties. Open comma separated list + if (cliOpts.property && cliOpts.property.startsWith('-')) { + // Detect forgotten property list (argument becomes the next flag) + console.error('error: property cannot be empty.') + process.exitCode = 1 + return + } + cliOpts.properties = csvToArray(cliOpts.property) + if (cliOpts.properties.length === 0) { + cliOpts.properties = ['version'] + } + // Options for check and generate const opts = { + properties: cliOpts.properties, + source: cliOpts.source, + template: cliOpts.template, + templateEngine: cliOpts.templateEngine || 'ejs', useSemicolon: cliOpts.semi, useDoubleQuotes: cliOpts.double, useBackticks: cliOpts.backtick, - useEs6Syntax: cliOpts.es6, - useStrict: cliOpts.strict, - source: cliOpts.source + useEs6Syntax: cliOpts.es6 || cliOpts.esm, + useStrict: cliOpts.strict } - // A source path along to search for the package.json + // Default source path from which to search for the package.json. if (typeof cliOpts.source !== 'string' || cliOpts.source === '') { cliOpts.source = target } if (cliOpts.checkOnly) { - return gv.check(target, opts, (err, doesExist, isByGv, isUpToDate) => { + gv.check(target, opts, (err, doesExist, isByGv, isUpToDate) => { if (err) { console.error(err.toString()) - return process.exit(1) + process.exitCode = 1 + return } let exitCode = 1 @@ -66,15 +92,15 @@ program if (verbose) { switch (exitCode) { case 0: - console.log('The version module ' + path.basename(target) + + console.log('The version module ' + targetBase + ' is up to date.') break case 1: - console.error('The version module ' + path.basename(target) + + console.error('The version module ' + targetBase + ' could not be found.') break case 2: - console.error('The version module ' + path.basename(target) + + console.error('The version module ' + targetBase + ' has outdated or unknown content.') break default: @@ -82,14 +108,17 @@ program } } - return process.exit(exitCode) + process.exitCode = exitCode }) + // check completed, exit without generation + return } gv.check(target, opts, (err, doesExist, isByGenversion) => { if (err) { console.error(err.toString()) - return process.exit(1) + process.exitCode = 1 + return } if (doesExist) { @@ -97,32 +126,55 @@ program gv.generate(target, opts, (errg, version) => { if (errg) { console.error(errg) + process.exitCode = 1 return } if (verbose) { - console.log('Version module ' + path.basename(target) + + console.log('Version module ' + targetBase + ' was successfully updated to ' + version) } }) + } else if (cliOpts.force) { + // Forcefully rewrite unknown file. + if (verbose) { + console.warn('File ' + targetBase + + ' will be forcefully overwritten.') + } + + gv.generate(target, opts, (errg, version) => { + if (errg) { + console.error(errg) + process.exitCode = 1 + return + } + + if (verbose) { + console.log('Version module ' + targetBase + + ' was successfully generated with version ' + version) + } + }) } else { // FAIL, unknown file, do not replace console.error( - 'ERROR: File ' + target + ' is not generated by genversion and ' + - 'therefore will not be replaced. Please ensure that the file can ' + - 'be destroyed and remove it manually before retry.' + 'ERROR: Target file ' + target + ' exists and it has unexpected ' + + 'content. To be safe, the file will not be replaced. ' + + 'Please ensure that the file is not important and ' + + 'remove it manually before retry.' ) + process.exitCode = 1 } } else { // OK, file does not exist. gv.generate(target, opts, (errg, version) => { if (errg) { console.error(errg) + process.exitCode = 1 return } if (verbose) { - console.log('Version module ' + path.basename(target) + + console.log('Version module ' + targetBase + ' was successfully generated with version ' + version) } }) diff --git a/doc/genversion-logo-2k-halo.png b/doc/genversion-logo-2k-halo.png new file mode 100644 index 0000000..ff0fa25 Binary files /dev/null and b/doc/genversion-logo-2k-halo.png differ diff --git a/doc/logo-2k.png b/doc/genversion-logo-2k.png similarity index 100% rename from doc/logo-2k.png rename to doc/genversion-logo-2k.png diff --git a/doc/genversion-logo-halo.png b/doc/genversion-logo-halo.png new file mode 100644 index 0000000..33f8059 Binary files /dev/null and b/doc/genversion-logo-halo.png differ diff --git a/doc/logo.png b/doc/genversion-logo.png similarity index 100% rename from doc/logo.png rename to doc/genversion-logo.png diff --git a/doc/logo-rain.html b/doc/logo-rain.html index b89c214..ed589cd 100644 --- a/doc/logo-rain.html +++ b/doc/logo-rain.html @@ -64,7 +64,7 @@

Emojitracker OpenMoji Visualization

const s = sprinkler.create(c) var images = [ - 'logo.png' + 'genversion-logo.png' ] var stop = s.start(images, { diff --git a/index.js b/index.js index bf12c17..c414a9b 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,3 @@ - -const gv = require('./lib/genversion') -const v = require('./lib/version') - -exports.check = gv.check -exports.generate = gv.generate -exports.version = v +exports.check = require('./lib/check') +exports.generate = require('./lib/generate') +exports.version = require('./lib/version') diff --git a/lib/check.js b/lib/check.js new file mode 100644 index 0000000..f75e2e6 --- /dev/null +++ b/lib/check.js @@ -0,0 +1,79 @@ +const dryRun = require('./dryRun') +const fs = require('fs') + +module.exports = (targetPath, opts, callback) => { + // Check if a version file can be generated. + // + // Parameters + // targetPath + // relative or absolute filepath to version file. + // opts + // optional options object. See dryRun docs for details. + // callback + // function (err, doesExist, isByGenversion, isUpToDate) + // err + // non-null on file system error + // doesExist + // boolean, if the version file exists + // isByGenversion + // boolean, true if file exists and is generated by genversion. + // The check is done by comparing the SIGNATURE on the first line. + // isUpToDate + // boolean, true if contents of the file are exactly as + // freshly generated. + // + if (typeof callback !== 'function') { + if (typeof opts !== 'function') { + throw new Error('Unexpected callback argument') + } else { + callback = opts + opts = {} + } + } + + dryRun(targetPath, opts, (err, result) => { + if (err) { + return callback(err) + } + + const absTarget = result.absoluteTargetPath + const referenceContent = result.generatedContent + + fs.readFile(absTarget, 'utf8', (errf, fileContent) => { + if (errf) { + if (errf.code === 'ENOENT') { + // OK, file does not exist. + return callback(null, false, false, false) + } + // Real error. + return callback(errf, false, false, false) + } + + // Issue axelpale/genversion#15 + // Remove all the CR characters inserted by git on clone/checkout + // when git configuration has core.autocrlf=true + while (fileContent.indexOf('\r') >= 0) { + fileContent = fileContent.replace(/\r/, '') + } + + // Get first line to test if we can touch the file. + // We should not touch the file if it does not resemble + // the content with which we are about to rewrite it. + const fingerprint = fileContent.trim().substring(0, 5).toLowerCase() + const refprint = referenceContent.trim().substring(0, 5).toLowerCase() + // Find the file begins like the generated content. + if (fingerprint !== refprint) { + // The file exists but is not created by genversion + return callback(null, true, false, false) + } + + if (fileContent !== referenceContent) { + // The file is created by genversion but has outdated content + return callback(null, true, true, false) + } + + // OK, the existing file was generated by genversion and is up to date. + return callback(null, true, true, true) + }) + }) +} diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..2d27805 --- /dev/null +++ b/lib/config.js @@ -0,0 +1,2 @@ +// The signature is the first line of the generated file. +exports.SIGNATURE = '// Generated by genversion.' diff --git a/lib/csvToArray.js b/lib/csvToArray.js new file mode 100644 index 0000000..0550f32 --- /dev/null +++ b/lib/csvToArray.js @@ -0,0 +1,20 @@ +module.exports = (csv) => { + if (!csv) { + return [] + } + + if (Array.isArray(csv)) { + // Spread comma-separated values and flatten + const flat = csv.reduce((acc, str) => { + return acc.concat(str.split(',')) + }, []).map(str => str.trim()) + + return flat + } + + if (typeof csv === 'string') { + return csv.split(',').map(str => str.trim()) + } + + return [] +} diff --git a/lib/dryRun.js b/lib/dryRun.js new file mode 100644 index 0000000..eae8f88 --- /dev/null +++ b/lib/dryRun.js @@ -0,0 +1,156 @@ +const makeAbsolute = require('./makeAbsolute') +const defaultTemplate = require('./template') +const pickPackage = require('./pickPackage') +const getTemplate = require('./getTemplate') + +module.exports = (targetPath, opts, callback) => { + // Dry-run version submodule generation. + // Works as a preprocessing step for exports.generate + // + // Parameters: + // targetPath + // string. absolute or relative path + // opts + // optional object with optional properties: + // properties + // an array of string. The keys of properties to pick from + // .. package.json. Default is `['version']`. + // source + // a file path string. A path to package.json + // template + // a file path string. A path to custom template. + // templateEngine + // a string. Defaults to 'ejs'. + // useSemicolon + // boolean. Set true to use semicolons in generated code + // useDoubleQuotes + // boolean. Set true to use double quotes in generated code + // instead of single quotes. + // useBackticks + // boolean. Set true to use backticks in generated code + // instead of single or double quotes. + // useEsmSyntax + // boolean. Set true to use ECMAScript modules syntax. + // .. Alias: useEs6Syntax + // useStrict + // boolean. Add the 'use strict' header + // callback + // function (err, dryRunResult) + // err + // null if generated successfully + // non-null if no package.json found or version in it is invalid + // dryRunResult, object with props: + // absoluteTargetPath + // absolute path of the target version submodule + // completeOptions + // validated and filled options object + // version + // new version string, undefined on error + // generatedContent + // string, contents for the new version submodule + // + + // Detect invalid path + if (typeof targetPath !== 'string') { + throw new Error('Unexpected targetPath argument') + } + // Handle callback fn in place of options. + if (typeof callback !== 'function') { + if (typeof opts !== 'function') { + throw new Error('Unexpected callback argument') + } else { + callback = opts + opts = {} + } + } + // Detect invalid options + if (typeof opts !== 'object') { + throw new Error('Unexpected opts argument') + } + + if (typeof opts.useSemicolon !== 'boolean') { + opts.useSemicolon = false // default + } + + if (typeof opts.useDoubleQuotes !== 'boolean') { + opts.useDoubleQuotes = false // default + } + + if (typeof opts.useBackticks !== 'boolean') { + opts.useBackticks = false // default + } + + if (typeof opts.useEs6Syntax !== 'boolean') { + opts.useEs6Syntax = false // default, alias + } + if (typeof opts.useEsmSyntax !== 'boolean') { + opts.useEsmSyntax = false // default + } + opts.useEsmSyntax = opts.useEs6Syntax || opts.useEsmSyntax + opts.useEs6Syntax = opts.useEsmSyntax + // TODO deprecate alias: useEs6Syntax + + if (typeof opts.useStrict !== 'boolean') { + opts.useStrict = false // default + } + + const absTarget = makeAbsolute(targetPath) + if (typeof opts.source !== 'string') { + opts.source = absTarget // default + } + + if (typeof opts.templateEngine !== 'string') { + opts.templateEngine = 'ejs' // default + } + + if (!Array.isArray(opts.properties) || opts.properties.length === 0) { + opts.properties = ['version'] + } + + // Pick version always regardless of parameters. + // TODO possibly drop version passing in v4 as unnecessary + let versionPackage = null + try { + versionPackage = pickPackage(opts.source, ['version']) + if (!versionPackage) { + throw new Error('No package.json found.') + } + } catch (e) { + return callback(e) + } + + const version = versionPackage.version + if (typeof version !== 'string') { + const err = new Error('Invalid version in package.json: ' + version) + return callback(err) + } + + // Then pick needed properties for the template. + const templateData = { + pkg: pickPackage(opts.source, opts.properties), + options: opts + } + + // And generate module. + let generatedContent = '' + if (opts.template) { + // Use custom template + let templateFn + try { + templateFn = getTemplate(opts.template, opts.templateEngine) + } catch (e) { + return callback(e) + } + generatedContent = templateFn(templateData) + } else { + // Use the default template + generatedContent = defaultTemplate(templateData) + } + + return callback(null, { + absoluteTargetPath: absTarget, + completeOptions: opts, + version, + generatedContent + }) +} diff --git a/lib/generate.js b/lib/generate.js new file mode 100644 index 0000000..6f189fd --- /dev/null +++ b/lib/generate.js @@ -0,0 +1,72 @@ +const dryRun = require('./dryRun') +const path = require('path') +const fs = require('fs') + +module.exports = (targetPath, opts, callback) => { + // Generate version submodule file to targetPath with utf-8 encoding. + // + // Parameters: + // targetPath + // string. absolute or relative path + // opts + // optional object with optional properties: + // properties + // an array of string. The keys of properties to pick from + // .. package.json. Default is `['version']`. + // source + // a file path string. A path to package.json + // useSemicolon + // a boolean. Set true to use semicolons in generated code + // useDoubleQuotes. + // a boolean. Set true to use double quotes in generated code + // instead of single quotes. + // useBackticks + // a boolean. Set true to use backticks in generated code + // instead of single or double quotes. + // useEsmSyntax + // a boolean. Set true to use ECMAScript module syntax. + // .. Alias: useEs6Syntax + // useStrict: + // a boolean. Add the 'use strict' header. + // callback + // function (err, version) + // err + // null if generated successfully + // non-null if no package.json found or version in it is invalid + // version + // new version string, undefined on error + // + if (typeof callback !== 'function') { + if (typeof opts !== 'function') { + throw new Error('Unexpected callback argument') + } else { + callback = opts + opts = {} + } + } + + dryRun(targetPath, opts, (err, result) => { + if (err) { + return callback(err) + } + + const absTarget = result.absoluteTargetPath + const absTargetDir = path.dirname(absTarget) + const content = result.generatedContent + const version = result.version + + // Ensure directory exists before writing file + fs.mkdir(absTargetDir, { recursive: true }, errp => { + if (errp) { + return callback(errp) + } + + fs.writeFile(absTarget, content, 'utf8', errw => { + if (errw) { + return callback(errw) + } + return callback(null, version) + }) + }) + }) +} diff --git a/lib/genversion.js b/lib/genversion.js index f682771..e69de29 100644 --- a/lib/genversion.js +++ b/lib/genversion.js @@ -1,263 +0,0 @@ - -const makeAbsolute = require('./makeAbsolute') -const path = require('path') -const findPackage = require('find-package') -const fs = require('fs') - -const PATTERN = require('./versionTools').PATTERN -const createContent = require('./versionTools').createContent - -exports.check = (targetPath, opts, callback) => { - // Check if a version file can be generated. - // - // Parameters - // targetPath - // relative or absolute filepath to version file. - // opts - // optional options object. See exports.dry docs for details. - // callback - // function (err, doesExist, isByGenversion, isUpToDate) - // err - // non-null on file system error - // doesExist - // boolean, if the version file exists - // isByGenversion - // boolean, true if file exists and is generated by genversion. - // The check is done by comparing the SIGNATURE on the first line. - // isUpToDate - // boolean, true if contents of the file are exactly as - // freshly generated. - // - if (typeof callback !== 'function') { - if (typeof opts !== 'function') { - throw new Error('Unexpected callback argument') - } else { - callback = opts - opts = {} - } - } - - exports.dry(targetPath, opts, (err, result) => { - if (err) { - return callback(err) - } - - const absTarget = result.absoluteTargetPath - const referenceContent = result.generatedContent - - fs.readFile(absTarget, 'utf8', (errf, fileContent) => { - if (errf) { - if (errf.code === 'ENOENT') { - // OK, file does not exist. - return callback(null, false, false, false) - } - // Real error. - return callback(errf, false, false, false) - } - - // Get first line to test if we can touch the file. - // We should not touch the file if it not created by genversion. - const linebreak = fileContent.indexOf('\n') - let firstline = fileContent.substring(0, linebreak) - // In the case fileContent is a single line - if (linebreak < 0) { - firstline = fileContent - } - // Find the signature pattern in the first line. - if (!firstline.match(PATTERN)) { - // The file exists but is not created by genversion - return callback(null, true, false, false) - } - - // Issue axelpale/genversion#15 - // Remove all the CR characters inserted by git on clone/checkout - // when git configuration has core.autocrlf=true - while (fileContent.indexOf('\r') >= 0) { - fileContent = fileContent.replace(/\r/, '') - } - - if (fileContent !== referenceContent) { - // The file is created by genversion but has outdated content - return callback(null, true, true, false) - } - - // OK, the existing file was generated by genversion and is up to date. - return callback(null, true, true, true) - }) - }) -} - -exports.dry = (targetPath, opts, callback) => { - // Dry-run version submodule generation. - // Works as a preprocessing step for exports.generate - // - // Parameters: - // targetPath - // string. absolute or relative path - // opts - // optional object with optional properties - // source - // file path string. A path to package.json - // useSemicolon - // boolean. Set true to use semicolons in generated code - // useDoubleQuotes - // boolean. Set true to use double quotes in generated code - // instead of single quotes. - // useBackticks - // boolean. Set true to use backticks in generated code - // instead of single or double quotes. - // useEs6Syntax - // boolean. Set true to use ES6 export syntax - // useStrict: - // boolean. Add the 'use strict' header - // callback - // function (err, dryRunResult) - // err - // null if generated successfully - // non-null if no package.json found or version in it is invalid - // dryRunResult, object with props: - // absoluteTargetPath - // absolute path of the target version submodule - // completeOptions - // validated and filled options object - // version - // new version string, undefined on error - // generatedContent - // string, contents for the new version submodule - // - if (typeof targetPath !== 'string') { - throw new Error('Unexpected targetPath argument') - } - - if (typeof callback !== 'function') { - if (typeof opts !== 'function') { - throw new Error('Unexpected callback argument') - } else { - callback = opts - opts = {} - } - } - - if (typeof opts !== 'object') { - throw new Error('Unexpected opts argument') - } - - if (typeof opts.useSemicolon !== 'boolean') { - opts.useSemicolon = false // default - } - - if (typeof opts.useDoubleQuotes !== 'boolean') { - opts.useDoubleQuotes = false // default - } - - if (typeof opts.useBackticks !== 'boolean') { - opts.useBackticks = false // default - } - - if (typeof opts.useEs6Syntax !== 'boolean') { - opts.useEs6Syntax = false // default - } - - if (typeof opts.useStrict !== 'boolean') { - opts.useStrict = false // default - } - - if (typeof opts.source !== 'string') { - opts.source = targetPath // default - } - - const absTarget = makeAbsolute(targetPath) - const absSource = makeAbsolute(opts.source) - - // Find closest package.json from the target towards filesystem root - const pjson = findPackage(absSource) - - // findPackage returns null if not found - if (pjson === null) { - const err = new Error('No package.json found along path ' + absSource) - return callback(err) - } - - // Get version property - const version = pjson.version - - // Ensure version is a string - if (typeof version !== 'string') { - const err = new Error('Invalid version in package.json: ' + version) - return callback(err) - } - - const content = createContent(version, opts) - - return callback(null, { - absoluteTargetPath: absTarget, - completeOptions: opts, - version: version, - generatedContent: content - }) -} - -exports.generate = (targetPath, opts, callback) => { - // Generate version submodule file to targetPath with utf-8 encoding. - // - // Parameters: - // targetPath - // string. absolute or relative path - // opts - // optional object with optional properties - // source - // file path string. A path to package.json - // useSemicolon - // boolean. Set true to use semicolons in generated code - // useDoubleQuotes - // boolean. Set true to use double quotes in generated code - // instead of single quotes. - // useBackticks - // boolean. Set true to use backticks in generated code - // instead of single or double quotes. - // useEs6Syntax - // boolean. Set true to use ES6 export syntax - // useStrict: - // boolean. Add the 'use strict' header - // callback - // function (err, version) - // err - // null if generated successfully - // non-null if no package.json found or version in it is invalid - // version - // new version string, undefined on error - // - if (typeof callback !== 'function') { - if (typeof opts !== 'function') { - throw new Error('Unexpected callback argument') - } else { - callback = opts - opts = {} - } - } - - exports.dry(targetPath, opts, (err, result) => { - if (err) { - return callback(err) - } - - const absTarget = result.absoluteTargetPath - const absTargetDir = path.dirname(absTarget) - const content = result.generatedContent - const version = result.version - - // Ensure directory exists before writing file - fs.mkdir(absTargetDir, { recursive: true }, errp => { - if (errp) { - return callback(errp) - } - - fs.writeFile(absTarget, content, 'utf8', errw => { - if (errw) { - return callback(errw) - } - return callback(null, version) - }) - }) - }) -} diff --git a/lib/getTemplate.js b/lib/getTemplate.js new file mode 100644 index 0000000..c10f0a3 --- /dev/null +++ b/lib/getTemplate.js @@ -0,0 +1,52 @@ +const makeAbsolute = require('./makeAbsolute') +const ejs = require('ejs') +const fs = require('fs') + +module.exports = (templatePath, engine) => { + // Create a template function from a template file at the given path + // using the selected engine. + // + // Parameters: + // templatePath + // a string being absolute or relative file path. + // engine + // a string, the name of the template engine to use. + // + // Return: + // a function (data) + // + // Throws + // + + // Normalize params + const absolutePath = makeAbsolute(templatePath) + engine = engine.toLowerCase() + + // Read the template file + let file = '' + try { + file = fs.readFileSync(absolutePath, 'utf8') + } catch (e) { + if (e.code === 'ENOENT') { + throw new Error('Missing template file: ' + templatePath) + } else { + throw e + } + } + + // Compile the template using selected engine. + let templateFn = null + if (engine === 'ejs') { + const options = {} + try { + templateFn = ejs.compile(file, options) + } catch (e) { + throw new Error('Bad template: ' + e.message) + } + } else { + throw new Error('Template engine is not supported: ' + engine) + } + + // Return the compiled function. + return templateFn +} diff --git a/lib/pickPackage.js b/lib/pickPackage.js new file mode 100644 index 0000000..38f7a62 --- /dev/null +++ b/lib/pickPackage.js @@ -0,0 +1,37 @@ +const makeAbsolute = require('./makeAbsolute') +const findPackage = require('find-package') + +module.exports = (path, properties) => { + // Pick properties from package.json. + // + // Parameters: + // path + // a string + // properties + // an array of string, the names of properties to pick. + // + // Return + // an object + // + // Throws if no package.json found along the path. + // + const absSource = makeAbsolute(path) + + // Find closest package.json from the target towards filesystem root + const pjson = findPackage(absSource) + + // findPackage returns null if not found + if (pjson === null) { + throw new Error('No package.json found along path ' + absSource) + } + + // Pick selected properties from package + const subjson = properties.reduce((acc, key) => { + if (key in pjson) { + acc[key] = pjson[key] + } + return acc + }, {}) + + return subjson +} diff --git a/lib/reservedWords.js b/lib/reservedWords.js new file mode 100644 index 0000000..a08eac8 --- /dev/null +++ b/lib/reservedWords.js @@ -0,0 +1,76 @@ +// These reserved words should be quoted if used as an object key. +// For example, the dependencies object in package.json can list package names +// that match one of the following words. +module.exports = [ + // ECMAScript 3 + 'int', + 'byte', + 'char', + 'goto', + 'long', + 'final', + 'float', + 'short', + 'double', + 'native', + 'throws', + 'boolean', + 'abstract', + 'volatile', + 'transient', + 'synchronized', + // ECMAScript 5 + 'break', + 'case', + 'catch', + 'continue', + 'debugger', + 'default', + 'delete', + 'do', + 'else', + 'finally', + 'for', + 'function', + 'if', + 'in', + 'instanceof', + 'new', + 'return', + 'switch', + 'this', + 'throw', + 'try', + 'typeof', + 'var', + 'void', + 'while', + 'with', + // ECMAScript 6 + 'class', + 'const', + 'enum', + 'export', + 'extends', + 'import', + 'super', + 'implements', + 'interface', + 'let', + 'package', + 'private', + 'protected', + 'public', + 'static', + 'yield', + // Literals + 'null', + 'true', + 'false', + 'NaN', + 'Infinity', + 'undefined', + // Other + 'eval', + 'arguments' +] diff --git a/lib/stringify.js b/lib/stringify.js new file mode 100644 index 0000000..15b4ded --- /dev/null +++ b/lib/stringify.js @@ -0,0 +1,78 @@ +const reservedWords = require('./reservedWords') + +const stringifyKey = (key, options) => { + // Attempt to quote object key if necessary. + // Note: does not solve all the cases just the common ones + // such as a package name that has dash in its name. + // See https://mathiasbynens.be/notes/javascript-identifiers + const quote = options.quote + if (key.indexOf('-') >= 0) { + return quote + key + quote + } + if (reservedWords.indexOf(key) >= 0) { + return quote + key + quote + } + return key +} + +const stringifyKeyValue = (key, value, options) => { + return stringifyKey(key, options) + ': ' + stringify(value, options) +} + +const stringify = (value, options) => { + // Convert value to string that is valid JavaScript. + // Format and quote depending on the value type. + // + const quote = options.quote + const valueType = typeof value + + if (valueType === 'string') { + // value is from JSON and thus can have + // - unescaped double or single quotes ' + if (quote === '\'') { + // escape unescaped single quote + const escapedValue = value.replaceAll('\'', '\\\'') + return quote + escapedValue + quote + } else if (quote === '"') { + // escape unescaped double quote + const escapedValue = value.replaceAll('"', '\\"') + return quote + escapedValue + quote + } else if (quote === '`') { + // escape unescaped backtick + const escapedValue = value.replaceAll('`', '\\`') + return quote + escapedValue + quote + } + // else + return quote + value + quote + } + + if (valueType === 'number') { + return '' + value + } + + if (valueType === 'boolean') { + return '' + value + } + + if (valueType === 'object') { + if (Array.isArray(value)) { + return '[' + value.map(v => stringify(v, options)).join(', ') + ']' + } + + if (value === null) { + return 'null' + } + + const keys = Object.keys(value) + const mapper = k => stringifyKeyValue(k, value[k], options) + return '{ ' + keys.map(mapper).join(', ') + ' }' + } + + if (valueType === 'undefined') { + return 'undefined' + } + + throw new Error('Unexpected property value type: ' + valueType) +} + +module.exports = stringify diff --git a/lib/template.js b/lib/template.js new file mode 100644 index 0000000..18f80f4 --- /dev/null +++ b/lib/template.js @@ -0,0 +1,76 @@ +const SIGNATURE = require('./config').SIGNATURE +const stringify = require('./stringify') + +module.exports = (data) => { + // This is the default template function for the generated module. + // + // Parameters: + // data + // an object with properties: + // pkg + // an object. A stripped down subset of package.json properties. + // options + // optional object with optional properties: + // useSemicolon + // a boolean. True to use semicolons in generated code + // useDoubleQuotes + // a boolean. Set true to use double quotes in generated code + // instead of single quotes. + // useBackticks + // a boolean. Set true to use backticks in generated code + // .. instead of single or double quotes. + // useEsmSyntax + // a boolean. True to use ECMAScript module syntax. + // useStrict + // a boolean. Add the 'use strict' header + // + // Return: + // a string. The version module contents. + // + const intro = SIGNATURE + '\n' + const pkg = data.pkg + const opts = data.options + + let Q = opts.useDoubleQuotes ? '"' : '\'' + Q = opts.useBackticks ? '`' : Q + + const SEMI = opts.useSemicolon ? ';' : '' + + const keys = Object.keys(pkg) + + let exporter + if (opts.useEsmSyntax) { + exporter = 'export const ' + } else if (keys.length > 1) { + exporter = 'exports.' + } else { + // Single property + exporter = 'module.exports' + } + + // Options for stringify + const strOpts = { + quote: Q + } + + // Begin content build + let content = intro + + // In some cases 'use strict' is required in the file + // Can have comments before, but must be first statement + if (opts.useStrict) { + content += Q + 'use strict' + Q + SEMI + '\n\n' + } + + if (keys.length === 1 && !opts.useEsmSyntax) { + const prop = pkg[keys[0]] + content += exporter + ' = ' + stringify(prop, strOpts) + SEMI + '\n' + } else { + keys.forEach(key => { + const prop = pkg[key] + content += exporter + key + ' = ' + stringify(prop, strOpts) + SEMI + '\n' + }) + } + + return content +} diff --git a/lib/version.js b/lib/version.js index 701ab95..3f6b812 100644 --- a/lib/version.js +++ b/lib/version.js @@ -1,2 +1,2 @@ // Generated by genversion. -module.exports = '3.1.1' +module.exports = '3.2.0-rc' diff --git a/lib/versionTools.js b/lib/versionTools.js index 23584ad..e69de29 100644 --- a/lib/versionTools.js +++ b/lib/versionTools.js @@ -1,56 +0,0 @@ -// The signature is the first line of the generated file. -const SIGNATURE = '// Generated by genversion.' -// If the pattern matches the first line of a file, -// we assume the file has been previously generated by genversion. -// Allow signature to have OS-specific line-endings. See PR#6 -// Allow signatures to have different case and trailing dot. See PR#18 -const PATTERN = /\/\/\s+generated by genversion/i - -exports.SIGNATURE = SIGNATURE -exports.PATTERN = PATTERN - -exports.createContent = (version, opts) => { - // Create content for the version module - // - // Parameters: - // version: string, version tag - // opts: optional object with optional properties: - // useSemicolon: - // boolean. True to use semicolons in generated code - // useDoubleQuotes - // boolean. Set true to use double quotes in generated code - // instead of single quotes. - // useBackticks - // boolean. Set true to use backticks in generated code - // instead of single or double quotes. - // useEs6Syntax: - // boolean. True to use ES6 export syntax in generated code - // useStrict: - // boolean. Add the 'use strict' header - // - // Return: - // string. The version module contents. - // - let content = SIGNATURE + '\n' - - let Q = opts.useDoubleQuotes ? '"' : '\'' - Q = opts.useBackticks ? '`' : Q - - const SEMI = opts.useSemicolon ? ';' : '' - - // In some cases 'use strict' is required in the file - // Can have comments before, but must be first statement - if (opts.useStrict) { - content += Q + 'use strict' + Q + SEMI + '\n\n' - } - - if (opts.useEs6Syntax) { - content += 'export const version' - } else { - content += 'module.exports' - } - - content += ' = ' + Q + version + Q + SEMI + '\n' - - return content -} diff --git a/package.json b/package.json index a32db38..d0e492e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "genversion", - "version": "3.1.1", + "version": "3.2.0-rc", "description": "A command line utility to read version from package.json and attach it into your module as a property", "keywords": [ "release", @@ -38,15 +38,14 @@ "license": "MIT", "dependencies": { "commander": "^7.2.0", + "ejs": "^3.1.9", "find-package": "^1.0.0" }, "devDependencies": { - "command-line-test": "^1.0.10", - "eslint": "^7.32.0", "fs-extra": "^10.0.1", "mocha": "^10.2.0", "should": "^13.1.0", - "standard": "^16.0.4" + "standard": "^17.1.0" }, "engines": { "node": ">=10.0.0" diff --git a/test/api.test.js b/test/api.test.js index 97eb93a..743c789 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -64,7 +64,7 @@ describe('genversion api', () => { }) it('should recognise es6 flag', (done) => { - gv.generate(P, { useEs6Syntax: true }, (err, version) => { + gv.generate(P, { useEsmSyntax: true }, (err, version) => { should.equal(err, null) version.should.equal(pjson.version) diff --git a/test/cli.test.js b/test/cli.test.js index eeca953..9a73354 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -1,7 +1,7 @@ /* global describe,it,afterEach,beforeEach */ // See https://www.npmjs.com/package/command-line-test -const CliTest = require('command-line-test') +const CliTest = require('./clitest') const path = require('path') const fs = require('fs-extra') const should = require('should') // eslint-disable-line no-unused-vars @@ -19,6 +19,9 @@ const P = '.tmp/v.js' const createTemp = (content) => { fs.outputFileSync(P, content) } +const existsTemp = () => { + return fs.existsSync(P) +} const readTemp = () => { return fs.readFileSync(P).toString() } @@ -105,8 +108,7 @@ describe('genversion cli', () => { return } - // NOTE: response.stderr is null because process exited with code 1 - response.error.code.should.equal(1) + response.exitCode.should.equal(1) return done() }) @@ -129,6 +131,22 @@ describe('genversion cli', () => { }) }) + it('should allow --esm flag', (done) => { + const clit = new CliTest() + + clit.exec(GENERATE_COMMAND + ' --esm ' + P, (err, response) => { + if (err) { + console.error(err, response) + return + } + + readTemp().should.equal(SIGNATURE + + 'export const version = \'' + pjson.version + '\'\n') + + return done() + }) + }) + it('should allow --strict flag', (done) => { const clit = new CliTest() @@ -211,10 +229,10 @@ describe('genversion cli', () => { }) }) - it('should allow --semi and --es6 flag', (done) => { + it('should allow --semi and --esm flag', (done) => { const clit = new CliTest() - clit.exec(GENERATE_COMMAND + ' --semi --es6 ' + P, (err, response) => { + clit.exec(GENERATE_COMMAND + ' --semi --esm ' + P, (err, response) => { if (err) { console.error(err, response) return @@ -317,14 +335,39 @@ describe('genversion cli', () => { }) }) + describe('flag --force', () => { + it('should generate if unknown file exists', (done) => { + // Generate file with unknown signature + const INVALID_SIGNATURE = 'foobarcontent' + createTemp(INVALID_SIGNATURE) + + const clit = new CliTest() + + clit.exec(GENERATE_COMMAND + ' --force ' + P, (err, response) => { + if (err) { + return done(err) + } + + // Should not have any output + response.stdout.should.equal('') + response.stderr.should.equal('') + + // Ensure the file exists and was replaced + fs.existsSync(P).should.equal(true) + readTemp().should.not.equal(INVALID_SIGNATURE) + + return done() + }) + }) + }) + describe('flag --version', () => { it('should show genversion\'s own version', (done) => { const clit = new CliTest() clit.exec(GENERATE_COMMAND + ' --version', (err, response) => { if (err) { - console.error(err) - return + return done(err) } response.stdout.should.equal(pjson.version) @@ -360,7 +403,7 @@ describe('genversion cli', () => { }) }) - it('should detect missing file', done => { + it('should detect missing file', (done) => { const clit = new CliTest() clit.exec(GENERATE_COMMAND + ' --check-only ' + P, (err, response) => { @@ -368,17 +411,17 @@ describe('genversion cli', () => { return done(err) } - response.error.code.should.equal(1) + response.exitCode.should.equal(1) // Should not have any output. // Maybe not good way to test because CliTest nulls stdout anyway. - should(response.stdout).equal(null) - should(response.stderr).equal(null) + should(response.stdout).equal('') + should(response.stderr).equal('') return done() }) }) - it('should detect a standard change', done => { + it('should detect a standard change', (done) => { const clit = new CliTest() clit.exec(GENERATE_COMMAND + ' ' + P, (err) => { @@ -393,28 +436,223 @@ describe('genversion cli', () => { } // File exists but has incorrect syntax - response.error.code.should.equal(2) + response.exitCode.should.equal(2) return done() }) }) }) - // TODO cannot test verbosity with check-only due to annoying shortcoming - // TODO in command-line-test, where stdout and stderr become nulls - // TODO if exit code other than 0 - // it('should have verbose output', done => { - // const clit = new CliTest() - // - // const FLAGS = ' --verbose --check-only ' - // clit.exec(GENERATE_COMMAND + FLAGS + P, (err, response) => { - // if (err) { - // return done(err) - // } - // - // // File exists but has incorrect syntax - // response.stdout.should.include('could not be found') - // return done() - // }) - // }) + it('should have verbose output', (done) => { + const clit = new CliTest() + + const FLAGS = ' --verbose --check-only ' + clit.exec(GENERATE_COMMAND + FLAGS + P, (err, response) => { + if (err) { + return done(err) + } + + // File exists but has incorrect syntax + should(response.stderr).containEql('could not be found') + return done() + }) + }) + }) + + describe('flag --property', () => { + it('should pick selected property', (done) => { + const clit = new CliTest() + + clit.exec(GENERATE_COMMAND + ' --property name ' + P, (err, response) => { + if (err) { + return done(err) + } + + readTemp().should.equal(SIGNATURE + + 'module.exports = \'' + pjson.name + '\'\n') + + return done() + }) + }) + + it('should pick multiple properties', (done) => { + const clit = new CliTest() + const cmd = GENERATE_COMMAND + ' --property name,version ' + P + clit.exec(cmd, (err, response) => { + if (err) { + return done(err) + } + + readTemp().should.equal(SIGNATURE + + 'exports.name = \'' + pjson.name + '\'\n' + + 'exports.version = \'' + pjson.version + '\'\n') + + return done() + }) + }) + + it('should render non-string properties', (done) => { + const clit = new CliTest() + const cmd = GENERATE_COMMAND + ' ' + + '--source ./test/fixture ' + + '--property keywords,engines ' + P + clit.exec(cmd, (err, response) => { + if (err) { + return done(err) + } + + readTemp().should.equal(SIGNATURE + + 'exports.keywords = [\'foo\', \'bar\']\n' + + 'exports.engines = { ' + + 'node: \'>=10.0.0\', ' + + '\'foo-bar\': \'>=1337.0.0\'' + + ' }\n' + ) + + return done() + }) + }) + + it('should not understand multiple property flags', (done) => { + const clit = new CliTest() + const cmd = GENERATE_COMMAND + ' --property name ' + + '--property version --esm ' + P + clit.exec(cmd, (err, response) => { + if (err) { + return done(err) + } + + readTemp().should.equal(SIGNATURE + + 'export const version = \'' + pjson.version + '\'\n') + + return done() + }) + }) + + it('should detect null properties', (done) => { + const clit = new CliTest() + const cmd = GENERATE_COMMAND + ' --property --esm ' + P + clit.exec(cmd, (err, response) => { + if (err) { + return done(err) + } + + response.exitCode.should.equal(1) + response.stderr.should.containEql('property') + existsTemp().should.equal(false) + + return done() + }) + }) + }) + + describe('flag --template', () => { + it('should use custom template', (done) => { + const clit = new CliTest() + const cmd = GENERATE_COMMAND + + ' --template ./test/fixture/template.ejs ' + P + + clit.exec(cmd, (err, response) => { + if (err) { + return done(err) + } + + readTemp().should.equal( + 'export default \'' + pjson.version + '\'\n') + + return done() + }) + }) + + it('should prevent rewrite of unexpected', (done) => { + const clit = new CliTest() + const cmd = GENERATE_COMMAND + + ' --template ./test/fixture/template.ejs ' + P + + const content = 'something important\n' + createTemp(content) + + clit.exec(cmd, (err, response) => { + if (err) { + return done(err) + } + + should(response.exitCode).equal(1) + should(response.stderr).containEql('file') + // Check content is untouched + readTemp().should.equal(content) + + return done() + }) + }) + + it('should allow rewrite of familiar', (done) => { + const clit = new CliTest() + const cmd = GENERATE_COMMAND + + ' -t ./test/fixture/template.ejs ' + P + + const content = 'export default \'0.1.2-alpha.0\'\n' + createTemp(content) + + clit.exec(cmd, (err, response) => { + if (err) { + return done(err) + } + + const finalContent = 'export default \'' + pjson.version + '\'\n' + readTemp().should.equal(finalContent) + + return done() + }) + }) + + it('should detect non-existent template file', (done) => { + const clit = new CliTest() + const cmd = GENERATE_COMMAND + + ' --template ./test/fixture/foo.ejs ' + P + + clit.exec(cmd, (err, response) => { + if (err) { + return done(err) + } + + response.exitCode.should.equal(1) + should(response.stderr).startWith('Error: Missing') + + return done() + }) + }) + + it('should detect missing template path', (done) => { + const clit = new CliTest() + const cmd = GENERATE_COMMAND + + ' --template ' + P + + clit.exec(cmd, (err, response) => { + if (err) { + return done(err) + } + + response.exitCode.should.equal(1) + + return done() + }) + }) + + it('should detect corrupted template', (done) => { + const clit = new CliTest() + const cmd = GENERATE_COMMAND + + ' --template ./test/fixture/invalid.ejs ' + P + + clit.exec(cmd, (err, response) => { + if (err) { + return done(err) + } + + response.exitCode.should.equal(1) + should(response.stderr).containEql('Bad template') + + return done() + }) + }) }) }) diff --git a/test/clitest/index.js b/test/clitest/index.js new file mode 100644 index 0000000..2783239 --- /dev/null +++ b/test/clitest/index.js @@ -0,0 +1,76 @@ +// Command-line test module +// +// Much inspired by https://github.com/xudafeng/command-line-test (MIT, 2017) +// + +const childProcess = require('child_process') + +function CliTest (options) { + this.options = options || {} + this.reset() +} + +CliTest.prototype.get = function () { + return { + exitCode: this.exitCode, + error: this.error, + stdout: this.stdout, + stderr: this.stderr + } +} + +CliTest.prototype.reset = function () { + this.exitCode = null + this.error = null + this.stdout = null + this.stderr = null +} + +CliTest.prototype.exec = function () { + const args = Array.prototype.slice.call(arguments) + const command = args.shift() + let options = {} + let callback = null + + if (args.length === 2) { + options = args.shift() + callback = args.shift() + } else if (args.length === 1) { + if (typeof args[0] === 'function') { + callback = args[0] + } else { + options = args[0] + } + } + + const promise = new Promise(resolve => { + childProcess.exec(command, Object.assign({ + maxBuffer: 1024 * 512 * 10, + wrapArgs: false + }, options), (error, stdout, stderr) => { + if (error) { + this.exitCode = error.code + this.error = error + this.stderr = stderr.trim() + this.stdout = stdout.trim() + return resolve(this.get()) + } + this.exitCode = 0 + this.stderr = stderr.trim() + this.stdout = stdout.trim() + resolve(this.get()) + }) + }) + + if (callback) { + promise.then(data => { + callback.call(this, null, data) + }).catch(err => { + callback.call(this, `exec ${command} error with: ${err}`) + }) + } else { + return promise + } +} + +module.exports = CliTest diff --git a/test/fixture/invalid.ejs b/test/fixture/invalid.ejs new file mode 100644 index 0000000..1d617b3 --- /dev/null +++ b/test/fixture/invalid.ejs @@ -0,0 +1 @@ +export default '<%= pkg.version' diff --git a/test/fixture/package.json b/test/fixture/package.json index fba4bd8..b857efd 100644 --- a/test/fixture/package.json +++ b/test/fixture/package.json @@ -2,10 +2,14 @@ "name": "genversion-fixture", "version": "0.1.2", "description": "A dummy package.json for tests", - "keywords": [], + "keywords": ["foo", "bar"], "main": "index.js", "license": "MIT", "dependencies": {}, + "engines": { + "node": ">=10.0.0", + "foo-bar": ">=1337.0.0" + }, "devDependencies": {}, "scripts": {} } diff --git a/test/fixture/template.ejs b/test/fixture/template.ejs new file mode 100644 index 0000000..d35d002 --- /dev/null +++ b/test/fixture/template.ejs @@ -0,0 +1 @@ +export default '<%= pkg.version %>'