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