Repository: bahmutov/now-pipeline Branch: master Commit: f0adf3e34e5a Files: 16 Total size: 26.9 KB Directory structure: gitextract_mt_ry8fj/ ├── .gitignore ├── .npmrc ├── .travis.yml ├── README.md ├── bin/ │ ├── list.js │ ├── now-pipeline.js │ └── prune.js ├── next-update-travis.sh ├── package.json ├── src/ │ ├── deploys-with-aliases.js │ ├── index.js │ ├── now-pipeline-spec.js │ └── run-command.js └── test/ ├── index.js ├── package.json └── subfolder/ └── foo.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ node_modules/ npm-debug.log .DS_Store ================================================ FILE: .npmrc ================================================ registry=http://registry.npmjs.org/ save-exact=true progress=false ================================================ FILE: .travis.yml ================================================ sudo: false language: node_js cache: directories: - node_modules notifications: email: true node_js: - '6' before_script: - npm prune script: - ./next-update-travis.sh - npm test # disable deploys for now - need new domain # because current domain used for testing bahmutov.com # is now a redirect to glebbahmutov.com :( # - cd test # - npm run deploy # - npm run list # - npm run prune # - echo There should be no deploys now # - npm run list # # back to main folder # - cd .. after_success: - npm run semantic-release branches: except: - /^v\d+\.\d+\.\d+$/ ================================================ FILE: README.md ================================================ # now-pipeline > Single CI command to deploy new code to Zeit Now > Includes e2e tests and the alias switch [![NPM][npm-icon] ][npm-url] [![Build status][ci-image] ][ci-url] [![semantic-release][semantic-image] ][semantic-url] [![js-standard-style][standard-image]][standard-url] [![first-timers-only](http://img.shields.io/badge/first--timers--only-friendly-blue.svg)](https://github.com/bahmutov/now-pipeline/labels/first-timers-only) [![next-update-travis badge][nut-badge]][nut-readme] ## First time contributors This repo is [first time OSS contributor friendly](http://www.firsttimersonly.com/). See [these issues](https://github.com/bahmutov/now-pipeline/labels/first-timers-only) to contribute in meaningful way. ## What and why I am [super excited](https://glebbahmutov.com/blog/think-inside-the-box/) about [Zeit Now](https://zeit.co/now) tool; this is the "missing CI tool" for it. A single command `now-pipeline` - deploys new version - tests it - switches alias to the new deployment - takes down the old deployment Should be enough to automatically update the server or service running in the cloud without breaking anything. ## Install and use ```sh npm i -g now-pipeline ``` Set `NOW_TOKEN` CI environment variable with a token that you can get from [Zeit account page](https://zeit.co/account#api-tokens) Add CI command to `now-pipeline`. By default it will execute `npm test` and will pass the deployed url as `NOW_URL` environment variable. You can customize everything. ## Example Simple Travis commands ```yml script: # after unit tests - npm i -g now-pipeline - now-pipeline ``` Prune existing deploys (if they do not have an alias) and show the deploy. ```yml script: - npm i -g now-pipeline - now-pipeline-prune - now-pipeline - now-pipeline-list ``` Set [domain alias](https://zeit.co/world) if there is no existing one ```yml script: - npm i -g now-pipeline - now-pipeline --alias foo.domain.com ``` Pass in path to be used as deploy directory ```yml script: - npm i -g now-pipeline - now-pipeline --dir your/directory ``` Pass test command and name of the environment variable for deployed url ```yml script: - npm i -g now-pipeline - now-pipeline --as HOST --test "npm run e2e" ``` ## Example projects * [todomvc-express](https://github.com/bahmutov/todomvc-express/blob/master/.travis.yml) * [express-sessions-tutorial](https://github.com/bahmutov/express-sessions-tutorial/blob/master/.travis.yml) * [test-semantic-deploy-with-now](https://github.com/bahmutov/test-semantic-deploy-with-now) ## Additional bin commands * `now-pipeline-list` - see the current deploys for the current project * `now-pipeline-prune` - remove all non-aliased deploys for the current project You can pass custom test command to the pipeline to be used after deploying fresh install using `--test "command"` argument. The command will get `NOW_URL` environment variable with new install. For example ```sh npm i -g now-pipeline now-pipeline --test "npm run prod-test" ``` where the `package.json` has ```json { "scripts": { "prod-test": "e2e-test $NOW_URL" } } ``` ## Debugging You can see verbose log messages by running this tool with environment variable `DEBUG=now-pipeline` ## Details * `now-pipeline` uses [Zeit API](https://zeit.co/api) via [now-client](https://github.com/zeit/now-client). * You can see the list of recent actions at [Zeit dashboard](https://zeit.co/dashboard). * It discovers files to send using [pkgd](https://github.com/inikulin/pkgd), you can see the files by using the following command (read [Smaller published NPM modules](https://glebbahmutov.com/blog/smaller-published-NPM-modules/) for more details) ```sh t="$(npm pack .)"; wc -c "${t}"; tar tvf "${t}"; rm "${t}" ``` * file `.npmignore` is considered an optional file ## Related * [next-update](https://github.com/bahmutov/next-update) is a similar "if tests pass, upgrade" tool for your NPM dependencies. ### Small print Author: Gleb Bahmutov <gleb.bahmutov@gmail.com> © 2016 * [@bahmutov](https://twitter.com/bahmutov) * [glebbahmutov.com](http://glebbahmutov.com) * [blog](http://glebbahmutov.com/blog) License: MIT - do anything with the code, but don't blame me if it does not work. Support: if you find any problems with this module, email / tweet / [open issue](https://github.com/bahmutov/now-pipeline/issues) on Github ## MIT License Copyright (c) 2016 Gleb Bahmutov <gleb.bahmutov@gmail.com> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. [npm-icon]: https://nodei.co/npm/now-pipeline.svg?downloads=true [npm-url]: https://npmjs.org/package/now-pipeline [ci-image]: https://travis-ci.org/bahmutov/now-pipeline.svg?branch=master [ci-url]: https://travis-ci.org/bahmutov/now-pipeline [semantic-image]: https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg [semantic-url]: https://github.com/semantic-release/semantic-release [standard-image]: https://img.shields.io/badge/code%20style-standard-brightgreen.svg [standard-url]: http://standardjs.com/ [nut-badge]: https://img.shields.io/badge/next--update--travis-ok-green.svg [nut-readme]: https://github.com/bahmutov/next-update-travis#readme ================================================ FILE: bin/list.js ================================================ #!/usr/bin/env node 'use strict' require('console.table') const nowPipeline = require('..') const pkg = nowPipeline.getPackage() nowPipeline.deployments(pkg.name) .then(deploys => { if (deploys.length) { deploys.forEach(d => { delete d.created }) console.table(deploys) } else { console.log(`Zero deploys for ${pkg.name}`) } }) .catch(err => { console.error(err) process.exit(-1) }) ================================================ FILE: bin/now-pipeline.js ================================================ #!/usr/bin/env node 'use strict' const debug = require('debug')('now-pipeline') const initCrashReporter = require('node-sentry-error-reporter') initCrashReporter() const R = require('ramda') const runCommand = require('../src/run-command') const is = require('check-more-types') const la = require('lazy-ass') const pkgd = require('pkgd') const argv = require('minimist')(process.argv.slice(2)) const path = require('path') const envUrlName = 'NOW_URL' const passAsName = argv.as || envUrlName const testCommand = argv.test || 'npm test' const nowPipeline = require('..') const pkg = nowPipeline.getPackage() function findFiles () { if ('dir' in argv) { // Pass directory with --dir flag let dirArg = argv.dir la(is.string(dirArg), 'directory path should be a string', dirArg) try { // change cwd to the passed directory path process.chdir(path.resolve(argv.dir)) } catch (err) { console.error('error changing deploy directory') console.error(err) console.error(`attempted directory: ${dirArg}`) la(new Error(err)) } } debug('deploying from directory', process.cwd()) return pkgd(process.cwd()) .then(R.prop('files')) } var start function findDeploy (url) { la(is.url(url), 'expected url', url) return nowPipeline.deployments() .then(deploys => { debug('looking for url %s in %d deploys', url, deploys.length) const found = deploys.find(d => { return url.endsWith(d.url) }) if (!found) { console.error('Could not find deploy for url', url) process.exit(-1) } debug('found deployment', found) return found }) } if (process.env[envUrlName]) { const url = process.env[envUrlName] console.log(`found existing env variable ${envUrlName} ${url}`) // start = Promise.resolve(url) // todo Find the deployment with given url start = findFiles().then((filenames) => findDeploy(url)) } else { start = findFiles().then(nowPipeline.deploy) } function setFullHost (deploy) { if (!deploy.url) { deploy.url = deploy.host } deploy.url = addHttps(deploy.url) console.log('deployed to url', deploy.url) la(is.url(deploy.url), 'expected deploy.url to be full https', deploy.url) return deploy } function deployIsWorking (deploy) { console.log(`deployed url ${deploy.url} is working`) } function addHttps (url) { return url.startsWith('https://') ? url : 'https://' + url } const updateAliasIfNecessary = R.curry( function updateAliasIfNecessary (aliasName, deploy) { la(is.maybe.string(aliasName), 'alias name should be a string', aliasName) return nowPipeline.deployments(pkg.name) .then(deploys => { return R.filter(R.prop('alias'))(deploys) }) .then(deployed => { console.log('found %d deploy(s) with aliases', deployed.length) debug(deployed) if (!deployed.length) { console.log('there is no existing alias') if (!aliasName) { console.log('will skip updating alias to', deploy.url) return } console.log('setting new alias to %s', aliasName) return nowPipeline.now.createAlias(deploy.uid, aliasName) .catch(err => { console.error('Could not create alias') console.error(err.message) console.error('Note: you need to create a FIRST alias to domain MANUALLY') console.error('using a command like this:') console.error(` now alias ${deployed.url} ${aliasName}`) return Promise.reject(err) }) } if (deployed.length > 1) { console.log('found %d deployed aliases', deployed.length) console.log('not sure which one to update') return Promise.reject(new Error('Multiple aliases')) } la(deployed.length === 1, 'expect single alias') const alias = deployed[0] if (alias.uid === deploy.uid) { console.log('The current alias %s points at the same deploy %s', alias.alias, deploy.url) console.log('Nothing to do') return } console.log('switching alias %s to point at new deploy %s', alias.alias, deploy.url) la(is.unemptyString(alias.alias), 'invalid alias', alias) return nowPipeline.now.createAlias(deploy.uid, alias.alias) .then(result => { debug('createAlias result', result) console.log('switched alias %s to point at %s', alias.alias, deploy.url) console.log('taking down previously aliased deploy', alias.uid) return nowPipeline.remove(alias.uid) }) .catch(error => { console.error('error switching alias', deploy.uid, alias.alias) console.error(error) throw error }) }) }) function testDeploy (deploy) { la(is.object(deploy), 'wrong deploy object', deploy) console.log('testing url %s', deploy.url) console.log('passing it as env variable %s', passAsName) console.log('test command "%s"', testCommand) la(is.url(deploy.url), 'missing deploy url in', deploy) const env = {} env[passAsName] = deploy.url return runCommand(testCommand, env) .then(R.always(deploy)) } start .then(setFullHost) .then(testDeploy) .then(R.tap(deployIsWorking)) .then(updateAliasIfNecessary(argv.alias)) .catch(err => { console.error('Something went wrong') console.error('Sometimes restarting pipeline can help') console.error(err) process.exit(-1) }) ================================================ FILE: bin/prune.js ================================================ #!/usr/bin/env node 'use strict' require('console.table') const nowPipeline = require('..') const pkg = nowPipeline.getPackage() function nonAliasedDeploys (deploys, aliases) { const aliasedDeploys = aliases.map(alias => alias.deploymentId) return deploys.filter(deploy => { return !aliasedDeploys.includes(deploy.uid) }) } Promise.all([ nowPipeline.deployments(pkg.name), nowPipeline.aliases(pkg.name) ]).then(([deploys, aliases]) => { if (deploys.length) { console.table('Deploys', deploys) } else { console.log('No deploys') } if (aliases.length) { console.table('Aliases', aliases) } else { console.log('No aliases') } const needToPrune = nonAliasedDeploys(deploys, aliases) if (needToPrune.length) { console.table('Will prune deploys', needToPrune) } else { console.log('No deploys to prune') } return needToPrune.reduce((prev, deploy) => { return prev.then(() => { console.log(`removing deploy ${deploy.uid} ${deploy.url}`) return nowPipeline.remove(deploy.uid) }) }, Promise.resolve()) }).then(() => { console.log('Done pruning deploys') }).catch(err => { console.error(err) process.exit(-1) }) ================================================ FILE: next-update-travis.sh ================================================ #!/bin/bash set -e if [ "$TRAVIS_EVENT_TYPE" = "cron" ]; then if [ "$GH_TOKEN" = "" ]; then echo "" echo "⛔️ Cannot find environment variable GH_TOKEN ⛔️" echo "Please set it up for this script to be able" echo "to push results to GitHub" echo "ℹ️ The best way is to use semantic-release to set it up" echo "" echo " https://github.com/semantic-release/semantic-release" echo "" echo "npm i -g semantic-release-cli" echo "semantic-release-cli setup" echo "" exit 1 fi echo "Upgrading dependencies using next-update" npm i -g next-update # you can edit options to allow only some updates # --allow major | minor | patch # --latest true | false # see all options by installing next-update # and running next-update -h next-update --allow minor --latest false git status # if package.json is modified we have # new upgrades if git diff --name-only | grep package.json > /dev/null; then echo "There are new versions of dependencies 💪" git add package.json echo "----------- package.json diff -------------" git diff --staged echo "-------------------------------------------" git config --global user.email "next-update@ci.com" git config --global user.name "next-update" git commit -m "chore(deps): upgrade dependencies using next-update" # push back to GitHub using token git remote remove origin # TODO read origin from package.json # or use github api module github # like in https://github.com/semantic-release/semantic-release/blob/caribou/src/post.js git remote add origin https://next-update:$GH_TOKEN@github.com/bahmutov/now-pipeline.git git push origin HEAD:master else echo "No new versions found ✋" fi else echo "Not a cron job, normal test" fi ================================================ FILE: package.json ================================================ { "name": "now-pipeline", "version": "0.0.0-semantic-release", "description": "Single CI command to deploy new version to Zeit Now, including e2e tests and alias switch", "author": "Gleb Bahmutov ", "bugs": "https://github.com/bahmutov/now-pipeline/issues", "config": { "pre-git": { "commit-msg": "simple", "pre-commit": [ "npm prune", "npm run deps", "npm test", "npm run ban" ], "pre-push": [ "npm run secure", "npm run license", "npm run ban -- --all", "npm run size" ], "post-commit": [], "post-merge": [] } }, "engines": { "node": ">=6" }, "files": [ "bin", "src/*.js", "!src/*-spec.js" ], "bin": { "now-pipeline": "bin/now-pipeline.js", "now-pipeline-list": "bin/list.js", "now-pipeline-prune": "bin/prune.js" }, "homepage": "https://github.com/bahmutov/now-pipeline#readme", "keywords": [ "ci", "now", "test", "tool", "util", "zeit" ], "license": "MIT", "main": "src/", "noScopeName": "now-pipeline", "publishConfig": { "registry": "http://registry.npmjs.org/" }, "repository": { "type": "git", "url": "https://github.com/bahmutov/now-pipeline.git" }, "scripts": { "ban": "ban", "deps": "deps-ok", "issues": "git-issues", "license": "license-checker --production --onlyunknown --csv", "lint": "standard --verbose --fix src/*.js bin/*.js", "pretest": "npm run lint", "secure": "nsp check", "size": "t=\"$(npm pack .)\"; wc -c \"${t}\"; tar tvf \"${t}\"; rm \"${t}\";", "test": "npm run unit", "unit": "mocha src/*-spec.js", "semantic-release": "semantic-release pre && npm publish && semantic-release post" }, "devDependencies": { "ban-sensitive-files": "1.9.2", "deps-ok": "1.4.1", "git-issues": "1.3.1", "github-post-release": "1.13.1", "license-checker": "15.0.0", "mocha": "4.1.0", "next-update-travis": "1.7.1", "nsp": "3.2.1", "pre-git": "3.17.1", "semantic-release": "6.3.6", "simple-commit-message": "3.3.2", "standard": "10.0.3" }, "dependencies": { "check-more-types": "2.24.0", "console.table": "0.10.0", "cross-spawn": "5.1.0", "debug": "3.2.6", "lazy-ass": "1.6.0", "minimist": "1.2.0", "moment": "2.24.0", "node-sentry-error-reporter": "1.8.0", "now-client": "0.7.0", "pkgd": "1.1.2", "ramda": "0.26.1" }, "release": { "analyzeCommits": "simple-commit-message", "generateNotes": "github-post-release" } } ================================================ FILE: src/deploys-with-aliases.js ================================================ 'use strict' const is = require('check-more-types') const la = require('lazy-ass') const debug = require('debug')('now-pipeline') function combineDeploysAndAliases ({deploys, aliases}) { la(is.array(deploys), 'list of deploys missing', deploys) la(is.array(aliases), 'list of aliases missing', aliases) debug('matching %d deploys with %d aliases', deploys.length, aliases.length) return deploys.map(deploy => { const alias = aliases.find(a => a.deploymentId === deploy.uid) if (alias) { deploy.alias = alias.alias deploy.aliasId = alias.uid debug('deploy %s matched alias %s', deploy.url, deploy.alias) } return deploy }) } module.exports = combineDeploysAndAliases ================================================ FILE: src/index.js ================================================ 'use strict' require('console.table') const debug = require('debug')('now-pipeline') const R = require('ramda') const path = require('path') const fs = require('fs') const is = require('check-more-types') const la = require('lazy-ass') const Now = require('now-client') const combineDeploysAndAliases = require('./deploys-with-aliases') const moment = require('moment') function getPackage () { const packageFilename = path.join(process.cwd(), 'package.json') const pkg = require(packageFilename) return pkg } function addRelativeTimes (deploys) { return deploys.map(deploy => { // relative time without "ago" suffix deploy.when = moment(Number(deploy.created)).fromNow(true) return deploy }) } function sortByAge (deploys) { return R.sortBy( R.compose(Number, R.prop('created')) )(deploys) } function nowApi () { const authToken = process.env.NOW_TOKEN if (!authToken) { console.log('WARNING: Cannot find NOW_TOKEN environment variable') } const now = Now(authToken) function wait (seconds) { return new Promise((resolve, reject) => { setTimeout(resolve, seconds * 1000) }) } function checkDeploy (id) { la(is.unemptyString(id), 'expected deploy id', id) return now.getDeployment(id) } function getDeploysAndAliases () { return Promise.all([ now.getDeployments(), now.getAliases() ]).then(([deploys, aliases]) => combineDeploysAndAliases({deploys, aliases})) } function isDeploying (state) { return state === 'DEPLOYING' || state === 'BOOTED' || state === 'BUILDING' } function waitUntilDeploymentReady (id, secondsRemaining) { la(is.number(secondsRemaining), 'wrong waiting limit', secondsRemaining) const sleepSeconds = 5 return checkDeploy(id) .then(r => { console.log(r.state, r.host, 'limit', secondsRemaining, 'seconds') if (r.state === 'READY') { return r } if (isDeploying(r.state)) { if (secondsRemaining < sleepSeconds) { throw new Error('Deploy timed out\n' + JSON.stringify(r)) } return wait(sleepSeconds) .then(() => waitUntilDeploymentReady(id, secondsRemaining - sleepSeconds)) } throw new Error('Something went wrong with the deploy\n' + JSON.stringify(r)) }) } const api = { now, // expose the actual now client getPackage, // lists current deploy optionally limited with given predicate deployments (filter) { if (is.string(filter)) { filter = R.propEq('name', filter) } filter = filter || R.T return getDeploysAndAliases() .then(sortByAge) .then(addRelativeTimes) .then(R.filter(filter)) }, aliases () { return now.getAliases() }, remove (id) { la(is.unemptyString(id), 'expected deployment id', id) debug('deleting deployment %s', id) return now.deleteDeployment(id) }, /** deploys given filenames. Returns object with result { uid: 'unique id', host: 'now-pipeline-test-lqsibottrb.now.sh', state: 'READY' } */ deploy (filenames) { debug('deploying %d files', filenames.length) debug(filenames) la(is.strings(filenames), 'missing file names', filenames) la(is.not.empty(filenames), 'expected list of files', filenames) // Files not required, but might be checked for by la const optionalFiles = ['.npmignore'] filenames.forEach(name => { if (!(optionalFiles.includes(name))) { la(fs.existsSync(name), 'cannot find file', name) } }) const isPackageJson = R.test(/package\.json$/) const packageJsonPresent = R.any(isPackageJson) la(packageJsonPresent(filenames), 'missing package.json file in the list', filenames) const packageJsonFilename = filenames.find(isPackageJson) const packageJsonFolder = path.dirname(packageJsonFilename) debug('package.json filename is', packageJsonFilename) debug('in folder', packageJsonFolder) // TODO make sure all files exist const sources = R.map(name => fs.readFileSync(name, 'utf8'))(filenames) const names = R.map(filename => { return path.relative(packageJsonFolder, filename) })(filenames) debug('sending files', names) const params = R.zipObj(names, sources) // parsed JSON object params.package = JSON.parse(params['package.json']) delete params['package.json'] // we do not need dev dependencies in the deployed server delete params.package.devDependencies return now.createDeployment(params) .then(r => { // TODO make an option const maxWaitSeconds = 60 * 10 return waitUntilDeploymentReady(r.uid, maxWaitSeconds) }) .catch(r => { if (is.error(r)) { console.error('error during deployment') console.error(r.message) return Promise.reject(r) } console.error('error') console.error(r.response.data) return Promise.reject(new Error(r.response.data.err.message)) }) } } return api } const now = nowApi() module.exports = now // // examples // // function showDeploysForProject () { // eslint-disable-line no-unused-vars // const name = 'now-pipeline-test' // now.deployments(R.propEq('name', name)) // .then(console.table).catch(console.error) // } // function showAllDeploys () { // eslint-disable-line no-unused-vars // now.deployments().then(console.table).catch(console.error) // } // function deployTest () { // eslint-disable-line no-unused-vars // const relative = require('path').join.bind(null, __dirname) // const files = [ // relative('../test/package.json'), // relative('../test/index.js') // ] // return now.deploy(files) // } // showAllDeploys() // showDeploysForProject() // deployTest() // .then(result => { // console.log('deployment done with result', result) // }) // .catch(console.error) ================================================ FILE: src/now-pipeline-spec.js ================================================ 'use strict' /* global describe, it */ describe('now-pipeline', () => { it('write this test!', () => { }) }) ================================================ FILE: src/run-command.js ================================================ 'use strict' const la = require('lazy-ass') const is = require('check-more-types') const spawn = require('cross-spawn') function runCommand (command, extraEnv) { if (is.string(command)) { command = command.split(' ') } la(is.array(command), 'expected command and args array', command) la(command.length > 0, 'missing command, needs at least something', command) la(is.object(extraEnv), 'expected env object', extraEnv) return new Promise(function (resolve, reject) { const customEnv = Object.assign({}, process.env, extraEnv) const spawnOptions = { env: customEnv, stdio: 'inherit' } const prog = command[0] const args = command.slice(1) console.log(`running "${prog}" with extra env keys`, Object.keys(extraEnv)) const proc = spawn(prog, args, spawnOptions) proc.on('error', (err) => { console.error('prog error') console.error(err) reject(err) }) proc.on('close', (code) => { // debug(`${prog} exit code ${code}`) if (code) { const msg = `${prog} exit code ${code}` console.error(msg) return reject(new Error(msg)) } resolve() }) }) } module.exports = runCommand ================================================ FILE: test/index.js ================================================ const port = process.env.PORT || 4000 const foo = require('./subfolder/foo') require('http').Server((req, res) => { const msg = "Hi there! " + foo + " node " + process.versions.node; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end(require("sign-bunny")(msg)); }).listen(port); console.log('listening on port', port) ================================================ FILE: test/package.json ================================================ { "name": "now-pipeline-test", "version": "0.0.0", "private": true, "dependencies": { "sign-bunny": "1.0.0" }, "scripts": { "start": "node index.js", "test": "curl $NOW_URL", "list": "node ../bin/list", "prune": "node ../bin/prune", "deploy": "DEBUG=now-pipeline node ../bin/now-pipeline --test 'npm run prod-test' --alias test.bahmutov.com", "prod-test": "echo Testing deployed url; curl $NOW_URL" } } ================================================ FILE: test/subfolder/foo.js ================================================ module.exports = 'foo'