Install plugin from package file

Summary:
Adding a way to install plugins directly from package files. This is required for testing after packaging format changes.

Stage 3: Implemented installation of plugins directly from package file.

Reviewed By: jknoxville

Differential Revision: D19765619

fbshipit-source-id: 57f36c87d3cf5d4e1c9a1f8f9f9f32b14a18bc8b
This commit is contained in:
Anton Nikolaev
2020-02-06 09:41:44 -08:00
committed by Facebook Github Bot
parent 984cdbfb67
commit b9e7f5d6d1
8 changed files with 457 additions and 81 deletions

View File

@@ -25,7 +25,6 @@ import {
LoadingIndicator,
Tooltip,
} from 'flipper';
import {default as FileSelector} from '../../ui/components/FileSelector';
import React, {useCallback, useState, useMemo, useEffect} from 'react';
import {List} from 'immutable';
import {SearchIndex} from 'algoliasearch';
@@ -42,14 +41,15 @@ import {
import {
PLUGIN_DIR,
readInstalledPlugins,
providePluginManager,
provideSearchIndex,
findPluginUpdates as _findPluginUpdates,
UpdateResult,
installPluginFromNpm,
} from '../../utils/pluginManager';
import {State as AppState} from '../../reducers';
import {connect} from 'react-redux';
import {Dispatch, Action} from 'redux';
import PluginPackageInstaller from './PluginPackageInstaller';
const TAG = 'PluginInstaller';
@@ -145,15 +145,18 @@ export function annotatePluginsWithUpdates(
const PluginInstaller = function props(props: Props) {
const [restartRequired, setRestartRequired] = useState(false);
const [query, setQuery] = useState('');
const [_localPackagePath, setLocalPackagePath] = useState('');
const [isLocalPackagePathValid, setIsLocalPackagePathValud] = useState(false);
const onInstall = useCallback(async () => {
props.refreshInstalledPlugins();
setRestartRequired(true);
}, []);
const rows = useNPMSearch(
setRestartRequired,
query,
setQuery,
props.searchIndexFactory,
props.installedPlugins,
props.refreshInstalledPlugins,
onInstall,
props.findPluginUpdates,
);
const restartApp = useCallback(() => {
@@ -190,26 +193,7 @@ const PluginInstaller = function props(props: Props) {
rows={rows}
/>
</Container>
<Toolbar>
<FileSelector
placeholderText="Specify path to a Flipper package or just drag and drop it here..."
onPathChanged={e => {
setLocalPackagePath(e.path);
setIsLocalPackagePathValud(e.isValid);
}}
/>
<Button
compact
type="primary"
disabled={!isLocalPackagePathValid}
title={
isLocalPackagePathValid
? 'Click to install the specified plugin package'
: 'Cannot install plugin package by the specified path'
}>
Install
</Button>
</Toolbar>
<PluginPackageInstaller onInstall={onInstall} />
</>
);
};
@@ -267,35 +251,8 @@ function InstallButton(props: {
props.name,
);
setAction({kind: 'Waiting'});
await fs.ensureDir(PLUGIN_DIR);
// create empty watchman config (required by metro's file watcher)
await fs.writeFile(path.join(PLUGIN_DIR, '.watchmanconfig'), '{}');
// Clean up existing destination files.
await fs.remove(path.join(PLUGIN_DIR, props.name));
const pluginManager = providePluginManager();
// install the plugin and all it's dependencies into node_modules
pluginManager.options.pluginsPath = path.join(
PLUGIN_DIR,
props.name,
'node_modules',
);
await pluginManager.install(props.name);
// move the plugin itself out of the node_modules folder
const pluginDir = path.join(
PLUGIN_DIR,
props.name,
'node_modules',
props.name,
);
const pluginFiles = await fs.readdir(pluginDir);
await Promise.all(
pluginFiles.map(f =>
fs.move(path.join(pluginDir, f), path.join(pluginDir, '..', '..', f)),
),
);
await installPluginFromNpm(props.name);
props.onInstall();
setAction({kind: 'Remove'});
@@ -376,12 +333,11 @@ function InstallButton(props: {
}
function useNPMSearch(
setRestartRequired: (restart: boolean) => void,
query: string,
setQuery: (query: string) => void,
searchClientFactory: () => SearchIndex,
installedPlugins: Map<string, PluginDefinition>,
refreshInstalledPlugins: () => void,
onInstall: () => Promise<void>,
findPluginUpdates: (
currentPlugins: PluginMap,
) => Promise<[string, UpdateResult][]>,
@@ -392,11 +348,6 @@ function useNPMSearch(
reportUsage(`${TAG}:open`);
}, []);
const onInstall = useCallback(async () => {
refreshInstalledPlugins();
setRestartRequired(true);
}, []);
const createRow = useCallback(
(h: UpdatablePluginDefinition) => ({
key: h.name,

View File

@@ -0,0 +1,110 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
Toolbar,
Button,
FlexRow,
Tooltip,
Glyph,
colors,
LoadingIndicator,
} from 'flipper';
import styled from '@emotion/styled';
import {default as FileSelector} from '../../ui/components/FileSelector';
import React, {useState} from 'react';
import {installPluginFromFile} from '../../utils/pluginManager';
const CenteredGlyph = styled(Glyph)({
margin: 'auto',
marginLeft: 2,
});
const Spinner = styled(LoadingIndicator)({
margin: 'auto',
marginLeft: 16,
});
const ButtonContainer = styled(FlexRow)({
width: 76,
});
const ErrorGlyphContainer = styled(FlexRow)({
width: 20,
});
export default function PluginPackageInstaller({
onInstall,
}: {
onInstall: () => Promise<void>;
}) {
const [path, setPath] = useState('');
const [isPathValid, setIsPathValid] = useState(false);
const [error, setError] = useState<Error>();
const [inProgress, setInProgress] = useState(false);
const onClick = async () => {
setError(undefined);
setInProgress(true);
try {
await installPluginFromFile(path);
await onInstall();
} catch (e) {
setError(e);
console.error(e);
} finally {
setInProgress(false);
}
};
const button = inProgress ? (
<Spinner size={16} />
) : (
<Button
compact
type="primary"
disabled={!isPathValid}
title={
isPathValid
? 'Click to install the specified plugin package'
: 'Cannot install plugin package by the specified path'
}
onClick={onClick}>
Install
</Button>
);
return (
<Toolbar>
<FileSelector
placeholderText="Specify path to a Flipper package or just drag and drop it here..."
onPathChanged={e => {
setPath(e.path);
setIsPathValid(e.isValid);
setError(undefined);
}}
/>
<ButtonContainer>
<FlexRow>
{button}
<ErrorGlyphContainer>
{error && (
<Tooltip
options={{position: 'toRight'}}
title={`Something went wrong: ${error}`}>
<CenteredGlyph
color={colors.orange}
size={16}
name="caution-triangle"
/>
</Tooltip>
)}
</ErrorGlyphContainer>
</FlexRow>
</ButtonContainer>
</Toolbar>
);
}

View File

@@ -9,7 +9,6 @@
import React, {useState} from 'react';
import FlexRow from './FlexRow';
import FlexColumn from './FlexColumn';
import Glyph from './Glyph';
import Input from './Input';
import electron from 'electron';
@@ -17,15 +16,20 @@ import styled from '@emotion/styled';
import {colors} from './colors';
import Electron from 'electron';
import fs from 'fs';
import {Tooltip} from '..';
const CenteredGlyph = styled(Glyph)({
flexGrow: 0,
margin: 'auto',
marginLeft: 10,
marginLeft: 4,
});
const Container = styled(FlexRow)({
width: '100%',
marginRight: 4,
});
const GlyphContainer = styled(FlexRow)({
width: 20,
});
const FileInputBox = styled(Input)<{isValid: boolean}>(({isValid}) => ({
@@ -49,7 +53,7 @@ export interface Props {
}
const defaultProps: Props = {
onPathChanged: () => {},
onPathChanged: _ => {},
placeholderText: '',
defaultPath: '/',
showHiddenFiles: false,
@@ -97,7 +101,7 @@ export default function FileSelector({
onChange(e.target.value);
}}
/>
<FlexColumn
<GlyphContainer
onClick={() =>
electron.remote.dialog
.showOpenDialog(options)
@@ -112,14 +116,18 @@ export default function FileSelector({
variant="outline"
title="Open file selection dialog"
/>
</FlexColumn>
{isValid ? null : (
<CenteredGlyph
name="caution-triangle"
color={colors.yellow}
title="Path is invalid"
/>
)}
</GlyphContainer>
<GlyphContainer>
{isValid ? null : (
<Tooltip title="The specified path is invalid or such file does not exist">
<CenteredGlyph
name="caution-triangle"
color={colors.yellow}
size={16}
/>
</Tooltip>
)}
</GlyphContainer>
</Container>
);
}

View File

@@ -15,10 +15,15 @@ import {PluginManager as PM} from 'live-plugin-manager';
import {default as algoliasearch, SearchIndex} from 'algoliasearch';
import NpmApi, {Package} from 'npm-api';
import semver from 'semver';
import decompress from 'decompress';
import decompressTargz from 'decompress-targz';
import tmp from 'tmp';
const ALGOLIA_APPLICATION_ID = 'OFCNCOG2CU';
const ALGOLIA_API_KEY = 'f54e21fa3a2a0160595bb058179bfb1e';
export const PLUGIN_DIR = path.join(homedir(), '.flipper', 'thirdparty');
// TODO(T57014856): The use should be constrained to just this module when the
// refactor is done.
export function providePluginManager(): PM {
@@ -27,13 +32,77 @@ export function providePluginManager(): PM {
});
}
async function installPlugin(
name: string,
installFn: (pluginManager: PM) => Promise<void>,
) {
await fs.ensureDir(PLUGIN_DIR);
// create empty watchman config (required by metro's file watcher)
await fs.writeFile(path.join(PLUGIN_DIR, '.watchmanconfig'), '{}');
const destinationDir = path.join(PLUGIN_DIR, name);
// Clean up existing destination files.
await fs.remove(destinationDir);
const pluginManager = providePluginManager();
// install the plugin and all it's dependencies into node_modules
pluginManager.options.pluginsPath = path.join(destinationDir, 'node_modules');
await installFn(pluginManager);
// move the plugin itself out of the node_modules folder
const pluginDir = path.join(PLUGIN_DIR, name, 'node_modules', name);
const pluginFiles = await fs.readdir(pluginDir);
await Promise.all(
pluginFiles.map(f =>
fs.move(path.join(pluginDir, f), path.join(pluginDir, '..', '..', f)),
),
);
}
export async function installPluginFromNpm(name: string) {
await installPlugin(name, pluginManager =>
pluginManager.install(name).then(() => {}),
);
}
export async function installPluginFromFile(packagePath: string) {
const tmpDir = tmp.dirSync().name;
try {
const files = await decompress(packagePath, tmpDir, {
plugins: [decompressTargz()],
});
if (!files.length) {
throw new Error('The package is not in tar.gz format or is empty');
}
const packageDir = path.join(tmpDir, 'package');
if (!(await fs.pathExists(packageDir))) {
throw new Error(
'Package format is invalid: directory "package" not found',
);
}
const packageJsonPath = path.join(packageDir, 'package.json');
if (!(await fs.pathExists(packageJsonPath))) {
throw new Error(
'Package format is invalid: file "package/package.json" not found',
);
}
const packageJson = await fs.readJSON(packageJsonPath);
const name = packageJson.name as string;
await installPlugin(name, pluginManager =>
pluginManager.installFromPath(packageDir).then(() => {}),
);
} finally {
if (fs.existsSync(tmpDir)) {
fs.removeSync(tmpDir);
}
}
}
// TODO(T57014856): This should be private, too.
export function provideSearchIndex(): SearchIndex {
const client = algoliasearch(ALGOLIA_APPLICATION_ID, ALGOLIA_API_KEY);
return client.initIndex('npm-search');
}
export const PLUGIN_DIR = path.join(homedir(), '.flipper', 'thirdparty');
export async function readInstalledPlugins(): Promise<PluginMap> {
const pluginDirExists = await fs.pathExists(PLUGIN_DIR);