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:
committed by
Facebook Github Bot
parent
984cdbfb67
commit
b9e7f5d6d1
@@ -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,
|
||||
|
||||
110
src/chrome/plugin-manager/PluginPackageInstaller.tsx
Normal file
110
src/chrome/plugin-manager/PluginPackageInstaller.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user