diff --git a/.dockerignore b/.dockerignore index 14b1fb97f..e413ac5ea 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,18 @@ +# excluded because we'll likely need to rebuild this. node_modules + +# scripts are used during development and testing, not +# production. +scripts + +# documentation should not be visable in production. +docs + +# static assets are rebuild in the docker container. +dist + +# tests are not run in the docker container. test + +# we won't use the .git folder in production. .git diff --git a/.editorconfig b/.editorconfig index 986314661..4a7ea3036 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,14 +2,11 @@ root = true [*] indent_style = space +indent_size = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true -[{package.json,.*rc,*.yml}] -indent_style = space -indent_size = 2 - [*.md] trim_trailing_whitespace = false diff --git a/.eslintignore b/.eslintignore index 83564d3ff..6ec4c0c88 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,5 @@ dist client/lib **/*.html +plugins/ +plugins/**/node_modules \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9ce1637d4..5edaabbd1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,13 @@ node_modules npm-debug.log* dist -!dist/coral-admin -dist/coral-admin/bundle.js test/e2e/reports -.DS_Store -*.iml -*.swp dump.rdb .env *.cfg .idea/ coverage/ -.tags -.tags1 - -# remove plugin folders -# plugins -# plugins.json +*.swp +plugins.json +plugins +!plugins/coral-plugin-respect diff --git a/Dockerfile b/Dockerfile index f8cb8a45d..5bc38013a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,16 +5,21 @@ RUN mkdir -p /usr/src/app WORKDIR /usr/src/app # Setup the environment -ENV NODE_ENV production ENV PATH /usr/src/app/bin:$PATH ENV TALK_PORT 5000 EXPOSE 5000 -# Install app dependencies -COPY package.json yarn.lock /usr/src/app/ -RUN yarn install --production - # Bundle app source COPY . /usr/src/app +# Install app dependencies and build static assets. +RUN yarn install --frozen-lockfile && \ + cli plugins reconcile && \ + yarn build && \ + yarn install --production && \ + yarn cache clean + +# Ensure the runtime of the container is in production mode. +ENV NODE_ENV production + CMD ["yarn", "start"] diff --git a/Dockerfile.onbuild b/Dockerfile.onbuild new file mode 100644 index 000000000..34c321448 --- /dev/null +++ b/Dockerfile.onbuild @@ -0,0 +1,14 @@ +FROM coralproject/talk:latest + +# Bundle app source +ONBUILD COPY . /usr/src/app + +# At this stage, we need to install the development dependancies again because +# we need to have webpack available. We then build the new dependancies and +# clear out the development dependancies again. After this we of course need to +# clear out the yarn cache, this saves quite a lot of size. +ONBUILD RUN NODE_ENV=development yarn install --frozen-lockfile && \ + NODE_ENV=production cli plugins reconcile && \ + NODE_ENV=production yarn build && \ + NODE_ENV=production yarn install --production && \ + yarn cache clean \ No newline at end of file diff --git a/PLUGINS.md b/PLUGINS.md index a77b0b8b2..e5cf8d760 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -9,7 +9,7 @@ All plugins must be registered in the root file `plugins.json`. The format for this file is thus: -```js +```json { "server": [ "people" @@ -22,6 +22,42 @@ name in the `plugins/` folder. For example, the above `plugins.json` would require a plugin from `plugins/people`, which must provide a `index.js` file that returns an object that matches the Plugin Specification. +If the package is external (available on NPM) you can specify the string for +the version by using an object instead, for example: + +```json +{ + "server": [ + {"people": "^1.2.0"} + ] +} +``` + +External plugins can be resolved by running: + +```bash +./bin/cli plugins reconcile +``` + +This will also traverse into local plugin folders and install their +dependancies. _Note that if the plugin is already installed and available in the +node_modules folder, it will not be fetched again unless there is a version +mismatch._ + +## Plugin Dependencies + +From your plugins you may import any component of server code relative to the +project root. An example could be: + +```js +const cache = require('services/cache'); +``` + +You may also include additional external depenancies in your local packages by +specifying a `package.json` at your plugin root which will result in a +`node_modules` folder being generated at the plugin root with your specific +dependencies. + ## Server Plugins ### Specification @@ -170,6 +206,88 @@ If your post function accepts four parameters, then it can modify the field result. It is *required* that the function resolves a promise (or returns) with the modified value or simply the original if you didn't modify it. +#### Field: `router` + +```js +(router) => { + router.get('/api/v1/people', (req, res) => { + res.json({people: [{name: 'Bob'}]}); + }); +} +``` + +The Router hook allows you to create a function that accepts the base express +router where you can mount any amount of middleware/routes to do any form of +action needed by external applications. We also provide the authorization +middleware via: + +```js +const authorization = require('middleware/authorization'); + +module.exports = { + router(router) { + router.get('/api/v1/people', authorization.needed('ADMIN'), (req, res) => { + res.json({people: [{name: 'SECRET PEOPLE'}]}); + }); + } +} +``` + +#### Field: `passport` + +```js +const FacebookStrategy = require('passport-facebook').Strategy; +const UsersService = require('services/users'); +const {ValidateUserLogin, HandleAuthPopupCallback} = require('services/passport'); + +module.exports = { + passport(passport) { + passport.use(new FacebookStrategy({ + clientID: process.env.TALK_FACEBOOK_APP_ID, + clientSecret: process.env.TALK_FACEBOOK_APP_SECRET, + callbackURL: `${process.env.TALK_ROOT_URL}/api/v1/auth/facebook/callback`, + passReqToCallback: true, + profileFields: ['id', 'displayName', 'picture.type(large)'] + }, async (req, accessToken, refreshToken, profile, done) => { + + let user; + try { + user = await UsersService.findOrCreateExternalUser(profile); + } catch (err) { + return done(err); + } + + return ValidateUserLogin(profile, user, done); + })); + }, + router(router) { + + // Note that we have to import the passport instance here, it is + // instantiated after all the strategies have been mounted. + const {passport} = require('services/passport'); + + /** + * Facebook auth endpoint, this will redirect the user immediatly to facebook + * for authorization. + */ + router.get('/facebook', passport.authenticate('facebook', {display: 'popup', authType: 'rerequest', scope: ['public_profile']})); + + /** + * Facebook callback endpoint, this will send the user a html page designed to + * send back the user credentials upon sucesfull login. + */ + router.get('/facebook/callback', (req, res, next) => { + + // Perform the facebook login flow and pass the data back through the opener. + passport.authenticate('facebook', HandleAuthPopupCallback(req, res, next))(req, res, next); + }); + } +}; +``` + +This is a full example including the routes hook to add the required components +to the application router to support a different auth strategy. + ### Full Example Contents of `plugins.json`: diff --git a/README.md b/README.md index 7e303f70e..3111446ea 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Talk [![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk) -A commenting platform from [The Coral Project](https://coralproject.net). Talk enters a closed beta in March 2017, but you can download the code for our alpha here. [Read more about Talk here.](https://coralproject.net/products/talk.html) +Talk is currently in Beta! [Read more about Talk here.](https://coralproject.net/products/talk.html) Third party licenses are available via the `/client/3rdpartylicenses.txt` endpoint when the server is running with built assets. diff --git a/app.js b/app.js index 5b704bea0..3ae03ec72 100644 --- a/app.js +++ b/app.js @@ -3,7 +3,7 @@ const bodyParser = require('body-parser'); const morgan = require('morgan'); const path = require('path'); const helmet = require('helmet'); -const passport = require('./services/passport'); +const {passport} = require('./services/passport'); const session = require('express-session'); const enabled = require('debug').enabled; const RedisStore = require('connect-redis')(session); diff --git a/bin/cli b/bin/cli index c0690e455..7e27fa85b 100755 --- a/bin/cli +++ b/bin/cli @@ -13,6 +13,7 @@ program .command('setup', 'setup the application') .command('jobs', 'work with the job queues') .command('users', 'work with the application auth') + .command('plugins', 'provides utilities for interacting with the plugin system') .parse(process.argv); /** diff --git a/bin/cli-plugins b/bin/cli-plugins new file mode 100755 index 000000000..94ce74904 --- /dev/null +++ b/bin/cli-plugins @@ -0,0 +1,297 @@ +#!/usr/bin/env node + +/** + * Module dependencies. + */ + +// Interface heavily inspired by the yarn package manager: +// https://yarnpkg.com/ + +const program = require('./commander'); + +// Make things colorful! +require('colors'); +const emoji = require('node-emoji'); + +const dir = process.cwd(); +const fs = require('fs'); +const path = require('path'); +const spawn = require('cross-spawn'); +const semver = require('semver'); +const resolve = require('resolve'); +const {plugins, itteratePlugins, isInternal} = require('../plugins'); + +function existsInNodeModules(name) { + try { + resolve.sync(name, {basedir: dir}); + + return true; + } catch (e) { + return false; + } +} + +function versionMatch(name, version) { + try { + let matched = false; + + resolve.sync(name, { + basedir: dir, + packageFilter: (pkg) => { + if (pkg && pkg.version && semver.satisfies(pkg.version, version)) { + matched = true; + } + + return pkg; + } + }); + + return matched; + } catch (e) { + return false; + } +} + +const EXTERNAL = /^\w[a-z\-0-9\.]+$/; // Match "react", "path", "fs", "lodash.random", etc. + +function reconcilePackages({quiet = false, upgradeRemote = false}) { + const fetchable = []; + const local = []; + const upgradable = []; + + if (!quiet) { + console.log(); + console.log(' +local (l) packages in your project'); + console.log(' +external (e) packages are external'); + console.log(' +outofdate (oe) packages are external but are out of date'); + console.log(' +missing (m) packages are not found'); + console.log(); + } + + for (let i in plugins) { + let section = itteratePlugins(plugins[i]); + + for (let j in section) { + let {name, version} = section[j]; + + let namespaced = name.charAt(0) === '@'; + let dep = name.split('/') + .slice(0, namespaced ? 2 : 1) + .join('/'); + + // Ignore relative modules, which aren't installed by NPM + if (!dep.match(EXTERNAL) && !namespaced) { + return; + } + + if (isInternal(dep)) { + if (!quiet) { + console.log(` l ${name}`); + } + + local.push({name, version}); + continue; + } + + if (!existsInNodeModules(dep)) { + if (!quiet) { + console.log(` m ${name}`); + } + fetchable.push({name, version}); + } else if (!versionMatch(dep, version)) { + + // A plugin was found, yet the current version does not match the + // current version installed. We should warn if upgradeRemote is + // not enabled that it is currently not supported. + if (!upgradeRemote) { + if (!quiet) { + console.warn(` oe ${name} (package upgrade may be required)`.bgRed); + } + + continue; + } + + console.log(` oe ${name} (package upgrade may be required)`); + + upgradable.push({name, version}); + } else { + if (!quiet) { + console.log(` e ${name}`); + } + + if (upgradeRemote) { + upgradable.push({name, version}); + } + } + } + } + + if (!quiet) { + console.log(); + } + + return {local, fetchable, upgradable}; +} + +async function reconcileRemotePlugins({skipLocal, dryRun, upgradeRemote}) { + console.log(`\n[${skipLocal ? '1/2' : '2/3'}] ${emoji.get('mag')} Reconciling plugins...`.yellow); + const {fetchable, upgradable} = reconcilePackages({upgradeRemote}); + + console.log(`[${skipLocal ? '2/2' : '3/3'}] ${emoji.get('truck')} Fetching plugins...\n`.yellow); + + if (fetchable.length > 0) { + + console.log(`$ yarn add ${fetchable.map(({name, version}) => `${name}@${version}`.cyan)}`); + + if (!dryRun) { + + let args = [ + 'add', + ...fetchable.map(({name, version}) => `${name}@${version}`) + ]; + + let output = spawn.sync('yarn', args, { + stdio: ['ignore', 'pipe', 'inherit'] + }); + + if (output.status) { + throw new Error('Could not install external plugins, errors occured during install'); + } + + console.log(output.stdout.toString()); + } + } + + if (upgradable.length > 0) { + console.log(`$ yarn upgrade ${upgradable.map(({name, version}) => `${name}@${version}`.cyan)}`); + + if (!dryRun) { + + let args = [ + 'upgrade', + ...upgradable.map(({name, version}) => `${name}@${version}`) + ]; + + let output = spawn.sync('yarn', args, { + stdio: ['ignore', 'pipe', 'inherit'] + }); + + if (output.status) { + throw new Error('Could not install external plugins, errors occured during install'); + } + + console.log(output.stdout.toString()); + } + } + + return {upgradable, fetchable}; +} + +async function reconcileLocalPlugins({skipRemote, dryRun}) { + console.log(`\n[${skipRemote ? '1/1' : '1/3'}] ${emoji.get('pick')} Installing local plugin dependencies...\n`.yellow); + const {local} = reconcilePackages({quiet: true}); + + for (let i in local) { + let {name} = local[i]; + + if (!fs.existsSync(path.join(dir, 'plugins', name, 'package.json'))) { + continue; + } + + let wd = path.join(dir, 'plugins', name); + + console.log(`$ cd ${wd.cyan} && yarn`); + + if (!dryRun) { + let args = []; + + let output = spawn.sync('yarn', args, { + stdio: ['ignore', 'pipe', 'inherit'], + cwd: wd + }); + + if (output.status) { + throw new Error('Could not install local plugin dependencies, errors occured during install'); + } + + console.log(output.stdout.toString()); + } + } +} + +// This traverses the local plugins and installs any dependencies listed there, +// this only is really needed for plugins that are installed via docker because +// core plugins will have their dependencies already included in core. +async function reconcilePluginDeps({skipLocal, skipRemote, dryRun, upgradeRemote}) { + let startTime = new Date(); + + // We don't need to do anything if we skip everything.... + if (skipLocal && skipRemote) { + return; + } + + // Traverse local plugins and install dependencies if enabled. + if (!skipLocal) { + await reconcileLocalPlugins({skipRemote, dryRun}); + } + + // Locate any external plugins and install them. + if (!skipRemote) { + let results = []; + try { + results = await reconcileRemotePlugins({skipLocal, skipRemote, dryRun, upgradeRemote}); + } catch (e) { + throw e; + } + + let status; + if (dryRun) { + status = '[dry-run] success'.green; + } else { + status = 'success'.green; + } + + let message; + if (results.upgradable.length === 0 && results.fetchable.length === 0) { + message = 'Already up-to-date.'; + } else if (results.upgradable.length === 0) { + message = `Fetched ${results.fetchable.length} new plugins.`; + } else if (results.fetchable.length === 0) { + message = `Upgraded ${results.upgradable.length} new plugins.`; + } else { + message = `Fetched ${results.fetchable.length} new plugins, upgraded ${results.upgradable.length} plugins.`; + } + + console.log(`\n${status} ${message}`); + } + + let endTime = new Date(); + + let totalTime = ((endTime.getTime() - startTime.getTime()) / 1000).toFixed(2); + console.log(`✨ Done in ${totalTime}s.`); +} + +//============================================================================== +// Setting up the program command line arguments. +//============================================================================== + +program + .command('list') + .description('') + .action(reconcilePackages); + +program + .command('reconcile') + .description('reconciles local plugin dependencies and downloads external plugins') + .option('-u, --upgrade-remote', 'upgrades remote dependencies') + .option('-d, --dry-run', 'does not actually change anything on the filesystem acts only as a simulation') + .option('--skip-local', 'skips the local dependancy reconciliation') + .option('--skip-remote', 'skips the remote plugin reconciliation') + .action(reconcilePluginDeps); + +program.parse(process.argv); + +// If there is no command listed, output help. +if (!process.argv.slice(2).length) { + program.outputHelp(); +} diff --git a/circle.yml b/circle.yml index 2357ecf4c..013ef28b8 100644 --- a/circle.yml +++ b/circle.yml @@ -1,6 +1,6 @@ machine: node: - version: 7.6 + version: 7 services: - docker - redis @@ -20,6 +20,7 @@ dependencies: # - sudo service mongod restart # Install node dependencies. + - yarn --version - yarn cache_directories: - ~/.cache/yarn @@ -47,9 +48,9 @@ deployment: release: tag: /v[0-9]+(\.[0-9]+)*/ commands: - - bash ./scripts/deploy.sh + - bash ./scripts/docker.sh deploy latest: branch: master commands: - - bash ./scripts/deploy.sh + - bash ./scripts/docker.sh deploy diff --git a/client/coral-admin/README.md b/client/coral-admin/README.md deleted file mode 100644 index 7bc9de36e..000000000 --- a/client/coral-admin/README.md +++ /dev/null @@ -1,23 +0,0 @@ - -# Coral Admin - -This app handles moderation for Talk (and maybe more later on) - -## Installation - - $ yarn install - $ cp config.sample.json config.json - -Then change `config.json` to adjust it to your project - -## Building for production - - $ yarn build - -The public folder has everything you need for deployment. You can just copy that folder to your favorite static web server - -## Development - - $ yarn start - -A development server will be running at http://localhost:4132 diff --git a/client/coral-embed-stream/src/Comment.css b/client/coral-embed-stream/src/Comment.css index 01022ae66..4383cbc5b 100644 --- a/client/coral-embed-stream/src/Comment.css +++ b/client/coral-embed-stream/src/Comment.css @@ -5,6 +5,7 @@ .Comment { margin-bottom: 15px; + position: relative; } .pendingComment { diff --git a/client/coral-embed-stream/src/Comment.js b/client/coral-embed-stream/src/Comment.js index 39c7d908f..f5781a430 100644 --- a/client/coral-embed-stream/src/Comment.js +++ b/client/coral-embed-stream/src/Comment.js @@ -148,8 +148,7 @@ class Comment extends React.Component { id={`c_${comment.id}`} style={{marginLeft: depth * 30}}>
- -
+
{ isStaff(comment.tags) @@ -192,7 +191,6 @@ class Comment extends React.Component { removeBest={removeBestTag} /> -
@@ -246,8 +244,7 @@ class Comment extends React.Component { showSignInDialog={showSignInDialog} reactKey={reply.id} key={reply.id} - comment={reply} - />; + comment={reply} />; }) } { diff --git a/client/coral-embed-stream/src/Embed.js b/client/coral-embed-stream/src/Embed.js index 31f829829..fa59cc1db 100644 --- a/client/coral-embed-stream/src/Embed.js +++ b/client/coral-embed-stream/src/Embed.js @@ -6,16 +6,17 @@ import I18n from 'coral-framework/modules/i18n/i18n'; import translations from 'coral-framework/translations'; const lang = new I18n(translations); -import {TabBar, Tab, TabContent, Spinner} from 'coral-ui'; +import {TabBar, Tab, TabContent, Spinner, Button} from 'coral-ui'; const {logout, showSignInDialog, requestConfirmEmail} = authActions; const {addNotification, clearNotification} = notificationActions; const {fetchAssetSuccess} = assetActions; +import {NEW_COMMENT_COUNT_POLL_INTERVAL} from 'coral-framework/constants/comments'; import {queryStream} from 'coral-framework/graphql/queries'; import {postComment, postFlag, postLike, postDontAgree, deleteAction, addCommentTag, removeCommentTag} from 'coral-framework/graphql/mutations'; import {editName} from 'coral-framework/actions/user'; -import {updateCountCache} from 'coral-framework/actions/asset'; +import {updateCountCache, viewAllComments} from 'coral-framework/actions/asset'; import {notificationActions, authActions, assetActions, pym} from 'coral-framework'; import Stream from './Stream'; @@ -31,7 +32,7 @@ import ChangeUsernameContainer from '../../coral-sign-in/containers/ChangeUserna import ProfileContainer from 'coral-settings/containers/ProfileContainer'; import RestrictedContent from 'coral-framework/components/RestrictedContent'; import ConfigureStreamContainer from 'coral-configure/containers/ConfigureStreamContainer'; -import Comment from './Comment'; +import HighlightedComment from './Comment'; import LoadMore from './LoadMore'; import NewCount from './NewCount'; @@ -69,10 +70,30 @@ class Embed extends Component { pym.sendMessage('childReady'); } + componentWillUnmount () { + clearInterval(this.state.countPoll); + } + componentWillReceiveProps (nextProps) { const {loadAsset} = this.props; if(!isEqual(nextProps.data.asset, this.props.data.asset)) { loadAsset(nextProps.data.asset); + + const {getCounts, updateCountCache} = this.props; + const {asset} = nextProps.data; + + updateCountCache(asset.id, asset.commentCount); + + this.setState({ + countPoll: setInterval(() => { + const {asset} = this.props.data; + getCounts({ + asset_id: asset.id, + limit: asset.comments.length, + sort: 'REVERSE_CHRONOLOGICAL' + }); + }, NEW_COMMENT_COUNT_POLL_INTERVAL) + }); } } @@ -80,7 +101,7 @@ class Embed extends Component { if(!isEqual(prevProps.data.comment, this.props.data.comment)) { // Scroll to a permalinked comment if one is in the URL once the page is done rendering. - setTimeout(()=>pym.scrollParentToChildEl(`c_${this.props.data.comment.id}`), 0); + setTimeout(() => pym.scrollParentToChildEl('coralStream'), 0); } } @@ -98,6 +119,8 @@ class Embed extends Component { const {closedAt, countCache = {}} = this.props.asset; const {loading, asset, refetch, comment} = this.props.data; const {loggedIn, isAdmin, user, showSignInDialog, signInOffset} = this.props.auth; + + // even though the permalinked comment is the highlighted one, we're displaying its parent + replies const highlightedComment = comment && comment.parent ? comment.parent : comment; const openStream = closedAt === null; @@ -125,6 +148,16 @@ class Embed extends Component { {lang.t('MY_COMMENTS')} Configure Stream + { + highlightedComment && + + } {loggedIn && this.props.logout().then(refetch)} changeTab={this.changeTab}/>} { @@ -171,9 +204,11 @@ class Embed extends Component { offset={signInOffset}/>} {loggedIn && user && } {loggedIn && } + + {/* the highlightedComment is isolated after the user followed a permalink */} { - highlightedComment && - asset.comments.length} loadMore={this.props.loadMore}/> + comment={highlightedComment} /> + :
+ +
+ +
+ asset.comments.length} + loadMore={this.props.loadMore} /> +
+ }
({ editName: (username) => dispatch(editName(username)), showSignInDialog: (offset) => dispatch(showSignInDialog(offset)), updateCountCache: (id, count) => dispatch(updateCountCache(id, count)), + viewAllComments: () => dispatch(viewAllComments()), logout: () => dispatch(logout()), dispatch: d => dispatch(d) }); @@ -279,5 +352,5 @@ export default compose( addCommentTag, removeCommentTag, deleteAction, - queryStream, + queryStream )(Embed); diff --git a/client/coral-embed-stream/src/LoadMore.js b/client/coral-embed-stream/src/LoadMore.js index 89dc0104c..1a58c8d19 100644 --- a/client/coral-embed-stream/src/LoadMore.js +++ b/client/coral-embed-stream/src/LoadMore.js @@ -5,7 +5,7 @@ import {ADDTL_COMMENTS_ON_LOAD_MORE} from 'coral-framework/constants/comments'; import {Button} from 'coral-ui'; const lang = new I18n(translations); -const loadMoreComments = (assetId, comments, loadMore, parentId) => { +const loadMoreComments = (assetId, comments, loadMore, parentId, replyCount) => { let cursor = null; if (comments.length) { @@ -15,7 +15,7 @@ const loadMoreComments = (assetId, comments, loadMore, parentId) => { } loadMore({ - limit: ADDTL_COMMENTS_ON_LOAD_MORE, + limit: parentId ? replyCount : ADDTL_COMMENTS_ON_LOAD_MORE, cursor, asset_id: assetId, parent_id: parentId, @@ -48,7 +48,7 @@ class LoadMore extends React.Component { diff --git a/client/coral-embed-stream/src/Stream.js b/client/coral-embed-stream/src/Stream.js index 37f26218a..66c19e0ab 100644 --- a/client/coral-embed-stream/src/Stream.js +++ b/client/coral-embed-stream/src/Stream.js @@ -1,6 +1,5 @@ import React, {PropTypes} from 'react'; import Comment from './Comment'; -import {NEW_COMMENT_COUNT_POLL_INTERVAL} from 'coral-framework/constants/comments'; class Stream extends React.Component { @@ -27,26 +26,6 @@ class Stream extends React.Component { this.state = {activeReplyBox: '', countPoll: null}; } - componentDidMount() { - const {asset, getCounts, updateCountCache} = this.props; - - updateCountCache(asset.id, asset.commentCount); - - // Note: Apollo's built-in polling doesn't work with fetchMore queries, so a - // setInterval is being used instead. - this.setState({ - countPoll: setInterval(() => getCounts({ - asset_id: asset.id, - limit: asset.comments.length, - sort: 'REVERSE_CHRONOLOGICAL' - }), NEW_COMMENT_COUNT_POLL_INTERVAL), - }); - } - - componentWillUnmount() { - clearInterval(this.state.countPoll); - } - render () { const { comments, diff --git a/client/coral-embed-stream/style/default.css b/client/coral-embed-stream/style/default.css index 599925758..6a6a2214d 100644 --- a/client/coral-embed-stream/style/default.css +++ b/client/coral-embed-stream/style/default.css @@ -16,6 +16,7 @@ body { font-size: 14px; margin: 0px; padding: 0px 0px 50px 0px; + height: auto !important; } .expandForSignin { @@ -306,10 +307,7 @@ button.comment__action-button[disabled], display: none; background-color: white; border: 1px solid black; - width: calc(100% - 15px); position: absolute; - top: 70px; - right: 0; padding: 5px; } diff --git a/client/coral-embed/src/index.js b/client/coral-embed/src/index.js index 8ebd403b8..e3350e0d3 100644 --- a/client/coral-embed/src/index.js +++ b/client/coral-embed/src/index.js @@ -74,6 +74,15 @@ function configurePymParent(pymParent, asset_url) { snackbar.style.opacity = 0; }); + // remove the permalink comment id from the hash + pymParent.onMessage('coral-view-all-comments', function () { + window.history.replaceState( + {}, + document.title, + location.origin + location.pathname + location.search + ); + }); + pymParent.onMessage('coral-alert', function (message) { const [type, text] = message.split('|'); snackbar.style.transform = 'translate(-50%, 20px)'; diff --git a/client/coral-framework/actions/asset.js b/client/coral-framework/actions/asset.js index 02e72ea98..50efa3dcb 100644 --- a/client/coral-framework/actions/asset.js +++ b/client/coral-framework/actions/asset.js @@ -1,6 +1,7 @@ import * as actions from '../constants/asset'; import coralApi from '../helpers/response'; import {addNotification} from '../actions/notification'; +import {pym} from 'coral-framework'; import I18n from 'coral-framework/modules/i18n/i18n'; import translations from './../translations'; @@ -49,3 +50,37 @@ export const updateOpenStatus = status => dispatch => { dispatch(updateOpenStream({closedAt: new Date().getTime()})); } }; + +function removeParam(key, sourceURL) { + let rtn = sourceURL.split('?')[0]; + let param; + let params_arr = []; + let queryString = (sourceURL.indexOf('?') !== -1) ? sourceURL.split('?')[1] : ''; + if (queryString !== '') { + params_arr = queryString.split('&'); + for (let i = params_arr.length - 1; i >= 0; i -= 1) { + param = params_arr[i].split('=')[0]; + if (param === key) { + params_arr.splice(i, 1); + } + } + rtn = `${rtn}?${params_arr.join('&')}`; + } + return rtn; +} + +export const viewAllComments = () => { + + // remove the comment_id url param + const modifiedUrl = removeParam('comment_id', location.href); + try { + + // "window" here refers to the embedded iframe + window.history.replaceState({}, document.title, modifiedUrl); + + // also change the parent url + pym.sendMessage('coral-view-all-comments'); + } catch (e) { /* not sure if we're worried about old browsers */ } + + return {type: actions.VIEW_ALL_COMMENTS}; +}; diff --git a/client/coral-framework/constants/asset.js b/client/coral-framework/constants/asset.js index 234095d9d..5547c5284 100644 --- a/client/coral-framework/constants/asset.js +++ b/client/coral-framework/constants/asset.js @@ -9,3 +9,5 @@ export const UPDATE_ASSET_SETTINGS_FAILURE = 'UPDATE_ASSET_SETTINGS_FAILURE'; export const OPEN_COMMENTS = 'OPEN_COMMENTS'; export const CLOSE_COMMENTS = 'CLOSE_COMMENTS'; export const UPDATE_COUNT_CACHE = 'UPDATE_COUNT_CACHE'; + +export const VIEW_ALL_COMMENTS = 'VIEW_ALL_COMMENTS'; diff --git a/client/coral-framework/graphql/queries/index.js b/client/coral-framework/graphql/queries/index.js index cdf2356d8..ec0dcc336 100644 --- a/client/coral-framework/graphql/queries/index.js +++ b/client/coral-framework/graphql/queries/index.js @@ -5,6 +5,7 @@ import GET_COUNTS from './getCounts.graphql'; import MY_COMMENT_HISTORY from './myCommentHistory.graphql'; import uniqBy from 'lodash/uniqBy'; import sortBy from 'lodash/sortBy'; +import isNil from 'lodash/isNil'; function getQueryVariable(variable) { let query = window.location.search.substring(1); @@ -20,6 +21,7 @@ function getQueryVariable(variable) { return null; } +// get the counts of the top-level comments export const getCounts = (data) => ({asset_id, limit, sort}) => { return data.fetchMore({ query: GET_COUNTS, @@ -41,23 +43,48 @@ export const getCounts = (data) => ({asset_id, limit, sort}) => { }); }; +// handle paginated requests for more Comments pertaining to the Asset export const loadMore = (data) => ({limit, cursor, parent_id = null, asset_id, sort}, newComments) => { return data.fetchMore({ query: LOAD_MORE, variables: { - limit, - cursor, - parent_id, - asset_id, - sort + limit, // how many comments are we returning + cursor, // the date of the first/last comment depending on the sort order + parent_id, // if null, we're loading more top-level comments, if not, we're loading more replies to a comment + asset_id, // the id of the asset we're currently on + sort // CHRONOLOGICAL or REVERSE_CHRONOLOGICAL }, updateQuery: (oldData, {fetchMoreResult:{data:{new_top_level_comments}}}) => { let updatedAsset; - if (parent_id) { + if (!isNil(oldData.comment)) { // loaded replies on a highlighted (permalinked) comment + + let comment = {}; + if (oldData.comment && oldData.comment.parent) { + + // put comments (replies) onto the oldData.comment.parent object + // the initial comment permalinked was a reply + const uniqReplies = uniqBy([...new_top_level_comments, ...oldData.comment.parent.replies], 'id'); + comment.parent = {...oldData.comment.parent, replies: sortBy(uniqReplies, 'created_at')}; + } else if (oldData.comment) { + + // put the comments (replies) directly onto oldData.comment + // the initial comment permalinked was a top-level comment + const uniqReplies = uniqBy([...new_top_level_comments, ...oldData.comment.replies], 'id'); + comment.replies = sortBy(uniqReplies, 'created_at'); + } + + updatedAsset = { + ...oldData, + comment: { + ...oldData.comment, + ...comment + } + }; + + } else if (parent_id) { // If loading more replies - // If loading more replies updatedAsset = { ...oldData, asset: { @@ -76,9 +103,8 @@ export const loadMore = (data) => ({limit, cursor, parent_id = null, asset_id, s }) } }; - } else { + } else { // If loading more top-level comments - // If loading more top-level comments updatedAsset = { ...oldData, asset: { @@ -94,8 +120,11 @@ export const loadMore = (data) => ({limit, cursor, parent_id = null, asset_id, s }); }; +// load the comment stream. export const queryStream = graphql(STREAM_QUERY, { options: () => { + + // where the query string is from the embeded iframe url let comment_id = getQueryVariable('comment_id'); let has_comment = comment_id != null; diff --git a/client/coral-framework/graphql/queries/streamQuery.graphql b/client/coral-framework/graphql/queries/streamQuery.graphql index 03af1de4c..c9f9d3d5b 100644 --- a/client/coral-framework/graphql/queries/streamQuery.graphql +++ b/client/coral-framework/graphql/queries/streamQuery.graphql @@ -1,10 +1,18 @@ #import "../fragments/commentView.graphql" query AssetQuery($asset_id: ID, $asset_url: String!, $comment_id: ID!, $has_comment: Boolean!) { + # the comment here is for loading one comment and it's children, probably after following a permalink + # $has_comment is derived from the comment_id query param in the iframe url, + # which is in turn pulled from the host page url comment(id: $comment_id) @include(if: $has_comment) { ...commentView + replyCount + replies { + ...commentView + } parent { ...commentView + replyCount replies { ...commentView } diff --git a/client/coral-framework/translations.json b/client/coral-framework/translations.json index f9038aba7..31c3e98ea 100644 --- a/client/coral-framework/translations.json +++ b/client/coral-framework/translations.json @@ -13,6 +13,7 @@ "error": "Usernames can contain letters, numbers and _ only" }, "viewMoreComments": "view more comments", + "showAllComments": "Show all comments", "viewReply": "view reply", "viewAllRepliesInitial": "view all {0} replies", "viewAllReplies": "view {0} replies", @@ -56,6 +57,7 @@ "newCount": "Ver {0} {1} más", "comment": "commentario", "comments": "commentarios", + "showAllComments": "Mostrar todos los comentarios", "error": { "emailNotVerified": "E-mail {0} no verificado.", "email": "No es un e-mail válido", diff --git a/client/coral-plugin-permalinks/PermalinkButton.js b/client/coral-plugin-permalinks/PermalinkButton.js index 4c6cbb571..464155b2e 100644 --- a/client/coral-plugin-permalinks/PermalinkButton.js +++ b/client/coral-plugin-permalinks/PermalinkButton.js @@ -23,6 +23,10 @@ class PermalinkButton extends React.Component { } toggle () { + + // I wish I could position this with a stylesheet, but top-level comments with + // nested replies throws everything off, as well as very long comments + this.popover.style.top = `${this.linkButton.offsetTop - 80}px`; this.setState({popoverOpen: !this.state.popoverOpen}); } @@ -48,11 +52,16 @@ class PermalinkButton extends React.Component { const {copySuccessful, copyFailure} = this.state; return (
- -
+
this.popover = ref} + className={`${name}-popover ${styles.container} ${this.state.popoverOpen ? 'active' : ''}`}> { + let plugin = {}; + + // This checks to see if the structure for this entry is an object: + // + // {"people": "^1.2.0"} + // + // otherwise it's checked whether it matches the local version: + // + // "people" + // + if (typeof p === 'object') { + plugin.name = Object.keys(p).find((name) => name !== null); + plugin.version = p[plugin.name]; + } else if (typeof p === 'string') { + plugin.name = p; + plugin.version = `file:./plugins/${plugin.name}`; + } else { + throw new Error(`plugins.json is malformed, refer to PLUGINS.md for formatting, expected a string or an object for a plugin entry, found a ${typeof p}`); + } + + // Get the path for the plugin. + plugin.path = pluginPath(plugin.name); + + return plugin; + }); +} + /** * Stores a reference to a section for a section of Plugins. */ class PluginSection { - constructor(plugin_names) { - this.plugins = plugin_names.map((plugin_name) => { - let plugin = require(`./plugins/${plugin_name}`); + constructor(plugins) { + this.plugins = itteratePlugins(plugins).map((plugin) => { + if (typeof plugin.path === 'undefined') { + throw new Error(`plugin '${plugin.name}' is not local and is not resolvable, plugin reconsiliation may be required`); + } - // Ensure we have a default plugin name, but allow the name to be - // overrided by the plugin. - plugin.name = plugin.name || plugin_name; + try { + plugin.module = require(plugin.path); + } catch (e) { + if (e && e.code && e.code === 'MODULE_NOT_FOUND' && isInternal(plugin.name)) { + console.error(new Error(`plugin '${plugin.name}' could not be loaded due to missing dependencies, plugin reconsiliation may be required`)); + throw e; + } + + console.error(new Error(`plugin '${plugin.name}' could not be required from '${plugin.path}': ${e.message}`)); + throw e; + } + + if (isInternal(plugin.name)) { + debug(`loading internal plugin '${plugin.name}' from '${plugin.path}'`); + } else { + debug(`loading external plugin '${plugin.name}' from '${plugin.path}'`); + } return plugin; }); @@ -38,8 +121,11 @@ class PluginSection { */ hook(hook) { return this.plugins - .filter((plugin) => hook in plugin) - .map((plugin) => ({plugin, [hook]: plugin[hook]})); + .filter(({module}) => hook in module) + .map((plugin) => ({ + plugin, + [hook]: plugin.module[hook] + })); } } @@ -78,4 +164,10 @@ class PluginManager { } } -module.exports = new PluginManager(plugins); +module.exports = { + plugins, + PluginManager, + isInternal, + pluginPath, + itteratePlugins +}; diff --git a/routes/api/auth/index.js b/routes/api/auth/index.js index fc3b87e51..12a3e8bbe 100644 --- a/routes/api/auth/index.js +++ b/routes/api/auth/index.js @@ -1,7 +1,6 @@ const express = require('express'); -const passport = require('../../../services/passport'); +const {passport, HandleAuthCallback, HandleAuthPopupCallback} = require('../../../services/passport'); const authorization = require('../../../middleware/authorization'); -const errors = require('../../../errors'); const router = express.Router(); @@ -34,53 +33,6 @@ router.delete('/', authorization.needed(), (req, res) => { // PASSPORT ROUTES //============================================================================== -/** - * This sends back the user data as JSON. - */ -const HandleAuthCallback = (req, res, next) => (err, user) => { - if (err) { - return next(err); - } - - if (!user) { - return next(errors.ErrNotAuthorized); - } - - // Perform the login of the user! - req.logIn(user, (err) => { - if (err) { - return next(err); - } - - // We logged in the user! Let's send back the user data and the CSRF token. - res.json({user}); - }); -}; - -/** - * Returns the response to the login attempt via a popup callback with some JS. - */ - -const HandleAuthPopupCallback = (req, res, next) => (err, user) => { - if (err) { - return res.render('auth-callback', {err: JSON.stringify(err), data: null}); - } - - if (!user) { - return res.render('auth-callback', {err: JSON.stringify(errors.ErrNotAuthorized), data: null}); - } - - // Perform the login of the user! - req.logIn(user, (err) => { - if (err) { - return res.render('auth-callback', {err: JSON.stringify(err), data: null}); - } - - // We logged in the user! Let's send back the user data. - res.render('auth-callback', {err: null, data: JSON.stringify(user)}); - }); -}; - /** * Local auth endpoint, will recieve a email and password */ diff --git a/routes/index.js b/routes/index.js index 58c8f13d7..9e38097f9 100644 --- a/routes/index.js +++ b/routes/index.js @@ -1,5 +1,7 @@ const express = require('express'); const path = require('path'); +const plugins = require('../services/plugins'); +const debug = require('debug')('talk:routes'); const router = express.Router(); @@ -22,4 +24,12 @@ if (process.env.NODE_ENV !== 'production') { }); } +// Inject server route plugins. +plugins.get('server', 'router').forEach((plugin) => { + debug(`added plugin '${plugin.plugin.name}'`); + + // Pass the root router to the plugin to mount it's routes. + plugin.router(router); +}); + module.exports = router; diff --git a/scripts/deploy.sh b/scripts/docker.sh old mode 100644 new mode 100755 similarity index 57% rename from scripts/deploy.sh rename to scripts/docker.sh index fd681fc7f..3aae8faf1 --- a/scripts/deploy.sh +++ b/scripts/docker.sh @@ -2,9 +2,7 @@ set -e -docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS - -# Sourced from https://segment.com/blog/ci-at-segment/ +# Inspired by https://segment.com/blog/ci-at-segment/ deploy_tag() { # Find our individual versions from the tags @@ -28,6 +26,7 @@ deploy_tag() { do echo "==> tagging $version" docker tag coralproject/talk:latest coralproject/talk:$version + docker tag coralproject/talk:latest-onbuild coralproject/talk:$version-onbuild done # Push each of the tags to docker hub, including latest @@ -35,21 +34,35 @@ deploy_tag() { do echo "==> pushing $version" docker push coralproject/talk:$version + docker push coralproject/talk:$version-onbuild done } deploy_latest() { echo "==> pushing latest" docker push coralproject/talk:latest + docker push coralproject/talk:latest-onbuild } -# build the repo -docker build -t coralproject/talk . +# build the repo, including the onbuild tagged versions. +docker build -t coralproject/talk:latest -f Dockerfile . +docker build -t coralproject/talk:latest-onbuild -f Dockerfile.onbuild . -# deploy based on the env -if [ -n "$CIRCLE_TAG" ] +if [ "$1" = "deploy" ] then - deploy_tag -else - deploy_latest -fi + + if [[ -n "$DOCKER_EMAIL" && -n "$DOCKER_USER" && -n "$DOCKER_PASS" ]] + then + + # Log the Docker Daemon in + docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS + fi + + # deploy based on the env + if [ -n "$CIRCLE_TAG" ] + then + deploy_tag + else + deploy_latest + fi +fi \ No newline at end of file diff --git a/services/passport.js b/services/passport.js index f1994392f..09ded01e8 100644 --- a/services/passport.js +++ b/services/passport.js @@ -7,6 +7,7 @@ const LocalStrategy = require('passport-local').Strategy; const FacebookStrategy = require('passport-facebook').Strategy; const errors = require('../errors'); const debug = require('debug')('talk:passport'); +const plugins = require('./plugins'); //============================================================================== // SESSION SERIALIZATION @@ -27,6 +28,52 @@ passport.deserializeUser((id, done) => { }); }); +/** + * This sends back the user data as JSON. + */ +const HandleAuthCallback = (req, res, next) => (err, user) => { + if (err) { + return next(err); + } + + if (!user) { + return next(errors.ErrNotAuthorized); + } + + // Perform the login of the user! + req.logIn(user, (err) => { + if (err) { + return next(err); + } + + // We logged in the user! Let's send back the user data and the CSRF token. + res.json({user}); + }); +}; + +/** + * Returns the response to the login attempt via a popup callback with some JS. + */ +const HandleAuthPopupCallback = (req, res, next) => (err, user) => { + if (err) { + return res.render('auth-callback', {err: JSON.stringify(err), data: null}); + } + + if (!user) { + return res.render('auth-callback', {err: JSON.stringify(errors.ErrNotAuthorized), data: null}); + } + + // Perform the login of the user! + req.logIn(user, (err) => { + if (err) { + return res.render('auth-callback', {err: JSON.stringify(err), data: null}); + } + + // We logged in the user! Let's send back the user data. + res.render('auth-callback', {err: null, data: JSON.stringify(user)}); + }); +}; + /** * Validates that a user is allowed to login. * @param {User} user the user to be validated @@ -329,4 +376,19 @@ if (process.env.TALK_FACEBOOK_APP_ID && process.env.TALK_FACEBOOK_APP_SECRET && console.error('Facebook cannot be enabled, missing one of TALK_FACEBOOK_APP_ID, TALK_FACEBOOK_APP_SECRET, TALK_ROOT_URL'); } -module.exports = passport; +// Inject server route plugins. +plugins.get('server', 'passport').forEach((plugin) => { + debug(`added plugin '${plugin.plugin.name}'`); + + // Pass the passport.js instance to the plugin to allow it to inject it's + // functionality. + plugin.passport(passport); +}); + +module.exports = { + passport, + ValidateUserLogin, + HandleFailedAttempt, + HandleAuthCallback, + HandleAuthPopupCallback +}; diff --git a/services/plugins.js b/services/plugins.js new file mode 100644 index 000000000..ced4bd75a --- /dev/null +++ b/services/plugins.js @@ -0,0 +1,3 @@ +const {plugins, PluginManager} = require('../plugins'); + +module.exports = new PluginManager(plugins); diff --git a/yarn.lock b/yarn.lock index 6c77f3e17..38977f5ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -194,6 +194,10 @@ apollo-client@^1.0.0, apollo-client@^1.0.0-rc.9: "@types/graphql" "^0.9.0" "@types/isomorphic-fetch" "0.0.33" +app-module-path@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/app-module-path/-/app-module-path-2.2.0.tgz#641aa55dfb7d6a6f0a8141c4b9c0aa50b6c24dd5" + "apparatus@>= 0.0.9": version "0.0.9" resolved "https://registry.yarnpkg.com/apparatus/-/apparatus-0.0.9.tgz#37dcd25834ad0b651076596291db823eeb1908bd" @@ -1715,7 +1719,7 @@ colors@1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" -colors@1.1.2, colors@~1.1.2: +colors@1.1.2, colors@^1.1.2, colors@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63" @@ -1963,6 +1967,14 @@ create-hmac@^1.1.0, create-hmac@^1.1.2: create-hash "^1.1.0" inherits "^2.0.1" +cross-spawn@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" + dependencies: + lru-cache "^4.0.1" + shebang-command "^1.2.0" + which "^1.2.9" + crypt@~0.0.1: version "0.0.2" resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" @@ -4865,6 +4877,13 @@ lru-cache@2, lru-cache@^2.5.0: version "2.7.3" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.7.3.tgz#6d4524e8b955f95d4f5b58851ce21dd72fb4e952" +lru-cache@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.0.2.tgz#1d17679c069cda5d040991a09dbc2c0db377e55e" + dependencies: + pseudomap "^1.0.1" + yallist "^2.0.0" + lru-cache@~2.6.5: version "2.6.5" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.6.5.tgz#e56d6354148ede8d7707b58d143220fd08df0fd5" @@ -5253,6 +5272,12 @@ node-dir@^0.1.10: dependencies: minimatch "^3.0.2" +node-emoji@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.5.1.tgz#fd918e412769bf8c448051238233840b2aff16a1" + dependencies: + string.prototype.codepointat "^0.2.0" + node-fetch@^1.0.1, node-fetch@^1.3.3, node-fetch@^1.6.3: version "1.6.3" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.6.3.tgz#dc234edd6489982d58e8f0db4f695029abcd8c04" @@ -6373,6 +6398,10 @@ ps-tree@^1.0.1: dependencies: event-stream "~3.3.0" +pseudomap@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" + public-encrypt@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.0.tgz#39f699f3a46560dd5ebacbca693caf7c65c18cc6" @@ -7014,9 +7043,11 @@ resolve-from@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-2.0.0.tgz#9480ab20e94ffa1d9e80a804c7ea147611966b57" -resolve@^1.1.6, resolve@^1.1.7: - version "1.2.0" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.2.0.tgz#9589c3f2f6149d1417a40becc1663db6ec6bc26c" +resolve@^1.1.6, resolve@^1.1.7, resolve@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.3.2.tgz#1f0442c9e0cbb8136e87b9305f932f46c7f28235" + dependencies: + path-parse "^1.0.5" restore-cursor@^1.0.1: version "1.0.1" @@ -7183,6 +7214,16 @@ sha.js@^2.3.6: dependencies: inherits "^2.0.1" +shebang-command@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" + dependencies: + shebang-regex "^1.0.0" + +shebang-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" + shelljs@0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.6.0.tgz#ce1ed837b4b0e55b5ec3dab84251ab9dbdc0c7ec" @@ -7451,6 +7492,10 @@ string-width@^2.0.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^3.0.0" +string.prototype.codepointat@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/string.prototype.codepointat/-/string.prototype.codepointat-0.2.0.tgz#6b26e9bd3afcaa7be3b4269b526de1b82000ac78" + string_decoder@^0.10.25, string_decoder@~0.10.x: version "0.10.31" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" @@ -8081,7 +8126,7 @@ which@1.1.1: dependencies: is-absolute "^0.1.7" -which@^1.1.1: +which@^1.1.1, which@^1.2.9: version "1.2.12" resolved "https://registry.yarnpkg.com/which/-/which-1.2.12.tgz#de67b5e450269f194909ef23ece4ebe416fa1192" dependencies: @@ -8183,6 +8228,10 @@ y18n@^3.2.0, y18n@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" +yallist@^2.0.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" + yargs-parser@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-2.4.1.tgz#85568de3cf150ff49fa51825f03a8c880ddcc5c4"