diff --git a/.eslintignore b/.eslintignore index ea1d9a6e5..a8730e9c2 100644 --- a/.eslintignore +++ b/.eslintignore @@ -5,4 +5,4 @@ coverage/**/* node_modules/**/* webpack/**/* **/__fixtures__/prettify/* -flow-typed/**/* \ No newline at end of file +flow-typed/ \ No newline at end of file diff --git a/.flowconfig b/.flowconfig index c25e6ac2f..c34c95b2b 100644 --- a/.flowconfig +++ b/.flowconfig @@ -6,7 +6,7 @@ [include] [libs] -flow-typed/.* +flow-typed/ [options] esproposal.decorators=ignore diff --git a/app/common/constants.js b/app/common/constants.js index d7a229c59..93b870f15 100644 --- a/app/common/constants.js +++ b/app/common/constants.js @@ -57,9 +57,7 @@ export const STATUS_CODE_PLUGIN_ERROR = -222; export const LARGE_RESPONSE_MB = 5; export const FLEXIBLE_URL_REGEX = /^(http|https):\/\/[0-9a-zA-Z\-_.]+[/\w.\-+=:\][@%^*&!#?;]*/; export const CHECK_FOR_UPDATES_INTERVAL = 1000 * 60 * 60 * 3; // 3 hours -export const PLUGIN_PATHS = [ - path.join((electron.remote || electron).app.getPath('userData'), 'plugins') -]; +export const PLUGIN_PATH = path.join((electron.remote || electron).app.getPath('userData'), 'plugins'); // Hotkeys export const MOD_SYM = isMac() ? '⌘' : 'ctrl'; diff --git a/app/plugins/context/__tests__/app.test.js b/app/plugins/context/__tests__/app.test.js index 89ece8d08..12d8e50f8 100644 --- a/app/plugins/context/__tests__/app.test.js +++ b/app/plugins/context/__tests__/app.test.js @@ -14,7 +14,7 @@ describe('init()', () => { it('initializes correctly', () => { const result = plugin.init({name: PLUGIN}); expect(Object.keys(result)).toEqual(['app']); - expect(Object.keys(result.app)).toEqual(['alert', 'getPath']); + expect(Object.keys(result.app)).toEqual(['alert', 'getPath', 'showSaveDialog']); }); }); diff --git a/app/plugins/context/__tests__/response.test.js b/app/plugins/context/__tests__/response.test.js index ee2979fe4..fbbfd350f 100644 --- a/app/plugins/context/__tests__/response.test.js +++ b/app/plugins/context/__tests__/response.test.js @@ -19,7 +19,9 @@ describe('init()', () => { 'getStatusMessage', 'getBytesRead', 'getTime', - 'getBody' + 'getBody', + 'getHeader', + 'hasHeader' ]); }); @@ -56,4 +58,20 @@ describe('response.*', () => { expect(result.response.getTime()).toBe(0); expect(result.response.getBody()).toBeNull(); }); + + it('works for getting headers', () => { + const response = { + headers: [ + {name: 'content-type', value: 'application/json'}, + {name: 'set-cookie', value: 'foo=bar'}, + {name: 'set-cookie', value: 'baz=qux'} + ] + }; + const result = plugin.init(PLUGIN, response); + expect(result.response.getHeader('Does-Not-Exist')).toBeNull(); + expect(result.response.getHeader('CONTENT-TYPE')).toBe('application/json'); + expect(result.response.getHeader('set-cookie')).toEqual(['foo=bar', 'baz=qux']); + expect(result.response.hasHeader('foo')).toBe(false); + expect(result.response.hasHeader('ConTent-Type')).toBe(true); + }); }); diff --git a/app/plugins/context/app.js b/app/plugins/context/app.js index 42e1bc9cb..eb674b230 100644 --- a/app/plugins/context/app.js +++ b/app/plugins/context/app.js @@ -16,6 +16,19 @@ export function init (plugin: Plugin): {app: Object} { default: throw new Error(`Unknown path name ${name}`); } + }, + async showSaveDialog (options: {defaultPath?: string} = {}): Promise { + return new Promise(resolve => { + const saveOptions = { + title: 'Save File', + buttonLabel: 'Save', + defaultPath: options.defaultPath + }; + + electron.remote.dialog.showSaveDialog(saveOptions, filename => { + resolve(filename || null); + }); + }); } } }; diff --git a/app/plugins/context/response.js b/app/plugins/context/response.js index bedf594f3..2bf3f4e8e 100644 --- a/app/plugins/context/response.js +++ b/app/plugins/context/response.js @@ -1,5 +1,6 @@ // @flow import type {Plugin} from '../'; +import type {ResponseHeader} from '../../models/response'; type MaybeResponse = { parentId?: string, @@ -7,6 +8,7 @@ type MaybeResponse = { statusMessage?: string, bytesRead?: number, elapsedTime?: number, + headers?: Array } export function init ( @@ -42,6 +44,20 @@ export function init ( }, getBody (): Buffer | null { return bodyBuffer; + }, + getHeader (name: string): string | Array | null { + const headers = response.headers || []; + const matchedHeaders = headers.filter(h => h.name.toLowerCase() === name.toLowerCase()); + if (matchedHeaders.length > 1) { + return matchedHeaders.map(h => h.value); + } else if (matchedHeaders.length === 1) { + return matchedHeaders[0].value; + } else { + return null; + } + }, + hasHeader (name: string): boolean { + return this.getHeader(name) !== null; } } }; diff --git a/app/plugins/index.js b/app/plugins/index.js index a24015a48..62f61a54d 100644 --- a/app/plugins/index.js +++ b/app/plugins/index.js @@ -3,14 +3,12 @@ import mkdirp from 'mkdirp'; import * as models from '../models'; import fs from 'fs'; import path from 'path'; -import {PLUGIN_PATHS} from '../common/constants'; -import {render} from '../templating'; -import skeletonPackageJson from './skeleton/package.json.js'; -import skeletonPluginJs from './skeleton/plugin.js.js'; +import {PLUGIN_PATH} from '../common/constants'; import {resolveHomePath} from '../common/misc'; export type Plugin = { name: string, + description: string, version: string, directory: string, module: * @@ -46,38 +44,59 @@ export async function getPlugins (force: boolean = false): Promise if (!plugins) { const settings = await models.settings.getOrCreate(); const extraPaths = settings.pluginPath.split(':').filter(p => p).map(resolveHomePath); - const allPaths = [...PLUGIN_PATHS, ...extraPaths]; // Make sure the default directories exist - for (const p of PLUGIN_PATHS) { - mkdirp.sync(p); - } + mkdirp.sync(PLUGIN_PATH); + + // Also look in node_modules folder in each directory + const basePaths = [PLUGIN_PATH, ...extraPaths]; + const extendedPaths = basePaths.map(p => path.join(p, 'node_modules')); + const allPaths = [...basePaths, ...extendedPaths]; plugins = []; for (const p of allPaths) { - for (const dir of fs.readdirSync(p)) { - if (dir.indexOf('.') === 0) { + if (!fs.existsSync(p)) { + continue; + } + + for (const filename of fs.readdirSync(p)) { + const modulePath = path.join(p, filename); + const packageJSONPath = path.join(modulePath, 'package.json'); + + // Only read directories + if (!fs.statSync(modulePath).isDirectory()) { continue; } - const modulePath = path.join(p, dir); - const packageJSONPath = path.join(modulePath, 'package.json'); + // Is it a Node module? + if (!fs.readdirSync(modulePath).includes('package.json')) { + continue; + } // Use global.require() instead of require() because Webpack wraps require() delete global.require.cache[global.require.resolve(packageJSONPath)]; const pluginJson = global.require(packageJSONPath); + // Not an Insomnia plugin because it doesn't have the package.json['insomnia'] + if (!pluginJson.hasOwnProperty('insomnia')) { + continue; + } + // Delete require cache entry and re-require delete global.require.cache[global.require.resolve(modulePath)]; const module = global.require(modulePath); - plugins.push({ - name: pluginJson.name, + const pluginMeta = pluginJson.insomnia || {}; + + const plugin: Plugin = { + name: pluginMeta.name || pluginJson.name, + description: pluginMeta.description || '', version: pluginJson.version || '0.0.0', directory: modulePath, module - }); + }; + plugins.push(plugin); // console.log(`[plugin] Loaded ${modulePath}`); } } @@ -86,18 +105,6 @@ export async function getPlugins (force: boolean = false): Promise return plugins; } -export async function createPlugin (displayName: string): Promise { - // Create root plugin dir - const name = displayName.replace(/\s/g, '-').toLowerCase(); - const dir = path.join(PLUGIN_PATHS[0], name); - mkdirp.sync(dir); - - fs.writeFileSync(path.join(dir, 'plugin.js'), skeletonPluginJs); - - const renderedPackageJson = await render(skeletonPackageJson, {context: {name, displayName}}); - fs.writeFileSync(path.join(dir, 'package.json'), renderedPackageJson); -} - export async function getTemplateTags (): Promise> { let extensions = []; for (const plugin of await getPlugins()) { diff --git a/app/plugins/install.js b/app/plugins/install.js new file mode 100644 index 000000000..ce42c78ae --- /dev/null +++ b/app/plugins/install.js @@ -0,0 +1,45 @@ +// @flow +import childProcess from 'child_process'; +import {PLUGIN_PATH} from '../common/constants'; + +export default async function (moduleName: string): Promise { + return new Promise(async (resolve, reject) => { + try { + await _isInsomniaPlugin(moduleName); + } catch (err) { + reject(err); + return; + } + + childProcess.exec( + `npm install --prefix '${PLUGIN_PATH}' ${moduleName}`, + (err, stdout, stderr) => { + if (err) { + reject(new Error(stderr)); + } else { + resolve(); + } + } + ); + }); +} + +async function _isInsomniaPlugin (moduleName: string): Promise { + return new Promise((resolve, reject) => { + childProcess.exec( + `npm show ${moduleName} insomnia`, + (err, stdout, stderr) => { + if (err && stderr.includes('E404')) { + reject(new Error(`${moduleName} not found on npm`)); + return; + } + + if (stdout) { + resolve(); + } else { + reject(new Error(`"insomnia" attribute missing in ${moduleName}'s package.json`)); + } + } + ); + }); +} diff --git a/app/plugins/skeleton/package.json.js b/app/plugins/skeleton/package.json.js deleted file mode 100644 index 2fa3a8dbd..000000000 --- a/app/plugins/skeleton/package.json.js +++ /dev/null @@ -1,15 +0,0 @@ -module.exports = ` -{ - "name": "{{ name }}", - "displayName": "{{ displayName }}", - "version": "{{ version | default('0.0.1') }}", - "private": true, - "main": "plugin.js", - "description": "{{ description | default('A plugin for Insomnia') }}", - "dependencies": {}, - "devEngines": { - "node": "7.4", - "npm": "4.x | 5.x" - } -} -`.trim(); diff --git a/app/plugins/skeleton/plugin.js.js b/app/plugins/skeleton/plugin.js.js deleted file mode 100644 index 581563cde..000000000 --- a/app/plugins/skeleton/plugin.js.js +++ /dev/null @@ -1,39 +0,0 @@ -module.exports = ` -module.exports.preRequestHooks = [ - context => { - context.addHeader('foo', 'bar'); - } -]; - -module.exports.templateTags = [{ - name: 'hello', - displayName: 'Say Hello', - description: 'says hello to someone', - args: [{ - displayName: 'Mood', - type: 'enum', - options: [ - {displayName: 'Calm', value: 'calm'}, - {displayName: 'Talkative', value: 'talkative'}, - {displayName: 'Ecstatic', value: 'ecstatic'}, - ] - }, { - displayName: 'Name', - description: 'who to say "hi" to', - type: 'string', - defaultValue: 'Karen' - }], - async run (context, mood, name) { - switch (mood) { - case 'calm': - return \`Hi \${name}.\`; - case 'talkative': - return \`Oh, hello \${name}, it's so nice to see you! How have you been?\`; - case 'ecstatic': - return \`OH MY GOD, HI \${name.toUpperCase()}!\`; - default: - return \`Hello \${name.toUpperCase()}.\`; - } - } -}]; -`.trim(); diff --git a/app/ui/components/help-tooltip.js b/app/ui/components/help-tooltip.js index f6791f7fe..9677b28a2 100644 --- a/app/ui/components/help-tooltip.js +++ b/app/ui/components/help-tooltip.js @@ -1,25 +1,27 @@ -import React, {PropTypes, PureComponent} from 'react'; +// @flow +import React from 'react'; import autobind from 'autobind-decorator'; import Tooltip from './tooltip'; @autobind -class HelpTooltip extends PureComponent { +class HelpTooltip extends React.PureComponent { + props: { + children: React.Children, + + // Optional + position?: string, + className?: string, + info?: boolean + }; + render () { - const {children, className, ...props} = this.props; + const {children, className, info, ...props} = this.props; return ( - + ); } } -HelpTooltip.propTypes = { - children: PropTypes.node.isRequired, - - // Optional - position: PropTypes.string, - className: PropTypes.string -}; - export default HelpTooltip; diff --git a/app/ui/components/settings/plugins.js b/app/ui/components/settings/plugins.js index 2b9d67c6b..f6db959d2 100644 --- a/app/ui/components/settings/plugins.js +++ b/app/ui/components/settings/plugins.js @@ -1,58 +1,64 @@ // @flow import type {Plugin} from '../../../plugins/index'; -import {createPlugin, getPlugins} from '../../../plugins/index'; +import {getPlugins} from '../../../plugins/index'; import React from 'react'; import autobind from 'autobind-decorator'; import * as electron from 'electron'; import Button from '../base/button'; import CopyButton from '../base/copy-button'; -import {showPrompt} from '../modals/index'; import {trackEvent} from '../../../analytics/index'; import {reload} from '../../../templating/index'; +import installPlugin from '../../../plugins/install'; +import HelpTooltip from '../help-tooltip'; +import Link from '../base/link'; @autobind class Plugins extends React.PureComponent { state: { plugins: Array, - npmPluginValue: string + npmPluginValue: string, + error: string, + loading: boolean }; constructor (props: any) { super(props); this.state = { plugins: [], - npmPluginValue: '' + npmPluginValue: '', + error: '', + loading: false }; } + _handleClearError () { + this.setState({error: ''}); + } + _handleAddNpmPluginChange (e: Event & {target: HTMLButtonElement}) { this.setState({npmPluginValue: e.target.value}); } - _handleAddFromNpm () { - console.log('ADD FROM NPM', this.state.npmPluginValue); + async _handleAddFromNpm (e: Event): Promise { + e.preventDefault(); + + this.setState({loading: true}); + + const newState = {loading: false, error: ''}; + try { + await installPlugin(this.state.npmPluginValue); + await this._handleRefreshPlugins(); + } catch (err) { + newState.error = err.message; + } + + this.setState(newState); } _handleOpenDirectory (directory: string) { electron.remote.shell.showItemInFolder(directory); } - _handleGeneratePlugin () { - showPrompt({ - title: 'Plugin Name', - defaultValue: 'My Plugin', - submitName: 'Generate Plugin', - selectText: true, - placeholder: 'My Cool Plugin', - label: 'Plugin Name', - onComplete: async name => { - await createPlugin(name); - await this._handleRefreshPlugins(); - trackEvent('Plugins', 'Generate'); - } - }); - } - async _handleRefreshPlugins () { // Get and reload plugins const plugins = await getPlugins(true); @@ -67,12 +73,16 @@ class Plugins extends React.PureComponent { } render () { - const {plugins} = this.state; + const {plugins, error, loading} = this.state; return (

- Plugins are experimental and compatibility may break in future releases + Plugins is still an experimental feature. Please + {' '} + + Submit Feedback + if you have any.

@@ -85,7 +95,12 @@ class Plugins extends React.PureComponent { {plugins.map(plugin => ( - +
{plugin.name} + {plugin.name} + {plugin.description && ( + {plugin.description} + )} + {plugin.version}
-

-

-
-
- -
-
- + {error}
-
- -
-
- + )} + +
+
+
+ +
+
+ +
+ + +
+ +
+
); diff --git a/app/ui/css/layout/base.less b/app/ui/css/layout/base.less index 1bec49adc..4151b03c8 100644 --- a/app/ui/css/layout/base.less +++ b/app/ui/css/layout/base.less @@ -140,6 +140,11 @@ p.notice { z-index: 0; overflow: auto; + .pre { + line-height: 1.3em; + white-space: pre; + } + p { min-width: 8rem; padding: 0; @@ -217,6 +222,15 @@ p.notice { font-size: 0.75em; } +blockquote { + border-left: 0.4em solid var(--hl-md); + padding: @padding-xxs @padding-md; + + p { + margin: @padding-xs 0; + } +} + .img--circle { border-radius: 50%; } diff --git a/package.json b/package.json index cf6a0ab6b..1b12d696a 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "test:coverage": "cross-env NODE_ENV=test jest --coverage --silent && open ./coverage/lcov-report/index.html", "test:watch": "cross-env NODE_ENV=test jest --silent --watch", "test:flow": "flow check", - "test": "npm run test:flow && npm run test:lint && cross-env NODE_ENV=test jest --silent", + "test": "npm run test:flow && npm run test:lint && cross-env NODE_ENV=test jest --silent --maxWorkers 1", "start-hot": "npm run build-main && cross-env HOT=1 INSOMNIA_ENV=development electron app", "build-main": "cross-env NODE_ENV=development webpack --config ./webpack/webpack.config.electron.babel.js", "hot-server": "webpack-dev-server --config ./webpack/webpack.config.development.babel.js",