Load plugin from NPM (#371)

* Install plugins from npm

* A bit more

* Error handling and messaging
This commit is contained in:
Gregory Schier 2017-07-21 17:55:34 -07:00 committed by GitHub
parent 8523e67695
commit d3ce502c13
15 changed files with 231 additions and 146 deletions

View File

@ -5,4 +5,4 @@ coverage/**/*
node_modules/**/*
webpack/**/*
**/__fixtures__/prettify/*
flow-typed/**/*
flow-typed/

View File

@ -6,7 +6,7 @@
[include]
[libs]
flow-typed/.*
flow-typed/
[options]
esproposal.decorators=ignore

View File

@ -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';

View File

@ -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']);
});
});

View File

@ -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);
});
});

View File

@ -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<string | null> {
return new Promise(resolve => {
const saveOptions = {
title: 'Save File',
buttonLabel: 'Save',
defaultPath: options.defaultPath
};
electron.remote.dialog.showSaveDialog(saveOptions, filename => {
resolve(filename || null);
});
});
}
}
};

View File

@ -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<ResponseHeader>
}
export function init (
@ -42,6 +44,20 @@ export function init (
},
getBody (): Buffer | null {
return bodyBuffer;
},
getHeader (name: string): string | Array<string> | 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;
}
}
};

View File

@ -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<Array<Plugin>
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<Array<Plugin>
return plugins;
}
export async function createPlugin (displayName: string): Promise<void> {
// 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<Array<TemplateTag>> {
let extensions = [];
for (const plugin of await getPlugins()) {

45
app/plugins/install.js Normal file
View File

@ -0,0 +1,45 @@
// @flow
import childProcess from 'child_process';
import {PLUGIN_PATH} from '../common/constants';
export default async function (moduleName: string): Promise<void> {
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<void> {
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`));
}
}
);
});
}

View File

@ -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();

View File

@ -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();

View File

@ -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 (
<Tooltip {...props} className={className} message={children}>
<i className="fa fa-question-circle"/>
<i className={'fa ' + (info ? 'fa-info-circle' : 'fa-question-circle')}/>
</Tooltip>
);
}
}
HelpTooltip.propTypes = {
children: PropTypes.node.isRequired,
// Optional
position: PropTypes.string,
className: PropTypes.string
};
export default HelpTooltip;

View File

@ -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<Plugin>,
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<void> {
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 (
<div>
<p className="notice info no-margin-top">
Plugins are experimental and compatibility may break in future releases
Plugins is still an experimental feature. Please
{' '}
<Link href="https://insomnia.rest/documentation/support-and-feedback/">
Submit Feedback
</Link> if you have any.
</p>
<table className="table--fancy table--striped margin-top margin-bottom">
<thead>
@ -85,7 +95,12 @@ class Plugins extends React.PureComponent {
<tbody>
{plugins.map(plugin => (
<tr key={plugin.name}>
<td>{plugin.name}</td>
<td>
{plugin.name}
{plugin.description && (
<HelpTooltip info className="space-left">{plugin.description}</HelpTooltip>
)}
</td>
<td>{plugin.version}</td>
<td className="no-wrap" style={{width: '10rem'}}>
<CopyButton className="btn btn--outlined btn--super-duper-compact"
@ -104,29 +119,40 @@ class Plugins extends React.PureComponent {
))}
</tbody>
</table>
<p className="text-right">
</p>
<div className="form-row">
<div className="form-control form-control--outlined">
<input onChange={this._handleAddNpmPluginChange}
type="text"
placeholder="insomnia-foo-bar"/>
</div>
<div className="form-control width-auto">
<button className="btn btn--clicky" onClick={this._handleAddFromNpm}>
Add From NPM
{error && (
<div className="notice error text-left margin-bottom">
<button className="pull-right icon" onClick={this._handleClearError}>
<i className="fa fa-times"/>
</button>
{error}
</div>
<div className="form-control width-auto">
<button className="btn btn--clicky" onClick={this._handleRefreshPlugins}>
Reload
</button>
</div>
<div className="form-control width-auto">
<button className="btn btn--clicky" onClick={this._handleGeneratePlugin}>
New Plugin
</button>
)}
<form onSubmit={this._handleAddFromNpm}>
<div className="form-row">
<div className="form-control form-control--outlined">
<input onChange={this._handleAddNpmPluginChange}
disabled={loading}
type="text"
placeholder="npm-package-name"/>
</div>
<div className="form-control width-auto">
<button className="btn btn--clicky" disabled={loading}>
{loading && <i className="fa fa-refresh fa-spin space-right"/>}
Install Plugin
</button>
</div>
</div>
</form>
<hr/>
<div className="text-right">
<button type="button" className="btn btn--clicky"
onClick={this._handleRefreshPlugins}>
Reload Plugin List
</button>
</div>
</div>
);

View File

@ -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%;
}

View File

@ -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",