diff --git a/README.md b/README.md index 338d85f..51d3af0 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ subsequent commands to run. ## Features - [x] Supports private NPM registries (if `npm` can access it, so should this package) +- [x] `--exec 'npm update %s'` to inject and run commands on release - [ ] `--install` flag to automatically run `npm install $package` on release - [ ] `--update` flag to automatically run `npm update $package` on release -- [ ] `--exec 'echo %p@%v'` to inject and run commands on release - [ ] `--daemon` to keep listening for new releases (works with `--exec`) ## Installation @@ -32,22 +32,33 @@ Package identifiers may optionally include: ### Options ``` +-e, --exec execute shell command on release (interpolates %p, %s, %t, %v) -o, --output output format (default/verbose/none/json) --g, --grace accept versions released up to X before invocation (default: 10) +-g, --grace accept versions released up to X seconds before invocation (default: 10) -t, --timeout exit if no release matches after X seconds (default: 0) -d, --delay time between polling requests (default: 2) ``` -### In combination with other tools +### Using `--exec` + +Other processes can be invoked when a release is discovered using the `--exec` +option. The provided string will be executed within a basic shell environment +after interpolation of the following placeholders: + +- `%p`: package name (`my-package`, `@scope/package`, etc.) +- `%s`: package name and version (`my-package@1.0.1`, `@scope/package@2.3.0`, etc.) +- `%t`: time of release in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) + format (`2020-05-26T22:01:02Z`) +- `%v`: released version (`1.0.1`) #### Install new version of a dependency when it becomes available ```sh -await-release my-dependency && npm update my-dependency +await-release my-dependency --exec 'npm update %s' ``` #### Notify when a new package version is released ```sh -await-release package-name && npx -p node-notifier-cli notify -t 'package-name released' +await-release package-name --exec "npx -p node-notifier-cli notify -t '%p released %v'" ``` diff --git a/cli.js b/cli.js index 0ce9e53..1b52eb2 100755 --- a/cli.js +++ b/cli.js @@ -3,10 +3,13 @@ const path = require('path') const { program } = require('commander') +const { spawn } = require('child_process') +const { parseArgsStringToArgv } = require('string-argv') const { awaitRelease, - DEFAULTS, ReleaseMatchError, + DEFAULTS, + PACKAGE_SPEC_REGEX, } = require('./index') const packageJson = require(path.join(__dirname, 'package.json')) @@ -28,8 +31,11 @@ program .action((pkg, additionalPackages) => { packages.push(pkg, ...additionalPackages) }) + .option('-e, --exec ', + `execute shell command on release (interpolates %p, %s, %t, %v)`) .option('-o, --output ', - `output format (${OUTPUT_STYLES.join('/')})`) + `output format (${OUTPUT_STYLES.join('/')})`, + OUTPUT_STYLES[0]) .option('-g, --grace ', 'accept versions released up to X seconds before invocation', decimal, DEFAULTS.grace) @@ -46,25 +52,83 @@ if (process.argv.length < 3) { program.parse(process.argv) +// Validate package input if (!packages.length) { return program.help() } +packages.forEach(pkg => { + if (pkg.indexOf('%') >= 0) { + console.error( + `error: Package identifiers may not contain %: '${pkg}'\n\n` + + `If it was intended be part of the --exec command,\n` + + `ensure that you quote the command correctly.` + ) + process.exit(1) + } else if (!PACKAGE_SPEC_REGEX.test(pkg)) { + console.error( + `error: Invalid package identifier: '${pkg}'` + ) + process.exit(1) + } +}) const args = program.opts() if (args.output === 'verbose') { args.logger = (message) => console.log(message) } -const promises = packages.map(p => awaitRelease(p, args)) -Promise.all(promises).then(packages => { +// Start polling for each package +const awaitReleasePromises = packages.map(pkg => { + return awaitRelease(pkg, args).then(release => { + switch (args.output) { + case 'default': + case 'verbose': + console.log( + `${release.spec} (released ${release.time.toLocaleString()})` + ) + break + } + + if (!args.exec) { + return release + } + + const replacements = { + p: release.name, + s: release.spec, + t: release.time.toJSON(), + v: release.version, + } + const cmdString = args.exec.replace(/%[%pstv]/g, (m) => (replacements[m[1]] || '%')) + const cmdArgs = parseArgsStringToArgv(cmdString) + const cmd = cmdArgs.shift() + + if (args.output === 'verbose') { + console.log('exec:', cmd, ...cmdArgs) + } + return new Promise((resolve, reject) => { + const proc = spawn(cmd, cmdArgs, { shell: true }) + proc.stdout.pipe(process.stdout) + proc.stderr.pipe(process.stderr) + proc.on('error', (error) => { + reject(new Error(`Error while executing ${release.spec}: ${error.message}`)) + }) + proc.on('exit', (code, signal) => { + if (code > 0) { + reject(new Error(`Exit code ${code} (signal ${signal}) while executing ${release.spec}`)) + } else { + resolve(release) + } + }) + }) + }) +}) + +Promise.all(awaitReleasePromises).then(packages => { switch (args.output) { case 'json': console.log(JSON.stringify(packages, null, 2)) break - default: - console.log(packages.map(p => { - return `- ${p.name}@${p.version} (released ${p.time.toLocaleString()})` - }).join('\n')) } process.exit(0) }).catch(error => { diff --git a/index.js b/index.js index 86c9ea3..9ddb90e 100644 --- a/index.js +++ b/index.js @@ -5,6 +5,8 @@ const semver = require('semver') const { read: getNpmConfig } = require('libnpmconfig') const SECOND = 1e3 +// Capturing groups: @scope/package-name, @scope, package-name, @semverString +const PACKAGE_SPEC_REGEX = /^((?:(@[a-z0-9-~][a-z0-9-._~]*)\/)?([a-z0-9-~][a-z0-9-._~]*))(?:@([^@]+))?$/ const ANY_VERSION = '>=0' const DEFAULTS = { version: ANY_VERSION, @@ -26,9 +28,9 @@ function awaitRelease(packageString, { if (!Number.isFinite(timeout)) { timeout = DEFAULTS.timeout } if (!Number.isFinite(delay)) { delay = DEFAULTS.delay } - const releasedAfter = new Date(Date.now() - grace * SECOND) + const releasedAfter = new Date(Math.max(0, Date.now() - grace * SECOND)) - const packageParts = packageString.match(/^((@[^/@]+)?([^@]+))(?:@([^@]+))?$/) + const packageParts = packageString.match(PACKAGE_SPEC_REGEX) if (!packageParts) { return Promise.reject(new ReleaseMatchError( `Invalid package string: '${packageString}'`, @@ -113,17 +115,18 @@ function lookupLatestMatchingRelease(packageName, { if (matching) { // Return a shallow cloned object excluding underscore prefixed keys - const versionObject = Object.assign({ + const release = Object.assign({ name: packageName, version: matching[0], }, data.versions[matching[0]]) - for (let key in versionObject) { - if (Object.prototype.hasOwnProperty.call(versionObject, key) && key[0] === '_') { - delete versionObject[key] + for (let key in release) { + if (Object.prototype.hasOwnProperty.call(release, key) && key[0] === '_') { + delete release[key] } } - versionObject.time = new Date(matching[1]) - return versionObject + release.time = new Date(matching[1]) + release.spec = [release.name, release.version].join('@') + return release } else { throw new ReleaseMatchError('No matching releases found', metadata) } @@ -141,7 +144,8 @@ class ReleaseMatchError extends Error { module.exports = { awaitRelease, + ReleaseMatchError, DEFAULTS, ANY_VERSION, - ReleaseMatchError, + PACKAGE_SPEC_REGEX, } diff --git a/package-lock.json b/package-lock.json index 28ab5e2..06f9c16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -576,6 +576,11 @@ "minipass": "^3.1.1" } }, + "string-argv": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz", + "integrity": "sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==" + }, "tar": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/tar/-/tar-6.0.2.tgz", diff --git a/package.json b/package.json index c038539..05a090b 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "commander": "^5.1.0", "libnpmconfig": "^1.2.1", "npm-registry-fetch": "^8.1.0", - "semver": "^7.3.2" + "semver": "^7.3.2", + "string-argv": "^0.3.1" } }