├── .gitignore ├── index.js ├── config.js ├── package.json ├── actions ├── info.js ├── rebuild.js ├── config.js └── hook.js ├── cli.js ├── LICENSE ├── .eslintrc.js ├── utils.js ├── db.js ├── README.md └── github.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 2 | exports.info = require('./actions/info') 3 | 4 | exports.config = require('./actions/config') 5 | 6 | exports.hook = require('./actions/hook') 7 | 8 | exports.rebuild = require('./actions/rebuild') 9 | 10 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | exports.VERSION = require('./package.json').version 2 | exports.REGION = require('awscred').loadRegionSync() || 'us-east-1' 3 | exports.STACK = 'lambci' 4 | 5 | // The JS AWS SDK doesn't currently choose the region from config files: 6 | // https://github.com/aws/aws-sdk-js/issues/1039 7 | require('aws-sdk').config.update({region: exports.REGION}) 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lambci-cli", 3 | "version": "0.3.2", 4 | "description": "A command line tool for administering LambCI", 5 | "repository": "lambci/cli", 6 | "author": "Michael Hart ", 7 | "license": "MIT", 8 | "main": "index.js", 9 | "bin": { 10 | "lambci": "cli.js" 11 | }, 12 | "engines": { 13 | "node": ">=4.0.0" 14 | }, 15 | "scripts": { 16 | "test": "echo \"Error: no test specified\" && exit 1" 17 | }, 18 | "dependencies": { 19 | "aws-sdk": "^2.653.0", 20 | "awscred": "^1.5.0", 21 | "minimist": "^1.2.5" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /actions/info.js: -------------------------------------------------------------------------------- 1 | var AWS = require('aws-sdk') 2 | var config = require('../config') 3 | 4 | var lambda = new AWS.Lambda() 5 | 6 | module.exports = info 7 | 8 | function info() { 9 | console.log(`CLI version: ${config.VERSION}`) 10 | console.log(`Stack name: ${config.STACK}`) 11 | console.log(`Region: ${config.REGION}`) 12 | 13 | lambda.invoke({ 14 | FunctionName: `${config.STACK}-build`, 15 | Payload: JSON.stringify({ 16 | action: 'version', 17 | }), 18 | }, function(err, data) { 19 | if (err && err.code == 'ResourceNotFoundException') { 20 | err = new Error(`LambCI stack '${config.STACK}' does not exist or has no Lambda function named '${config.STACK}-build'`) 21 | } 22 | if (err) { 23 | console.error(err) 24 | return process.exit(1) 25 | } 26 | var response = JSON.parse(data.Payload) 27 | if (data.FunctionError && response && response.errorMessage) { 28 | console.error(response.errorMessage) 29 | return process.exit(1) 30 | } 31 | console.log(`LambCI version: ${response}`) 32 | }) 33 | } 34 | 35 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var config = require('./config') 4 | 5 | var opts = require('minimist')(process.argv.slice(2)) 6 | 7 | if (opts.version) return console.log(config.VERSION) 8 | if (opts.stack) config.STACK = opts.stack 9 | 10 | var lambci = require('./index.js') 11 | 12 | var cmd = opts._ && opts._[0] 13 | 14 | if (typeof lambci[cmd] != 'function') { 15 | var isErr = !(opts.help || opts.h) 16 | console[isErr ? 'error' : 'log']( 17 | ` 18 | Usage: lambci [--stack ] [options] 19 | 20 | A command line tool for administering LambCI 21 | 22 | Options: 23 | --stack LambCI stack name / prefix to use (default: lambci) 24 | --help Display help about a particular command 25 | 26 | Commands: 27 | info General info about the LambCI stack 28 | config Display/edit config options 29 | hook Add/remove/update/show hook for GitHub repo 30 | rebuild Run a particular project build again 31 | 32 | Report bugs at github.com/lambci/cli/issues 33 | ` 34 | ) 35 | return process.exit(isErr ? 1 : 0) 36 | } 37 | 38 | lambci[cmd](opts) 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016 Michael Hart and LambCI contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'eslint:recommended', 3 | env: { 4 | node: true, 5 | es6: true, 6 | mocha: true, 7 | }, 8 | rules: { 9 | 10 | // relaxed restrictions 11 | 'no-mixed-requires': 0, 12 | 'no-underscore-dangle': 0, 13 | 'no-shadow': 0, 14 | 'no-console': 0, 15 | 'no-use-before-define': [2, 'nofunc'], 16 | 'camelcase': [2, {'properties': 'never'}], 17 | 'curly': 0, 18 | 'eqeqeq': 0, 19 | 'new-parens': 0, 20 | 'quotes': [2, 'single', 'avoid-escape'], 21 | 'semi': [2, 'never'], 22 | 'strict': 0, 23 | 24 | // extra restrictions 25 | 'no-empty-character-class': 2, 26 | 'no-extra-parens': [2, 'functions'], 27 | 'no-floating-decimal': 2, 28 | 'no-lonely-if': 2, 29 | 'no-self-compare': 2, 30 | 'no-throw-literal': 2, 31 | 'no-unused-vars': 2, 32 | 33 | // style 34 | 'array-bracket-spacing': [2, 'never'], 35 | 'brace-style': [2, '1tbs', {allowSingleLine: true}], 36 | 'comma-dangle': [2, 'always-multiline'], 37 | 'comma-style': [2, 'last'], 38 | 'consistent-this': [2, 'self'], 39 | 'object-curly-spacing': [2, 'never'], 40 | 'operator-assignment': [2, 'always'], 41 | 'operator-linebreak': [2, 'after'], 42 | 'keyword-spacing': 2, 43 | 'space-before-blocks': [2, 'always'], 44 | 'space-before-function-paren': [2, 'never'], 45 | 'space-in-parens': [2, 'never'], 46 | 'spaced-comment': [2, 'always'], 47 | }, 48 | } 49 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | var https = require('https') 2 | var url = require('url') 3 | var querystring = require('querystring') 4 | 5 | // Just like normal Node.js https request, but supports `url`, `body` and `timeout` params 6 | exports.request = function(options, cb) { 7 | cb = exports.once(cb) 8 | 9 | if (options.url) { 10 | var parsedUrl = url.parse(options.url) 11 | options.hostname = parsedUrl.hostname 12 | options.path = parsedUrl.path 13 | delete options.url 14 | } 15 | 16 | if (options.body) { 17 | options.method = options.method || 'POST' 18 | options.headers = options.headers || {} 19 | if (typeof options.body != 'string' && !Buffer.isBuffer(options.body)) { 20 | var contentType = options.headers['Content-Type'] || options.headers['content-type'] 21 | options.body = contentType == 'application/x-www-form-urlencoded' ? 22 | querystring.stringify(options.body) : JSON.stringify(options.body) 23 | } 24 | var contentLength = options.headers['Content-Length'] || options.headers['content-length'] 25 | if (!contentLength) options.headers['Content-Length'] = Buffer.byteLength(options.body) 26 | } 27 | 28 | var req = https.request(options, function(res) { 29 | var data = '' 30 | res.setEncoding('utf8') 31 | res.on('error', cb) 32 | res.on('data', function(chunk) { data += chunk }) 33 | res.on('end', function() { cb(null, res, data) }) 34 | }).on('error', cb) 35 | 36 | if (options.timeout != null) { 37 | req.setTimeout(options.timeout) 38 | req.on('timeout', function() { req.abort() }) 39 | } 40 | 41 | req.end(options.body) 42 | } 43 | 44 | exports.once = function(cb) { 45 | var called = false 46 | return function() { 47 | if (called) return 48 | called = true 49 | cb.apply(this, arguments) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /actions/rebuild.js: -------------------------------------------------------------------------------- 1 | var AWS = require('aws-sdk') 2 | var config = require('../config') 3 | 4 | var lambda = new AWS.Lambda() 5 | 6 | module.exports = rebuild 7 | 8 | function rebuild(opts) { 9 | var project = opts._[1] 10 | var buildNum = +opts._[2] 11 | if (!project || !buildNum || opts.help || opts.h) { 12 | var isErr = !(opts.help || opts.h) 13 | console[isErr ? 'error' : 'log']( 14 | ` 15 | Usage: lambci rebuild 16 | 17 | Run a particular project build again 18 | 19 | Options: 20 | --stack LambCI stack name / prefix to use (default: lambci) 21 | Project to rebuild (eg, gh/mhart/dynalite) 22 | The build you want to rebuild (eg, 23) 23 | 24 | Report bugs at github.com/lambci/cli/issues 25 | ` 26 | ) 27 | return process.exit(isErr ? 1 : 0) 28 | } 29 | 30 | lambda.invoke({ 31 | FunctionName: `${config.STACK}-build`, 32 | LogType: 'Tail', 33 | Payload: JSON.stringify({ 34 | action: 'rebuild', 35 | project, 36 | buildNum, 37 | }), 38 | }, function(err, data) { 39 | if (err && err.code == 'ResourceNotFoundException') { 40 | err = new Error(`LambCI stack '${config.STACK}' does not exist or has no Lambda function`) 41 | } 42 | if (err) { 43 | console.error(err) 44 | return process.exit(1) 45 | } 46 | if (data.LogResult) { 47 | console.log(formatLogResult(data.LogResult)) 48 | } 49 | var response = JSON.parse(data.Payload) 50 | if (data.FunctionError && response && response.errorMessage) { 51 | console.error(response.errorMessage) 52 | if (!/^gh\//.test(project)) { 53 | console.error('Try specifying the git source at the start of your project, eg:') 54 | console.error(`gh/${project}`) 55 | } 56 | return process.exit(1) 57 | } 58 | }) 59 | } 60 | 61 | function formatLogResult(logResult) { 62 | return new Buffer(logResult, 'base64').toString('utf8') 63 | .split('\n') 64 | .map(line => line.replace(/^.+\t.+\t/, '')) 65 | .join('\n') 66 | } 67 | -------------------------------------------------------------------------------- /actions/config.js: -------------------------------------------------------------------------------- 1 | var db = require('../db') 2 | 3 | module.exports = config 4 | 5 | function config(opts) { 6 | var configName = opts.unset || opts._[1] 7 | if (!configName || opts.help || opts.h) { 8 | var isErr = !(opts.help || opts.h) 9 | console[isErr ? 'error' : 'log']( 10 | ` 11 | Usage: lambci config [--project ] [--unset] [] 12 | 13 | Display/edit the specified config value (using dot notation) 14 | 15 | Options: 16 | --stack LambCI stack name / prefix to use (default: lambci) 17 | --project Project config to edit, eg 'gh/mhart/dynalite' (default: global) 18 | --unset Unset (delete) this config value 19 | Name of the config property (using dot notation) 20 | Value to set config (if not supplied, just display it) 21 | 22 | 23 | Examples: 24 | 25 | To set the global GitHub token to 'abcdef01234': 26 | 27 | lambci config secretEnv.GITHUB_TOKEN abcdef01234 28 | 29 | To override the GitHub token for a particular project: 30 | 31 | lambci config --project gh/mhart/dynalite secretEnv.GITHUB_TOKEN abcdef01234 32 | 33 | To retrieve the global Slack channel: 34 | 35 | lambci config notifications.slack.channel 36 | 37 | 38 | Report bugs at github.com/lambci/cli/issues 39 | ` 40 | ) 41 | return process.exit(isErr ? 1 : 0) 42 | } 43 | 44 | if (opts.unset) { 45 | return db.deleteConfig(opts.unset, opts.project, function(err) { 46 | if (err) return console.error(err) 47 | }) 48 | } 49 | 50 | var configNameIx = process.argv.indexOf(configName) 51 | var configVal = process.argv[configNameIx + 1] 52 | if (configVal) { 53 | db.editConfig(configName, configVal, opts.project, function(err) { 54 | if (err) return console.error(err) 55 | }) 56 | } else { 57 | db.getConfig(configName, opts.project, function(err, value) { 58 | if (err) { 59 | console.error(err) 60 | return process.exit(1) 61 | } 62 | console.log(value == null ? '' : value) 63 | }) 64 | } 65 | } 66 | 67 | 68 | -------------------------------------------------------------------------------- /db.js: -------------------------------------------------------------------------------- 1 | var AWS = require('aws-sdk') 2 | var config = require('./config') 3 | 4 | var client = new AWS.DynamoDB.DocumentClient() 5 | 6 | exports.getConfig = function(name, project, cb) { 7 | var table = configTable() 8 | project = project || 'global' 9 | 10 | var parsed = parsePathStr(name) 11 | 12 | client.get({ 13 | TableName: table, 14 | Key: {project}, 15 | ProjectionExpression: parsed.projExpr, 16 | ExpressionAttributeNames: parsed.attrNames, 17 | }, function(err, data) { 18 | if (err) return cb(friendlyErr(table, err)) 19 | cb(null, walk(data.Item, parsed.path)) 20 | }) 21 | } 22 | 23 | exports.editConfig = function(name, value, project, cb) { 24 | var table = configTable() 25 | project = project || 'global' 26 | 27 | var parsed = parsePathStr(name) 28 | 29 | // Convert boolean looking strings 30 | if (~['true', 'false'].indexOf(value)) { 31 | value = (value === 'true') 32 | } 33 | 34 | client.update({ 35 | TableName: table, 36 | Key: {project}, 37 | UpdateExpression: `SET ${parsed.projExpr} = :val`, 38 | ExpressionAttributeNames: parsed.attrNames, 39 | ExpressionAttributeValues: {':val': value}, 40 | }, function(err) { 41 | if (err && err.code == 'ValidationException' && /invalid for update/.test(err.message)) { 42 | var rootProp = parsed.path[0] 43 | return exports.getConfig(rootProp, project, function(err, rootVal) { 44 | if (err) return cb(friendlyErr(table, err)) 45 | rootVal = rootVal || {} 46 | createProperty(rootVal, parsed.path.slice(1), value) 47 | exports.editConfig(rootProp, rootVal, project, cb) 48 | }) 49 | } 50 | if (err) return cb(friendlyErr(table, err)) 51 | cb() 52 | }) 53 | } 54 | 55 | exports.deleteConfig = function(name, project, cb) { 56 | var table = configTable() 57 | project = project || 'global' 58 | 59 | var parsed = parsePathStr(name) 60 | 61 | client.update({ 62 | TableName: table, 63 | Key: {project}, 64 | UpdateExpression: `REMOVE ${parsed.projExpr}`, 65 | ExpressionAttributeNames: parsed.attrNames, 66 | }, function(err, data) { 67 | if (err) return cb(friendlyErr(table, err)) 68 | cb(null, data) 69 | }) 70 | } 71 | 72 | function parsePathStr(str) { 73 | var path = str.split('.') 74 | var projExprs = [] 75 | var attrNames = path.reduce((obj, name) => { 76 | projExprs.push(`#${name}`) 77 | obj[`#${name}`] = name 78 | return obj 79 | }, {}) 80 | return { 81 | path, 82 | attrNames, 83 | projExpr: projExprs.join('.'), 84 | } 85 | } 86 | 87 | function walk(obj, path) { 88 | if (!path.length || obj == null) return obj 89 | var prop = path.shift() 90 | return walk(obj[prop], path) 91 | } 92 | 93 | function createProperty(obj, path, value) { 94 | var prop = path.shift() 95 | if (!path.length) { 96 | obj[prop] = value 97 | return 98 | } 99 | obj[prop] = {} 100 | createProperty(obj[prop], path, value) 101 | } 102 | 103 | function configTable() { 104 | return `${config.STACK}-config` 105 | } 106 | 107 | function friendlyErr(table, err) { 108 | switch (err.code) { 109 | case 'UnrecognizedClientException': 110 | return new Error('Incorrect AWS_ACCESS_KEY_ID or AWS_SESSION_TOKEN') 111 | case 'InvalidSignatureException': 112 | return new Error('Incorrect AWS_SECRET_ACCESS_KEY') 113 | case 'AccessDeniedException': 114 | return new Error(`Insufficient credentials to access DynamoDB table ${table}`) 115 | case 'ResourceNotFoundException': 116 | return new Error(`LambCI stack '${config.STACK}' does not exist or has no DynamoDB tables`) 117 | } 118 | return err 119 | } 120 | 121 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LambCI command line helper 2 | 3 | ```console 4 | npm install -g lambci-cli 5 | ``` 6 | 7 | You can then run `lambci` on the command line: 8 | 9 | ```console 10 | $ lambci 11 | 12 | Usage: lambci [--stack ] [options] 13 | 14 | A command line tool for administering LambCI 15 | 16 | Options: 17 | --stack LambCI stack name / prefix to use (default: lambci) 18 | --help Display help about a particular command 19 | 20 | Commands: 21 | info General info about the LambCI stack 22 | config Display/edit config options 23 | hook Add/remove/update/show hook for GitHub repo 24 | rebuild Run a particular project build again 25 | 26 | Report bugs at github.com/lambci/cli/issues 27 | ``` 28 | 29 | ## Prerequisites 30 | 31 | You'll need to make sure you have your AWS credentials set either in environment variables, 32 | or in configuration files, in the same format as the 33 | [AWS CLI](http://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html). 34 | 35 | ## lambci config 36 | 37 | ```console 38 | Usage: lambci config [--project ] [--unset] [] 39 | 40 | Display/edit the specified config value (using dot notation) 41 | 42 | Options: 43 | --stack LambCI stack name / prefix to use (default: lambci) 44 | --project Project config to edit, eg 'gh/mhart/dynalite' (default: global) 45 | --unset Unset (delete) this config value 46 | Name of the config property (using dot notation) 47 | Value to set config (if not supplied, just display it) 48 | 49 | 50 | Examples: 51 | 52 | To set the global GitHub token to 'abcdef01234': 53 | 54 | lambci config secretEnv.GITHUB_TOKEN abcdef01234 55 | 56 | To override the GitHub token for a particular project: 57 | 58 | lambci config --project gh/mhart/dynalite secretEnv.GITHUB_TOKEN abcdef01234 59 | 60 | To retrieve the global Slack channel: 61 | 62 | lambci config notifications.slack.channel 63 | ``` 64 | 65 | ## lambci hook 66 | 67 | ```console 68 | Usage: lambci hook [--token |--hub-token] [options] 69 | 70 | Add/remove/update/show hook for GitHub repo 71 | 72 | Options: 73 | --stack LambCI stack name / prefix to use (default: lambci) 74 | --token GitHub API token to use 75 | --hub-token Use the API token from ~/.config/hub (if you have installed 'hub') 76 | --add / --remove / --update / --show 77 | GitHub repo to add/update/remove hook from (eg, mhart/dynalite) 78 | --topic SNS topic to use with --add (defaults to value from CloudFormation stack) 79 | --key AWS access key to use with --add (defaults to value from CloudFormation stack) 80 | --secret AWS secret key to use with --add (defaults to value from CloudFormation stack) 81 | 82 | 83 | Examples: 84 | 85 | To add a hook to the mhart/dynalite repo if you have a LambCI CloudFormation stack: 86 | 87 | lambci hook --token abcdef0123 --add mhart/dynalite 88 | 89 | To add a hook using the GitHub token if you've installed 'hub': 90 | 91 | lambci hook --hub-token --add mhart/dynalite 92 | 93 | To add a hook using specific params instead of from a CloudFormation stack: 94 | 95 | lambci hook --token abcd --add mhart/dynalite --topic arn:aws:sns:... --key ABCD --secret 234832 96 | 97 | To update an existing hook just to ensure it responds to the correct events: 98 | 99 | lambci hook --token abcd --update mhart/dynalite 100 | 101 | To show the existing hooks in a repo: 102 | 103 | lambci hook --token abcd --show mhart/dynalite 104 | 105 | To remove a LambCI (SNS) hook from a repo: 106 | 107 | lambci hook --token abcd --remove mhart/dynalite 108 | ``` 109 | 110 | ## lambci rebuild 111 | 112 | ```console 113 | Usage: lambci rebuild 114 | 115 | Run a particular project build again 116 | 117 | Options: 118 | --stack LambCI stack name / prefix to use (default: lambci) 119 | Project to rebuild (eg, gh/mhart/dynalite) 120 | The build you want to rebuild (eg, 23) 121 | ``` 122 | -------------------------------------------------------------------------------- /github.js: -------------------------------------------------------------------------------- 1 | var config = require('./config') 2 | var utils = require('./utils') 3 | 4 | var USER_AGENT = 'lambci-cli' 5 | 6 | exports.createClient = function(token, repo) { 7 | return new GithubClient(token, repo) 8 | } 9 | 10 | exports.GithubClient = GithubClient 11 | 12 | function GithubClient(token, repo) { 13 | this.token = token 14 | this.repo = repo 15 | } 16 | 17 | // By default this will just ensure that the SNS hook listens for push & pull_request 18 | GithubClient.prototype.updateSnsHook = function(options, cb) { 19 | options = options || {events: ['push', 'pull_request']} 20 | var getHookId = cb => options.id ? cb(null, options.id) : this.getSnsHook((err, hook) => cb(err, hook && hook.id)) 21 | getHookId((err, id) => { 22 | if (err) return cb(err) 23 | this.updateHook(id, options, cb) 24 | }) 25 | } 26 | 27 | GithubClient.prototype.deleteSnsHook = function(id, cb) { 28 | var getHookId = cb => id ? cb(null, id) : this.getSnsHook((err, hook) => cb(err, hook && hook.id)) 29 | 30 | console.log(`Deleting SNS hook for ${this.repo}`) 31 | 32 | getHookId((err, id) => { 33 | if (err) return cb(err) 34 | if (!id) return cb() 35 | this.deleteHook(id, cb) 36 | }) 37 | } 38 | 39 | GithubClient.prototype.getSnsHook = function(cb) { 40 | this.listHooks((err, data) => cb(err, data && data.find(hook => hook.name == 'amazonsns'))) 41 | } 42 | 43 | GithubClient.prototype.createOrUpdateSnsHook = function(awsKey, awsSecret, snsTopic, cb) { 44 | var hook = { 45 | name: 'amazonsns', 46 | active: true, 47 | events: ['push', 'pull_request'], 48 | // https://github.com/github/github-services/blob/master/lib/services/amazon_sns.rb 49 | config: { 50 | aws_key: awsKey, 51 | aws_secret: awsSecret, 52 | sns_topic: snsTopic, 53 | sns_region: config.REGION, 54 | }, 55 | } 56 | 57 | console.log(`Updating SNS hook for ${this.repo}`) 58 | 59 | this.createHook(hook, cb) 60 | } 61 | 62 | GithubClient.prototype.listHooks = function(cb) { 63 | this.request({path: `/repos/${this.repo}/hooks`}, cb) 64 | } 65 | 66 | // See: https://developer.github.com/v3/repos/hooks/#create-a-hook 67 | // hook can be: {name: '', config: {}, events: [], active: true} 68 | GithubClient.prototype.createHook = function(hook, cb) { 69 | this.request({path: `/repos/${this.repo}/hooks`, body: hook}, cb) 70 | } 71 | 72 | // See: https://developer.github.com/v3/repos/hooks/#edit-a-hook 73 | // updates can be: {config: {}, events: [], add_events: [], remove_events: [], active: true} 74 | GithubClient.prototype.updateHook = function(id, updates, cb) { 75 | this.request({method: 'PATCH', path: `/repos/${this.repo}/hooks/${id}`, body: updates}, cb) 76 | } 77 | 78 | // See: https://developer.github.com/v3/repos/hooks/#delete-a-hook 79 | GithubClient.prototype.deleteHook = function(id, cb) { 80 | this.request({method: 'DELETE', path: `/repos/${this.repo}/hooks/${id}`}, cb) 81 | } 82 | 83 | GithubClient.prototype.request = function(options, cb) { 84 | /* eslint dot-notation:0 */ 85 | options.host = 'api.github.com' 86 | options.headers = options.headers || {} 87 | options.headers['Accept'] = 'application/vnd.github.v3+json' 88 | options.headers['User-Agent'] = USER_AGENT 89 | if (options.body) { 90 | options.headers['Content-Type'] = 'application/vnd.github.v3+json' 91 | } 92 | if (this.token) { 93 | options.headers['Authorization'] = `token ${this.token}` 94 | } 95 | utils.request(options, function(err, res, data) { 96 | if (err) return cb(err) 97 | if (!data && res.statusCode < 400) return cb(null, {}) 98 | 99 | var json 100 | try { 101 | json = JSON.parse(data) 102 | } catch (e) { 103 | err = new Error(data ? `Could not parse response: ${data}` : res.statusCode) 104 | err.statusCode = res.statusCode 105 | err.body = data 106 | return cb(err) 107 | } 108 | if (res.statusCode >= 400) { 109 | var errMsg = json.message || data 110 | if (res.statusCode == 401) { 111 | errMsg = 'GitHub token is invalid' 112 | } else if (res.statusCode == 404) { 113 | errMsg = 'GitHub token has insufficient privileges or repository does not exist' 114 | } 115 | err = new Error(errMsg) 116 | err.statusCode = res.statusCode 117 | err.body = json 118 | return cb(err) 119 | } 120 | cb(null, json) 121 | }) 122 | } 123 | 124 | -------------------------------------------------------------------------------- /actions/hook.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var path = require('path') 3 | var AWS = require('aws-sdk') 4 | var config = require('../config') 5 | var github = require('../github') 6 | 7 | module.exports = hook 8 | 9 | function hook(opts) { 10 | var token = opts.token || opts['hub-token'] 11 | var repo = opts.add || opts.remove || opts.update || opts.show 12 | if (!token || typeof repo != 'string' || opts.help || opts.h) { 13 | var isErr = !(opts.help || opts.h) 14 | console[isErr ? 'error' : 'log']( 15 | ` 16 | Usage: lambci hook [--token |--hub-token] [options] 17 | 18 | Add/remove/update/show hook for GitHub repo 19 | 20 | Options: 21 | --stack LambCI stack name / prefix to use (default: lambci) 22 | --token GitHub API token to use 23 | --hub-token Use the API token from ~/.config/hub (if you have installed 'hub') 24 | --add / --remove / --update / --show 25 | GitHub repo to add/update/remove hook from (eg, mhart/dynalite) 26 | --topic SNS topic to use with --add (defaults to value from CloudFormation stack) 27 | --key AWS access key to use with --add (defaults to value from CloudFormation stack) 28 | --secret AWS secret key to use with --add (defaults to value from CloudFormation stack) 29 | 30 | 31 | Examples: 32 | 33 | To add a hook to the mhart/dynalite repo if you have a LambCI CloudFormation stack: 34 | 35 | lambci hook --token abcdef0123 --add mhart/dynalite 36 | 37 | To add a hook using the GitHub token if you've installed 'hub': 38 | 39 | lambci hook --hub-token --add mhart/dynalite 40 | 41 | To add a hook using specific params instead of from a CloudFormation stack: 42 | 43 | lambci hook --token abcd --add mhart/dynalite --topic arn:aws:sns:... --key ABCD --secret 234832 44 | 45 | To update an existing hook just to ensure it responds to the correct events: 46 | 47 | lambci hook --token abcd --update mhart/dynalite 48 | 49 | To show the existing hooks in a repo: 50 | 51 | lambci hook --token abcd --show mhart/dynalite 52 | 53 | To remove a LambCI (SNS) hook from a repo: 54 | 55 | lambci hook --token abcd --remove mhart/dynalite 56 | 57 | 58 | Report bugs at github.com/lambci/cli/issues 59 | ` 60 | ) 61 | return process.exit(isErr ? 1 : 0) 62 | } 63 | 64 | if (opts['hub-token']) { 65 | var homeDir = process.env[(process.platform == 'win32') ? 'USERPROFILE' : 'HOME'] 66 | var hubFile = fs.readFileSync(path.join(homeDir, '.config', 'hub'), 'utf8') 67 | var match = hubFile.match(/oauth_token:\s*([a-f0-9]+)/) 68 | if (!match) { 69 | console.error('Could not find oauth_token key in ~/.config/hub') 70 | return process.exit(1) 71 | } 72 | token = match[1].trim() 73 | } 74 | var client = github.createClient(token, repo) 75 | 76 | if (opts.show) { 77 | return client.listHooks(function(err, data) { 78 | if (err) { 79 | console.error(err) 80 | return process.exit(1) 81 | } 82 | console.log(data) 83 | }) 84 | } else if (opts.remove) { 85 | return client.deleteSnsHook(null, function(err) { 86 | if (err) { 87 | console.error(err) 88 | return process.exit(1) 89 | } 90 | }) 91 | } else if (opts.update) { 92 | return client.updateSnsHook(null, function(err) { 93 | if (err) { 94 | console.error(err) 95 | return process.exit(1) 96 | } 97 | }) 98 | } 99 | 100 | var topic = opts.topic 101 | var key = opts.key 102 | var secret = opts.secret 103 | 104 | if (topic && key && secret) { 105 | return client.createOrUpdateSnsHook(key, secret, topic, function(err) { 106 | if (err) { 107 | console.error(err) 108 | return process.exit(1) 109 | } 110 | }) 111 | } 112 | 113 | var cfn = new AWS.CloudFormation() 114 | cfn.describeStacks({StackName: config.STACK}, function(err, data) { 115 | if (err) { 116 | console.error(err) 117 | return process.exit(1) 118 | } 119 | var outputs = data.Stacks[0].Outputs 120 | topic = (outputs.find(output => output.OutputKey == 'SnsTopic') || {}).OutputValue 121 | key = (outputs.find(output => output.OutputKey == 'SnsAccessKey') || {}).OutputValue 122 | secret = (outputs.find(output => output.OutputKey == 'SnsSecret') || {}).OutputValue 123 | if (!topic || !key || !secret) { 124 | console.error('Could not find topic key and secret in stack outputs, please specify on command line') 125 | return process.exit(1) 126 | } 127 | client.createOrUpdateSnsHook(key, secret, topic, function(err) { 128 | if (err) { 129 | console.error(err) 130 | return process.exit(1) 131 | } 132 | }) 133 | }) 134 | } 135 | --------------------------------------------------------------------------------