mirror of
https://github.com/Kong/insomnia
synced 2024-11-07 22:30:15 +00:00
Load plugin from NPM (#371)
* Install plugins from npm * A bit more * Error handling and messaging
This commit is contained in:
parent
8523e67695
commit
d3ce502c13
@ -5,4 +5,4 @@ coverage/**/*
|
||||
node_modules/**/*
|
||||
webpack/**/*
|
||||
**/__fixtures__/prettify/*
|
||||
flow-typed/**/*
|
||||
flow-typed/
|
@ -6,7 +6,7 @@
|
||||
[include]
|
||||
|
||||
[libs]
|
||||
flow-typed/.*
|
||||
flow-typed/
|
||||
|
||||
[options]
|
||||
esproposal.decorators=ignore
|
||||
|
@ -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';
|
||||
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -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
45
app/plugins/install.js
Normal 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`));
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
@ -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();
|
@ -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();
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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%;
|
||||
}
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user