Skip to content

Commit

Permalink
feat: Implement --exec option
Browse files Browse the repository at this point in the history
- Avoid output when `--output=none`
- Ensure large negative grace values doesn't cause overflow
  • Loading branch information
mogelbrod committed May 26, 2020
1 parent 1c90940 commit cdb2f96
Show file tree
Hide file tree
Showing 5 changed files with 108 additions and 23 deletions.
21 changes: 16 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,22 +32,33 @@ Package identifiers may optionally include:
### Options

```
-e, --exec <command> execute shell command on release (interpolates %p, %s, %t, %v)
-o, --output <format> output format (default/verbose/none/json)
-g, --grace <seconds> accept versions released up to X before invocation (default: 10)
-g, --grace <seconds> accept versions released up to X seconds before invocation (default: 10)
-t, --timeout <seconds> exit if no release matches after X seconds (default: 0)
-d, --delay <seconds> 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 (`[email protected]`, `@scope/[email protected]`, 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'"
```
80 changes: 72 additions & 8 deletions cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'))

Expand All @@ -28,8 +31,11 @@ program
.action((pkg, additionalPackages) => {
packages.push(pkg, ...additionalPackages)
})
.option('-e, --exec <command>',
`execute shell command on release (interpolates %p, %s, %t, %v)`)
.option('-o, --output <format>',
`output format (${OUTPUT_STYLES.join('/')})`)
`output format (${OUTPUT_STYLES.join('/')})`,
OUTPUT_STYLES[0])
.option('-g, --grace <seconds>',
'accept versions released up to X seconds before invocation',
decimal, DEFAULTS.grace)
Expand All @@ -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 => {
Expand Down
22 changes: 13 additions & 9 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}'`,
Expand Down Expand Up @@ -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)
}
Expand All @@ -141,7 +144,8 @@ class ReleaseMatchError extends Error {

module.exports = {
awaitRelease,
ReleaseMatchError,
DEFAULTS,
ANY_VERSION,
ReleaseMatchError,
PACKAGE_SPEC_REGEX,
}
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}

0 comments on commit cdb2f96

Please sign in to comment.