Move the code related to plugin loading / installation to "flipper-plugin-lib"

Summary:
Sorry for so long diff, but actually there are no functional changes, just refactoring to make further changes of Plugin Manager easier to understand.

I've de-coupled the code related to plugin management from UI code and moved it from PluginInstaller UI component (which will be replaced soon by new UI) to "flipper-plugin-lib".  So pretty much everything related to plugin discovery and installation now consolidated in this package.

Additionally, this refactoring enables re-using of plugin management code in "flipper-pkg", e.g. to create CLI command for plugin installation from NPM, e.g.: `flipper-pkg install flipper-plugin-reactotron`.

Reviewed By: passy

Differential Revision: D23679346

fbshipit-source-id: 82e7b9de9afa08c508c1b228c2038b4ba423571c
This commit is contained in:
Anton Nikolaev
2020-09-16 06:30:20 -07:00
committed by Facebook GitHub Bot
parent 72ff87d7cd
commit e48707151a
18 changed files with 1274 additions and 483 deletions

View File

@@ -10,7 +10,6 @@
"privileged": true,
"license": "MIT",
"dependencies": {
"@algolia/client-search": "4.3.0",
"@emotion/core": "^10.0.22",
"@emotion/styled": "^10.0.23",
"@iarna/toml": "^2.2.5",
@@ -19,7 +18,6 @@
"JSONStream": "^1.3.1",
"adbkit": "^2.11.1",
"adbkit-logcat": "^2.0.1",
"algoliasearch": "^4.0.0",
"archiver": "^5.0.0",
"async-mutex": "^0.1.3",
"axios": "^0.19.2",
@@ -37,7 +35,6 @@
"invariant": "^2.2.2",
"lodash": "^4.17.19",
"lodash.memoize": "^4.1.2",
"npm-api": "^1.0.0",
"open": "^7.0.0",
"openssl-wrapper": "^0.3.4",
"promise-retry": "^1.1.1",

View File

@@ -25,24 +25,19 @@ import {
LoadingIndicator,
Tooltip,
} from 'flipper';
import React, {useCallback, useState, useMemo, useEffect} from 'react';
import React, {useCallback, useState, useEffect} from 'react';
import {List} from 'immutable';
import {SearchIndex} from 'algoliasearch';
import {SearchResponse} from '@algolia/client-search';
import {reportPlatformFailures, reportUsage} from '../../utils/metrics';
import restartFlipper from '../../utils/restartFlipper';
import {registerInstalledPlugins} from '../../reducers/pluginManager';
import {
getPendingAndInstalledPlugins,
removePlugin,
PluginMap,
PluginDetails,
} from 'flipper-plugin-lib';
import {
provideSearchIndex,
findPluginUpdates as _findPluginUpdates,
UpdateResult,
} from '../../utils/pluginManager';
getInstalledPlugins,
getUpdatablePlugins,
removePlugin,
UpdatablePluginDetails,
InstalledPluginDetails,
} from 'flipper-plugin-lib';
import {installPluginFromNpm} from 'flipper-plugin-lib';
import {State as AppState} from '../../reducers';
import {connect} from 'react-redux';
@@ -97,7 +92,7 @@ const RestartBar = styled(FlexColumn)({
});
type PropsFromState = {
installedPlugins: PluginMap;
installedPlugins: InstalledPluginDetails[];
};
type DispatchFromProps = {
@@ -105,58 +100,29 @@ type DispatchFromProps = {
};
type OwnProps = {
searchIndexFactory: () => SearchIndex;
autoHeight: boolean;
findPluginUpdates: (
currentPlugins: PluginMap,
) => Promise<[string, UpdateResult][]>;
};
type Props = OwnProps & PropsFromState & DispatchFromProps;
const defaultProps: OwnProps = {
searchIndexFactory: provideSearchIndex,
autoHeight: false,
findPluginUpdates: _findPluginUpdates,
};
type UpdatablePlugin = {
updateStatus: UpdateResult;
};
type UpdatablePluginDefinition = PluginDetails & UpdatablePlugin;
// exported for testing
export function annotatePluginsWithUpdates(
installedPlugins: PluginMap,
updates: Map<string, UpdateResult>,
): Map<string, UpdatablePluginDefinition> {
const annotated: Array<[string, UpdatablePluginDefinition]> = Array.from(
installedPlugins.entries(),
).map(([key, value]) => {
const updateStatus = updates.get(key) || {kind: 'up-to-date'};
return [key, {...value, updateStatus: updateStatus}];
});
return new Map(annotated);
}
const PluginInstaller = function (props: Props) {
const PluginInstaller = function ({
refreshInstalledPlugins,
installedPlugins,
autoHeight,
}: Props) {
const [restartRequired, setRestartRequired] = useState(false);
const [query, setQuery] = useState('');
const onInstall = useCallback(async () => {
props.refreshInstalledPlugins();
refreshInstalledPlugins();
setRestartRequired(true);
}, []);
}, [refreshInstalledPlugins]);
const rows = useNPMSearch(
query,
setQuery,
props.searchIndexFactory,
props.installedPlugins,
onInstall,
props.findPluginUpdates,
);
const rows = useNPMSearch(query, onInstall, installedPlugins);
const restartApp = useCallback(() => {
restartFlipper();
}, []);
@@ -187,7 +153,7 @@ const PluginInstaller = function (props: Props) {
columns={columns}
highlightableRows={false}
highlightedRows={new Set()}
autoHeight={props.autoHeight}
autoHeight={autoHeight}
rows={rows}
/>
</Container>
@@ -195,7 +161,6 @@ const PluginInstaller = function (props: Props) {
</>
);
};
PluginInstaller.defaultProps = defaultProps;
const TableButton = styled(Button)({
marginTop: 2,
@@ -209,18 +174,10 @@ const AlignedGlyph = styled(Glyph)({
marginTop: 6,
});
function liftUpdatable(val: PluginDetails): UpdatablePluginDefinition {
return {
...val,
updateStatus: {kind: 'up-to-date'},
};
}
function InstallButton(props: {
name: string;
version: string;
onInstall: () => void;
installed: boolean;
updateStatus: UpdateResult;
}) {
type InstallAction =
@@ -280,9 +237,9 @@ function InstallButton(props: {
const [action, setAction] = useState<InstallAction>(
props.updateStatus.kind === 'update-available'
? {kind: 'Update'}
: props.installed
? {kind: 'Remove'}
: {kind: 'Install'},
: props.updateStatus.kind === 'not-installed'
? {kind: 'Install'}
: {kind: 'Remove'},
);
if (action.kind === 'Waiting') {
@@ -332,22 +289,19 @@ function InstallButton(props: {
function useNPMSearch(
query: string,
setQuery: (query: string) => void,
searchClientFactory: () => SearchIndex,
installedPlugins: PluginMap,
onInstall: () => Promise<void>,
findPluginUpdates: (
currentPlugins: PluginMap,
) => Promise<[string, UpdateResult][]>,
onInstall: () => void,
installedPlugins: InstalledPluginDetails[],
): TableRows_immutable {
const index = useMemo(searchClientFactory, []);
useEffect(() => {
reportUsage(`${TAG}:open`);
}, []);
const [searchResults, setSearchResults] = useState<UpdatablePluginDetails[]>(
[],
);
const createRow = useCallback(
(h: UpdatablePluginDefinition) => ({
(h: UpdatablePluginDetails) => ({
key: h.name,
columns: {
name: {
@@ -378,7 +332,6 @@ function useNPMSearch(
name={h.name}
version={h.version}
onInstall={onInstall}
installed={installedPlugins.has(h.name)}
updateStatus={h.updateStatus}
/>
),
@@ -386,37 +339,20 @@ function useNPMSearch(
},
},
}),
[installedPlugins],
[onInstall],
);
const [searchResults, setSearchResults] = useState<
UpdatablePluginDefinition[]
>([]);
const [
updateAnnotatedInstalledPlugins,
setUpdateAnnotatedInstalledPlugins,
] = useState<Map<string, UpdatablePluginDefinition>>(new Map());
useEffect(() => {
(async () => {
let cancelled = false;
const {hits} = await reportPlatformFailures(
index.search<PluginDetails>('', {
query,
filters: 'keywords:flipper-plugin',
hitsPerPage: 20,
}) as Promise<SearchResponse<PluginDetails>>,
const updatablePlugins = await reportPlatformFailures(
getUpdatablePlugins(),
`${TAG}:queryIndex`,
);
if (cancelled) {
return;
}
setSearchResults(
hits
.filter((hit) => !installedPlugins.has(hit.name))
.map(liftUpdatable),
);
setSearchResults(updatablePlugins);
// Clean up: if query changes while we're searching, abandon results.
return () => {
cancelled = true;
@@ -424,28 +360,19 @@ function useNPMSearch(
})();
}, [query, installedPlugins]);
useEffect(() => {
(async () => {
const updates = new Map(await findPluginUpdates(installedPlugins));
setUpdateAnnotatedInstalledPlugins(
annotatePluginsWithUpdates(installedPlugins, updates),
);
})();
}, [installedPlugins]);
const results = Array.from(updateAnnotatedInstalledPlugins.values()).concat(
searchResults,
);
return List(results.map(createRow));
const rows: TableRows_immutable = List(searchResults.map(createRow));
return rows;
}
PluginInstaller.defaultProps = defaultProps;
export default connect<PropsFromState, DispatchFromProps, OwnProps, AppState>(
({pluginManager: {installedPlugins}}) => ({
installedPlugins,
}),
(dispatch: Dispatch<Action<any>>) => ({
refreshInstalledPlugins: () => {
getPendingAndInstalledPlugins().then((plugins) =>
getInstalledPlugins().then((plugins) =>
dispatch(registerInstalledPlugins(plugins)),
);
},

View File

@@ -7,84 +7,109 @@
* @format
*/
import {annotatePluginsWithUpdates} from '../PluginInstaller';
import {UpdateResult} from '../../../utils/pluginManager';
import {PluginDetails} from 'flipper-plugin-lib';
jest.mock('flipper-plugin-lib');
test('annotatePluginsWithUpdates', async () => {
const installedPlugins = new Map<string, PluginDetails>([
[
'example',
{
name: 'example',
import {default as PluginInstaller} from '../PluginInstaller';
import React from 'react';
import {render, waitForElement} from '@testing-library/react';
import configureStore from 'redux-mock-store';
import {Provider} from 'react-redux';
import type {InstalledPluginDetails} from 'flipper-plugin-lib';
import {getUpdatablePlugins, UpdatablePluginDetails} from 'flipper-plugin-lib';
import {Store} from '../../../reducers';
import {mocked} from 'ts-jest/utils';
const getUpdatablePluginsMock = mocked(getUpdatablePlugins);
function getStore(installedPlugins: InstalledPluginDetails[] = []): Store {
return configureStore([])({
application: {sessionId: 'mysession'},
pluginManager: {installedPlugins},
}) as Store;
}
const samplePluginDetails1: UpdatablePluginDetails = {
name: 'flipper-plugin-hello',
entry: './test/index.js',
version: '0.1.0',
description: 'Gaze into the death crystal',
dir: '/plugins/example',
specVersion: 2,
source: 'src/index.ts',
main: 'dist/bundle.js',
dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-sample1',
source: 'src/index.js',
id: 'Hello',
title: 'Hello',
description: 'World?',
isDefault: false,
main: 'lib/index.js',
title: 'Example',
id: 'Example',
entry: '/plugins/example/lib/index.js',
updateStatus: {
kind: 'not-installed',
version: '0.1.0',
},
],
[
'ricksybusiness',
{
name: 'ricksybusiness',
version: '1.0.0',
description: 'Rick Die Rickpeat',
dir: '/plugins/example',
};
const samplePluginDetails2: UpdatablePluginDetails = {
name: 'flipper-plugin-world',
entry: './test/index.js',
version: '0.2.0',
specVersion: 2,
source: 'src/index.ts',
main: 'dist/bundle.js',
dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-sample2',
source: 'src/index.js',
id: 'World',
title: 'World',
description: 'Hello?',
isDefault: false,
main: 'lib/index.js',
title: 'ricksybusiness',
id: 'ricksybusiness',
entry: '/plugins/ricksybusiness/lib/index.js',
updateStatus: {
kind: 'not-installed',
version: '0.2.0',
},
],
]);
const updates = new Map<string, UpdateResult>([
['example', {kind: 'update-available', version: '1.1.0'}],
]);
const res = annotatePluginsWithUpdates(installedPlugins, updates);
expect(res).toMatchInlineSnapshot(`
Map {
"example" => Object {
"description": "Gaze into the death crystal",
"dir": "/plugins/example",
"entry": "/plugins/example/lib/index.js",
"id": "Example",
"isDefault": false,
"main": "lib/index.js",
"name": "example",
"source": "src/index.ts",
"specVersion": 2,
"title": "Example",
"updateStatus": Object {
"kind": "update-available",
"version": "1.1.0",
},
"version": "0.1.0",
},
"ricksybusiness" => Object {
"description": "Rick Die Rickpeat",
"dir": "/plugins/example",
"entry": "/plugins/ricksybusiness/lib/index.js",
"id": "ricksybusiness",
"isDefault": false,
"main": "lib/index.js",
"name": "ricksybusiness",
"source": "src/index.ts",
"specVersion": 2,
"title": "ricksybusiness",
"updateStatus": Object {
"kind": "up-to-date",
},
"version": "1.0.0",
},
}
`);
};
const SEARCH_RESULTS = [samplePluginDetails1, samplePluginDetails2];
afterEach(() => {
getUpdatablePluginsMock.mockClear();
});
test('load PluginInstaller list', async () => {
getUpdatablePluginsMock.mockReturnValue(Promise.resolve(SEARCH_RESULTS));
const component = (
<Provider store={getStore()}>
<PluginInstaller
// Bit ugly to have this as an effectively test-only option, but
// without, we rely on height information from Electron which we don't
// have, causing no items to be rendered.
autoHeight={true}
/>
</Provider>
);
const {container, getByText} = render(component);
await waitForElement(() => getByText('hello'));
expect(getUpdatablePluginsMock.mock.calls.length).toBe(1);
expect(container).toMatchSnapshot();
});
test('load PluginInstaller list with one plugin installed', async () => {
getUpdatablePluginsMock.mockReturnValue(
Promise.resolve([
{...samplePluginDetails1, updateStatus: {kind: 'up-to-date'}},
samplePluginDetails2,
]),
);
const store = getStore([
{...samplePluginDetails1, installationStatus: 'installed'},
]);
const component = (
<Provider store={store}>
<PluginInstaller
// Bit ugly to have this as an effectively test-only option, but
// without, we rely on height information from Electron which we don't
// have, causing no items to be rendered.
autoHeight={true}
/>
</Provider>
);
const {container, getByText} = render(component);
await waitForElement(() => getByText('hello'));
expect(getUpdatablePluginsMock.mock.calls.length).toBe(1);
expect(container).toMatchSnapshot();
});

View File

@@ -0,0 +1,590 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`load PluginInstaller list 1`] = `
<div>
<div
class="css-13jp8bd-View-FlexBox-FlexColumn"
>
<div
class="css-mx54fh-View-FlexBox-FlexRow-Toolbar e13mj6h80"
>
<div
class="css-awcbnc-View-FlexBox-SearchBox e271nro1"
>
<input
class="css-mquw9q-Input-SearchInput e271nro2"
placeholder="Search Flipper plugins..."
type="text"
value=""
/>
</div>
</div>
<div
class="css-1s2tz3g-View-FlexBox-FlexColumn-Container e11hk09w0"
>
<div
class="css-k28k5g-View-FlexBox-FlexColumn ecr18to0"
>
<div
class="css-guwxm6-View-FlexBox-FlexRow-TableHeadContainer ejga3103"
>
<div
class="css-zk363n-TableHeadColumnContainer ejga3104"
title="name"
width="25%"
>
<div
class="ejga3101 css-1859yug-InteractiveContainer-TableHeaderColumnInteractive etmd34w0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
class="css-1yopr4n-TableHeaderColumnContainer ejga3102"
>
Name
</div>
</div>
</div>
<div
class="css-1243222-TableHeadColumnContainer ejga3104"
title="version"
width="10%"
>
<div
class="ejga3101 css-1859yug-InteractiveContainer-TableHeaderColumnInteractive etmd34w0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
class="css-1yopr4n-TableHeaderColumnContainer ejga3102"
>
Version
</div>
</div>
</div>
<div
class="css-nyra6d-TableHeadColumnContainer ejga3104"
title="description"
width="flex"
>
<div
class="ejga3101 css-1859yug-InteractiveContainer-TableHeaderColumnInteractive etmd34w0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
class="css-1yopr4n-TableHeaderColumnContainer ejga3102"
>
Description
</div>
</div>
</div>
<div
class="css-hyjqmu-TableHeadColumnContainer ejga3104"
title="install"
width="15%"
>
<div
class="ejga3101 css-1859yug-InteractiveContainer-TableHeaderColumnInteractive etmd34w0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
class="css-1yopr4n-TableHeaderColumnContainer ejga3102"
>
</div>
</div>
</div>
</div>
</div>
<div
class="css-1s2tz3g-View-FlexBox-FlexColumn-Container e11hk09w0"
>
<div
class="css-1gi2nc1-View-FlexBox-FlexRow-TableBodyRowContainer ehuguum0"
data-key="flipper-plugin-hello"
>
<div
class="css-hdg6vt-TableBodyColumnContainer ehuguum1"
title=""
width="25%"
>
<span
class="css-fyize4-Text"
>
hello
</span>
</div>
<div
class="css-1rdsust-TableBodyColumnContainer ehuguum1"
title=""
width="10%"
>
<span
class="css-fyize4-Text"
>
0.1.0
</span>
</div>
<div
class="css-1bw2bjn-TableBodyColumnContainer ehuguum1"
title=""
width="flex"
>
<div
class="css-wxxfdj-View-FlexBox-FlexRow epz0qe20"
>
<span
class="css-fyize4-Text"
>
World?
</span>
<div
class="css-te359u-View-FlexBox-Spacer e13mj6h81"
/>
<span
class="css-ad6n9d-StyledLink e1mzoj7l0"
>
<div
class="css-1tfjvvq-ColoredIconCustom e528dze1"
color="#bec2c9"
size="16"
src="https://external.xx.fbcdn.net/assets/?name=info-circle&variant=filled&size=16&set=facebook_icons&density=1x"
/>
</span>
</div>
</div>
<div
class="css-4dx1ef-TableBodyColumnContainer ehuguum1"
title=""
width="15%"
>
<div
class="css-1pccuw6-StyledButton enfqd40"
type="primary"
>
Install
</div>
</div>
</div>
<div
class="css-14d4692-View-FlexBox-FlexRow-TableBodyRowContainer ehuguum0"
data-key="flipper-plugin-world"
>
<div
class="css-hdg6vt-TableBodyColumnContainer ehuguum1"
title=""
width="25%"
>
<span
class="css-fyize4-Text"
>
world
</span>
</div>
<div
class="css-1rdsust-TableBodyColumnContainer ehuguum1"
title=""
width="10%"
>
<span
class="css-fyize4-Text"
>
0.2.0
</span>
</div>
<div
class="css-1bw2bjn-TableBodyColumnContainer ehuguum1"
title=""
width="flex"
>
<div
class="css-wxxfdj-View-FlexBox-FlexRow epz0qe20"
>
<span
class="css-fyize4-Text"
>
Hello?
</span>
<div
class="css-te359u-View-FlexBox-Spacer e13mj6h81"
/>
<span
class="css-ad6n9d-StyledLink e1mzoj7l0"
>
<div
class="css-1tfjvvq-ColoredIconCustom e528dze1"
color="#bec2c9"
size="16"
src="https://external.xx.fbcdn.net/assets/?name=info-circle&variant=filled&size=16&set=facebook_icons&density=1x"
/>
</span>
</div>
</div>
<div
class="css-4dx1ef-TableBodyColumnContainer ehuguum1"
title=""
width="15%"
>
<div
class="css-1pccuw6-StyledButton enfqd40"
type="primary"
>
Install
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="css-mx54fh-View-FlexBox-FlexRow-Toolbar e13mj6h80"
>
<div
class="css-1stmykz-View-FlexBox-FlexRow-Container ersmi541"
>
<input
class="css-phqpi-Input-FileInputBox ersmi543"
placeholder="Specify path to a Flipper package or just drag and drop it here..."
type="text"
value=""
/>
<div
class="css-1juns87-View-FlexBox-FlexRow-GlyphContainer ersmi542"
>
<img
alt="dots-3-circle"
class="ersmi540 css-6iptsk-ColoredIconBlack-CenteredGlyph e528dze0"
size="16"
src="https://external.xx.fbcdn.net/assets/?name=dots-3-circle&variant=outline&size=16&set=facebook_icons&density=1x"
title="Open file selection dialog"
/>
</div>
<div
class="css-1juns87-View-FlexBox-FlexRow-GlyphContainer ersmi542"
>
<div
class="css-yzqaun-TooltipContainer e1abcqbd0"
>
<div
class="ersmi540 css-1vos16e-ColoredIconCustom-CenteredGlyph e528dze1"
color="#D79651"
size="16"
src="https://external.xx.fbcdn.net/assets/?name=caution-triangle&variant=filled&size=16&set=facebook_icons&density=1x"
/>
</div>
</div>
</div>
<div
class="css-16kzr4q-View-FlexBox-FlexRow-ButtonContainer evd5j492"
>
<div
class="css-17wo7w2-View-FlexBox-FlexRow epz0qe20"
>
<div
class="css-1rxpsn8-StyledButton enfqd40"
disabled=""
title="Cannot install plugin package by the specified path"
type="primary"
>
Install
</div>
<div
class="css-ywl8lj-View-FlexBox-FlexRow-ErrorGlyphContainer evd5j493"
/>
</div>
</div>
</div>
</div>
`;
exports[`load PluginInstaller list with one plugin installed 1`] = `
<div>
<div
class="css-13jp8bd-View-FlexBox-FlexColumn"
>
<div
class="css-mx54fh-View-FlexBox-FlexRow-Toolbar e13mj6h80"
>
<div
class="css-awcbnc-View-FlexBox-SearchBox e271nro1"
>
<input
class="css-mquw9q-Input-SearchInput e271nro2"
placeholder="Search Flipper plugins..."
type="text"
value=""
/>
</div>
</div>
<div
class="css-1s2tz3g-View-FlexBox-FlexColumn-Container e11hk09w0"
>
<div
class="css-k28k5g-View-FlexBox-FlexColumn ecr18to0"
>
<div
class="css-guwxm6-View-FlexBox-FlexRow-TableHeadContainer ejga3103"
>
<div
class="css-zk363n-TableHeadColumnContainer ejga3104"
title="name"
width="25%"
>
<div
class="ejga3101 css-1859yug-InteractiveContainer-TableHeaderColumnInteractive etmd34w0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
class="css-1yopr4n-TableHeaderColumnContainer ejga3102"
>
Name
</div>
</div>
</div>
<div
class="css-1243222-TableHeadColumnContainer ejga3104"
title="version"
width="10%"
>
<div
class="ejga3101 css-1859yug-InteractiveContainer-TableHeaderColumnInteractive etmd34w0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
class="css-1yopr4n-TableHeaderColumnContainer ejga3102"
>
Version
</div>
</div>
</div>
<div
class="css-nyra6d-TableHeadColumnContainer ejga3104"
title="description"
width="flex"
>
<div
class="ejga3101 css-1859yug-InteractiveContainer-TableHeaderColumnInteractive etmd34w0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
class="css-1yopr4n-TableHeaderColumnContainer ejga3102"
>
Description
</div>
</div>
</div>
<div
class="css-hyjqmu-TableHeadColumnContainer ejga3104"
title="install"
width="15%"
>
<div
class="ejga3101 css-1859yug-InteractiveContainer-TableHeaderColumnInteractive etmd34w0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
class="css-1yopr4n-TableHeaderColumnContainer ejga3102"
>
</div>
</div>
</div>
</div>
</div>
<div
class="css-1s2tz3g-View-FlexBox-FlexColumn-Container e11hk09w0"
>
<div
class="css-1gi2nc1-View-FlexBox-FlexRow-TableBodyRowContainer ehuguum0"
data-key="flipper-plugin-hello"
>
<div
class="css-hdg6vt-TableBodyColumnContainer ehuguum1"
title=""
width="25%"
>
<span
class="css-fyize4-Text"
>
hello
</span>
</div>
<div
class="css-1rdsust-TableBodyColumnContainer ehuguum1"
title=""
width="10%"
>
<span
class="css-fyize4-Text"
>
0.1.0
</span>
</div>
<div
class="css-1bw2bjn-TableBodyColumnContainer ehuguum1"
title=""
width="flex"
>
<div
class="css-wxxfdj-View-FlexBox-FlexRow epz0qe20"
>
<span
class="css-fyize4-Text"
>
World?
</span>
<div
class="css-te359u-View-FlexBox-Spacer e13mj6h81"
/>
<span
class="css-ad6n9d-StyledLink e1mzoj7l0"
>
<div
class="css-1tfjvvq-ColoredIconCustom e528dze1"
color="#bec2c9"
size="16"
src="https://external.xx.fbcdn.net/assets/?name=info-circle&variant=filled&size=16&set=facebook_icons&density=1x"
/>
</span>
</div>
</div>
<div
class="css-4dx1ef-TableBodyColumnContainer ehuguum1"
title=""
width="15%"
>
<div
class="css-1pccuw6-StyledButton enfqd40"
>
Remove
</div>
</div>
</div>
<div
class="css-14d4692-View-FlexBox-FlexRow-TableBodyRowContainer ehuguum0"
data-key="flipper-plugin-world"
>
<div
class="css-hdg6vt-TableBodyColumnContainer ehuguum1"
title=""
width="25%"
>
<span
class="css-fyize4-Text"
>
world
</span>
</div>
<div
class="css-1rdsust-TableBodyColumnContainer ehuguum1"
title=""
width="10%"
>
<span
class="css-fyize4-Text"
>
0.2.0
</span>
</div>
<div
class="css-1bw2bjn-TableBodyColumnContainer ehuguum1"
title=""
width="flex"
>
<div
class="css-wxxfdj-View-FlexBox-FlexRow epz0qe20"
>
<span
class="css-fyize4-Text"
>
Hello?
</span>
<div
class="css-te359u-View-FlexBox-Spacer e13mj6h81"
/>
<span
class="css-ad6n9d-StyledLink e1mzoj7l0"
>
<div
class="css-1tfjvvq-ColoredIconCustom e528dze1"
color="#bec2c9"
size="16"
src="https://external.xx.fbcdn.net/assets/?name=info-circle&variant=filled&size=16&set=facebook_icons&density=1x"
/>
</span>
</div>
</div>
<div
class="css-4dx1ef-TableBodyColumnContainer ehuguum1"
title=""
width="15%"
>
<div
class="css-1pccuw6-StyledButton enfqd40"
type="primary"
>
Install
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="css-mx54fh-View-FlexBox-FlexRow-Toolbar e13mj6h80"
>
<div
class="css-1stmykz-View-FlexBox-FlexRow-Container ersmi541"
>
<input
class="css-phqpi-Input-FileInputBox ersmi543"
placeholder="Specify path to a Flipper package or just drag and drop it here..."
type="text"
value=""
/>
<div
class="css-1juns87-View-FlexBox-FlexRow-GlyphContainer ersmi542"
>
<img
alt="dots-3-circle"
class="ersmi540 css-6iptsk-ColoredIconBlack-CenteredGlyph e528dze0"
size="16"
src="https://external.xx.fbcdn.net/assets/?name=dots-3-circle&variant=outline&size=16&set=facebook_icons&density=1x"
title="Open file selection dialog"
/>
</div>
<div
class="css-1juns87-View-FlexBox-FlexRow-GlyphContainer ersmi542"
>
<div
class="css-yzqaun-TooltipContainer e1abcqbd0"
>
<div
class="ersmi540 css-1vos16e-ColoredIconCustom-CenteredGlyph e528dze1"
color="#D79651"
size="16"
src="https://external.xx.fbcdn.net/assets/?name=caution-triangle&variant=filled&size=16&set=facebook_icons&density=1x"
/>
</div>
</div>
</div>
<div
class="css-16kzr4q-View-FlexBox-FlexRow-ButtonContainer evd5j492"
>
<div
class="css-17wo7w2-View-FlexBox-FlexRow epz0qe20"
>
<div
class="css-1rxpsn8-StyledButton enfqd40"
disabled=""
title="Cannot install plugin package by the specified path"
type="primary"
>
Install
</div>
<div
class="css-ywl8lj-View-FlexBox-FlexRow-ErrorGlyphContainer evd5j493"
/>
</div>
</div>
</div>
</div>
`;

View File

@@ -10,10 +10,10 @@
import {Store} from '../reducers/index';
import {Logger} from '../fb-interfaces/Logger';
import {registerInstalledPlugins} from '../reducers/pluginManager';
import {getPendingAndInstalledPlugins} from 'flipper-plugin-lib';
import {getInstalledPlugins} from 'flipper-plugin-lib';
function refreshInstalledPlugins(store: Store) {
getPendingAndInstalledPlugins().then((plugins) =>
getInstalledPlugins().then((plugins) =>
store.dispatch(registerInstalledPlugins(plugins)),
);
}

View File

@@ -8,10 +8,11 @@
*/
import {default as reducer, registerInstalledPlugins} from '../pluginManager';
import {InstalledPluginDetails} from 'flipper-plugin-lib';
test('reduce empty registerInstalledPlugins', () => {
const result = reducer(undefined, registerInstalledPlugins(new Map()));
expect(result).toEqual({installedPlugins: new Map()});
const result = reducer(undefined, registerInstalledPlugins([]));
expect(result).toEqual({installedPlugins: []});
});
const EXAMPLE_PLUGIN = {
@@ -26,17 +27,15 @@ const EXAMPLE_PLUGIN = {
title: 'test',
id: 'test',
entry: '/plugins/test/lib/index.js',
};
installationStatus: 'installed',
} as InstalledPluginDetails;
test('reduce registerInstalledPlugins, clear again', () => {
const result = reducer(
undefined,
registerInstalledPlugins(new Map([['test', EXAMPLE_PLUGIN]])),
);
const result = reducer(undefined, registerInstalledPlugins([EXAMPLE_PLUGIN]));
expect(result).toEqual({
installedPlugins: new Map([['test', EXAMPLE_PLUGIN]]),
installedPlugins: [EXAMPLE_PLUGIN],
});
const result2 = reducer(result, registerInstalledPlugins(new Map()));
expect(result2).toEqual({installedPlugins: new Map()});
const result2 = reducer(result, registerInstalledPlugins([]));
expect(result2).toEqual({installedPlugins: []});
});

View File

@@ -8,19 +8,19 @@
*/
import {Actions} from './';
import {PluginMap} from 'flipper-plugin-lib';
import {InstalledPluginDetails} from 'flipper-plugin-lib';
export type State = {
installedPlugins: PluginMap;
installedPlugins: InstalledPluginDetails[];
};
export type Action = {
type: 'REGISTER_INSTALLED_PLUGINS';
payload: PluginMap;
payload: InstalledPluginDetails[];
};
const INITIAL_STATE: State = {
installedPlugins: new Map(),
installedPlugins: [],
};
export default function reducer(
@@ -37,7 +37,9 @@ export default function reducer(
}
}
export const registerInstalledPlugins = (payload: PluginMap): Action => ({
export const registerInstalledPlugins = (
payload: InstalledPluginDetails[],
): Action => ({
type: 'REGISTER_INSTALLED_PLUGINS',
payload,
});

View File

@@ -1,57 +0,0 @@
/**
* 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 path from 'path';
import {homedir} from 'os';
import {PluginMap, PluginDetails} from 'flipper-plugin-lib';
import {default as algoliasearch, SearchIndex} from 'algoliasearch';
import NpmApi, {Package} from 'npm-api';
import semver from 'semver';
const ALGOLIA_APPLICATION_ID = 'OFCNCOG2CU';
const ALGOLIA_API_KEY = 'f54e21fa3a2a0160595bb058179bfb1e';
export const PLUGIN_DIR = path.join(homedir(), '.flipper', 'thirdparty');
// 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 type UpdateResult =
| {kind: 'up-to-date'}
| {kind: 'error'; error: Error}
| {kind: 'update-available'; version: string};
export async function findPluginUpdates(
currentPlugins: PluginMap,
): Promise<[string, UpdateResult][]> {
const npm = new NpmApi();
return Promise.all(
Array.from(currentPlugins.values()).map(
async (currentPlugin: PluginDetails): Promise<[string, UpdateResult]> =>
npm
.repo(currentPlugin.name)
.package()
.then((pkg: Package): [string, UpdateResult] => {
if (semver.lt(currentPlugin.version, pkg.version)) {
return [
currentPlugin.name,
{kind: 'update-available', version: pkg.version},
];
} else {
return [currentPlugin.name, {kind: 'up-to-date'}];
}
})
.catch((err) => [currentPlugin.name, {kind: 'error', error: err}]),
),
);
}

View File

@@ -9,11 +9,15 @@
"license": "MIT",
"bugs": "https://github.com/facebook/flipper/issues",
"dependencies": {
"@algolia/client-search": "^4.4.0",
"algoliasearch": "^4.4.0",
"decompress": "^4.2.1",
"decompress-targz": "^4.1.1",
"decompress-unzip": "^4.0.1",
"fs-extra": "^9.0.1",
"live-plugin-manager": "^0.14.1",
"npm-api": "^1.0.0",
"p-map": "^4.0.0",
"semver": "^7.3.2",
"tmp": "^0.2.1"
},

View File

@@ -0,0 +1,131 @@
/**
* 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
*/
jest.mock('../getInstalledPlugins');
jest.mock('../getNpmHostedPlugins');
import {getUpdatablePlugins} from '../getUpdatablePlugins';
import {
getNpmHostedPlugins,
NpmPackageDescriptor,
} from '../getNpmHostedPlugins';
import type {InstalledPluginDetails} from '../getInstalledPlugins';
import {getInstalledPlugins} from '../getInstalledPlugins';
import {mocked} from 'ts-jest/utils';
import type {Package} from 'npm-api';
jest.mock('npm-api', () => {
return jest.fn().mockImplementation(() => {
return {
repo: jest.fn().mockImplementation((name: string) => {
let pkg: Package | undefined;
if (name === 'flipper-plugin-hello') {
pkg = {
$schema: 'https://fbflipper.com/schemas/plugin-package/v2.json',
name: 'flipper-plugin-hello',
title: 'Hello',
version: '0.1.0',
main: 'dist/bundle.js',
flipperBundlerEntry: 'src/index.js',
description: 'World?',
};
} else if (name === 'flipper-plugin-world') {
pkg = {
$schema: 'https://fbflipper.com/schemas/plugin-package/v2.json',
name: 'flipper-plugin-world',
title: 'World',
version: '0.3.0',
main: 'dist/bundle.js',
flipperBundlerEntry: 'src/index.js',
description: 'World?',
};
}
return {
package: jest.fn().mockImplementation(() => Promise.resolve(pkg)),
};
}),
};
});
});
const installedPlugins: InstalledPluginDetails[] = [
{
name: 'flipper-plugin-hello',
entry: './test/index.js',
version: '0.1.0',
specVersion: 2,
main: 'dist/bundle.js',
dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-sample1',
source: 'src/index.js',
id: 'Hello',
title: 'Hello',
description: 'World?',
isDefault: false,
installationStatus: 'installed',
},
{
name: 'flipper-plugin-world',
entry: './test/index.js',
version: '0.2.0',
specVersion: 2,
main: 'dist/bundle.js',
dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-sample2',
source: 'src/index.js',
id: 'World',
title: 'World',
description: 'Hello?',
isDefault: false,
installationStatus: 'pending',
},
];
const updates: NpmPackageDescriptor[] = [
{name: 'flipper-plugin-hello', version: '0.1.0'},
{name: 'flipper-plugin-world', version: '0.3.0'},
];
test('annotatePluginsWithUpdates', async () => {
const getInstalledPluginsMock = mocked(getInstalledPlugins);
getInstalledPluginsMock.mockReturnValue(Promise.resolve(installedPlugins));
const getNpmHostedPluginsMock = mocked(getNpmHostedPlugins);
getNpmHostedPluginsMock.mockReturnValue(Promise.resolve(updates));
const res = await getUpdatablePlugins();
expect(res.length).toBe(2);
expect({
name: res[0].name,
version: res[0].version,
updateStatus: res[0].updateStatus,
}).toMatchInlineSnapshot(`
Object {
"name": "flipper-plugin-hello",
"updateStatus": Object {
"kind": "up-to-date",
},
"version": "0.1.0",
}
`);
expect({
name: res[1].name,
version: res[1].version,
updateStatus: res[1].updateStatus,
}).toMatchInlineSnapshot(`
Object {
"name": "flipper-plugin-world",
"updateStatus": Object {
"kind": "update-available",
"version": "0.3.0",
},
"version": "0.3.0",
}
`);
});

View File

@@ -0,0 +1,104 @@
/**
* 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 fs from 'fs-extra';
import path from 'path';
import semver from 'semver';
import {
pluginPendingInstallationDir,
pluginInstallationDir,
} from './pluginPaths';
import PluginDetails from './PluginDetails';
import getPluginDetails from './getPluginDetails';
import pmap from 'p-map';
import {notNull} from './typeUtils';
export type PluginInstallationStatus =
| 'not-installed'
| 'installed'
| 'pending';
export type InstalledPluginDetails = PluginDetails & {
installationStatus: PluginInstallationStatus;
};
async function getFullyInstalledPlugins(): Promise<PluginDetails[]> {
const pluginDirExists = await fs.pathExists(pluginInstallationDir);
if (!pluginDirExists) {
return [];
}
const dirs = await fs.readdir(pluginInstallationDir);
const plugins = await pmap(dirs, async (dirName) => {
const pluginDir = path.join(pluginInstallationDir, dirName);
if (!(await fs.lstat(pluginDir)).isDirectory()) {
return undefined;
}
try {
return await getPluginDetails(pluginDir);
} catch (e) {
console.error(`Failed to load plugin from ${pluginDir}`, e);
return undefined;
}
});
return plugins.filter(notNull);
}
async function getPendingInstallationPlugins(): Promise<PluginDetails[]> {
const pluginDirExists = await fs.pathExists(pluginPendingInstallationDir);
if (!pluginDirExists) {
return [];
}
const dirs = await fs.readdir(pluginPendingInstallationDir);
const plugins = await pmap(dirs, async (dirName) => {
const versions = (
await fs.readdir(path.join(pluginPendingInstallationDir, dirName))
).sort((v1, v2) => semver.compare(v2, v1, true));
if (versions.length === 0) {
return undefined;
}
const pluginDir = path.join(
pluginPendingInstallationDir,
dirName,
versions[0],
);
if (!(await fs.lstat(pluginDir)).isDirectory()) {
return undefined;
}
try {
return await getPluginDetails(pluginDir);
} catch (e) {
console.error(`Failed to load plugin from ${pluginDir}`, e);
return undefined;
}
});
return plugins.filter(notNull);
}
export async function getInstalledPlugins(): Promise<InstalledPluginDetails[]> {
const map = new Map<string, InstalledPluginDetails>(
(await getFullyInstalledPlugins()).map((p) => [
p.name,
{...p, installationStatus: 'installed'},
]),
);
for (const p of await getPendingInstallationPlugins()) {
if (!map.get(p.name) || semver.gt(p.version, map.get(p.name)!.version)) {
map.set(p.name, {...p, installationStatus: 'pending'});
}
}
const allPlugins = [...map.values()].sort((p1, p2) =>
p1.installationStatus === 'installed' && p2.installationStatus === 'pending'
? 1
: p1.installationStatus === 'pending' &&
p2.installationStatus === 'installed'
? -1
: p1.name.localeCompare(p2.name),
);
return allPlugins;
}

View File

@@ -0,0 +1,46 @@
/**
* 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 {default as algoliasearch, SearchIndex} from 'algoliasearch';
const ALGOLIA_APPLICATION_ID = 'OFCNCOG2CU';
const ALGOLIA_API_KEY = 'f54e21fa3a2a0160595bb058179bfb1e';
function provideSearchIndex(): SearchIndex {
const client = algoliasearch(ALGOLIA_APPLICATION_ID, ALGOLIA_API_KEY);
return client.initIndex('npm-search');
}
export type NpmPackageDescriptor = {
name: string;
version: string;
};
export type NpmHostedPluginsSearchArgs = {
query?: string;
};
export async function getNpmHostedPlugins(
args: NpmHostedPluginsSearchArgs = {},
): Promise<NpmPackageDescriptor[]> {
const index = provideSearchIndex();
args = Object.assign(
{
query: '',
filters: 'keywords:flipper-plugin',
hitsPerPage: 50,
},
args,
);
const {hits} = await index.search<NpmPackageDescriptor>(
args.query || '',
args,
);
return hits;
}

View File

@@ -0,0 +1,120 @@
/**
* 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 PluginDetails from './PluginDetails';
import {getInstalledPlugins} from './getInstalledPlugins';
import semver from 'semver';
import {getNpmHostedPlugins, NpmPackageDescriptor} from './getNpmHostedPlugins';
import NpmApi from 'npm-api';
import getPluginDetails from './getPluginDetails';
import {getPluginInstallationDir} from './pluginInstaller';
import pmap from 'p-map';
import {notNull} from './typeUtils';
export type UpdateResult =
| {kind: 'not-installed'; version: string}
| {kind: 'pending'}
| {kind: 'up-to-date'}
| {kind: 'error'; error: Error}
| {kind: 'update-available'; version: string};
export type UpdatablePlugin = {
updateStatus: UpdateResult;
};
export type UpdatablePluginDetails = PluginDetails & UpdatablePlugin;
export async function getUpdatablePlugins(): Promise<UpdatablePluginDetails[]> {
const npmApi = new NpmApi();
const installedPlugins = await getInstalledPlugins();
const npmHostedPlugins = new Map<string, NpmPackageDescriptor>(
(await getNpmHostedPlugins()).map((p) => [p.name, p]),
);
const annotatedInstalledPlugins = await pmap(
installedPlugins,
async (installedPlugin): Promise<UpdatablePluginDetails> => {
try {
const npmPackageDescriptor = npmHostedPlugins.get(installedPlugin.name);
if (npmPackageDescriptor) {
npmHostedPlugins.delete(installedPlugin.name);
if (
semver.lt(installedPlugin.version, npmPackageDescriptor.version)
) {
const pkg = await npmApi.repo(npmPackageDescriptor.name).package();
const npmPluginDetails = await getPluginDetails(
getPluginInstallationDir(npmPackageDescriptor.name),
pkg,
);
return {
...npmPluginDetails,
updateStatus: {
kind: 'update-available',
version: npmPluginDetails.version,
},
};
}
}
const updateStatus: UpdateResult =
installedPlugin.installationStatus === 'installed'
? {kind: 'up-to-date'}
: {kind: 'pending'};
return {
...installedPlugin,
updateStatus,
};
} catch (error) {
return {
...installedPlugin,
updateStatus: {
kind: 'error',
error,
},
};
}
},
{
concurrency: 4,
},
);
const annotatedNotInstalledPlugins = await pmap(
npmHostedPlugins.values(),
async (notInstalledPlugin) => {
try {
const pkg = await npmApi.repo(notInstalledPlugin.name).package();
const npmPluginDetails = await getPluginDetails(
getPluginInstallationDir(notInstalledPlugin.name),
pkg,
);
return {
...npmPluginDetails,
updateStatus: {
kind: 'not-installed',
version: npmPluginDetails.version,
},
} as UpdatablePluginDetails;
} catch (error) {
console.log(
`Failed to load details from npm for plugin ${notInstalledPlugin.name}`,
);
return null;
}
},
{
concurrency: 4,
},
);
return [
...annotatedInstalledPlugins.sort((p1, p2) =>
p1.name.localeCompare(p2.name),
),
...annotatedNotInstalledPlugins
.filter(notNull)
.sort((p1, p2) => p1.name.localeCompare(p2.name)),
];
}

View File

@@ -10,3 +10,5 @@
export {default as PluginDetails} from './PluginDetails';
export {default as getPluginDetails} from './getPluginDetails';
export * from './pluginInstaller';
export * from './getInstalledPlugins';
export * from './getUpdatablePlugins';

View File

@@ -23,8 +23,6 @@ import {
} from './pluginPaths';
import semver from 'semver';
export type PluginMap = Map<string, PluginDetails>;
const getTmpDir = promisify(tmp.dir) as () => Promise<string>;
function providePluginManager(): PM {
@@ -51,7 +49,7 @@ function getPluginPendingInstallationsDir(name: string): string {
);
}
function getPluginInstallationDir(name: string): string {
export function getPluginInstallationDir(name: string): string {
return path.join(
pluginInstallationDir,
replaceInvalidPathSegmentCharacters(name),
@@ -183,83 +181,11 @@ export async function installPluginFromFile(
}
}
export async function getInstalledPlugins(): Promise<PluginMap> {
const pluginDirExists = await fs.pathExists(pluginInstallationDir);
if (!pluginDirExists) {
return new Map();
}
const dirs = await fs.readdir(pluginInstallationDir);
const plugins = await Promise.all<[string, PluginDetails]>(
dirs.map(
(dirName) =>
new Promise(async (resolve, reject) => {
const pluginDir = path.join(pluginInstallationDir, dirName);
if (!(await fs.lstat(pluginDir)).isDirectory()) {
return resolve(undefined);
}
try {
const details = await getPluginDetails(pluginDir);
resolve([details.name, details]);
} catch (e) {
reject(e);
}
}),
),
);
return new Map(plugins.filter(Boolean));
}
export async function getPendingInstallationPlugins(): Promise<PluginMap> {
const pluginDirExists = await fs.pathExists(pluginPendingInstallationDir);
if (!pluginDirExists) {
return new Map();
}
const dirs = await fs.readdir(pluginPendingInstallationDir);
const plugins = await Promise.all<[string, PluginDetails]>(
dirs.map(
(dirName) =>
new Promise(async (resolve, reject) => {
const versions = (
await fs.readdir(path.join(pluginPendingInstallationDir, dirName))
).sort((v1, v2) => semver.compare(v2, v1, true));
if (versions.length === 0) {
return resolve(undefined);
}
const pluginDir = path.join(
pluginPendingInstallationDir,
dirName,
versions[0],
);
if (!(await fs.lstat(pluginDir)).isDirectory()) {
return resolve(undefined);
}
try {
const details = await getPluginDetails(pluginDir);
resolve([details.name, details]);
} catch (e) {
reject(e);
}
}),
),
);
return new Map(plugins.filter(Boolean));
}
export async function getPendingAndInstalledPlugins(): Promise<PluginMap> {
const plugins = await getInstalledPlugins();
for (const [name, details] of await getPendingInstallationPlugins()) {
if (
!plugins.get(name) ||
semver.gt(details.version, plugins.get(name)!.version)
) {
plugins.set(name, details);
}
}
return plugins;
}
export async function removePlugin(name: string): Promise<void> {
await fs.remove(getPluginInstallationDir(name));
await Promise.all([
fs.remove(getPluginInstallationDir(name)),
fs.remove(getPluginPendingInstallationsDir(name)),
]);
}
export async function finishPendingPluginInstallations() {

View File

@@ -0,0 +1,15 @@
/**
* 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
*/
// TODO T75614643: move to a separate lib for utils, e.g. flipper-utils
// Typescript doesn't know Array.filter(Boolean) won't contain nulls.
// So use Array.filter(notNull) instead.
export function notNull<T>(x: T | null | undefined): x is T {
return x !== null && x !== undefined;
}

View File

@@ -32,5 +32,6 @@ declare module 'npm-api' {
export interface Package {
name: string;
version: string;
[name: string]: string;
}
}

View File

@@ -12,150 +12,109 @@
resolved "https://registry.yarnpkg.com/7zip-bin/-/7zip-bin-5.0.3.tgz#bc5b5532ecafd923a61f2fb097e3b108c0106a3f"
integrity sha512-GLyWIFBbGvpKPGo55JyRZAo4lVbnBiD52cKlw/0Vt+wnmKvWJkpZvsjVoaIolyBXDeAQKSicRtqFNPem9w0WYA==
"@algolia/cache-browser-local-storage@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.1.0.tgz#c4f1bfc57ea562248072b35831e3c4b646cc3921"
integrity sha512-r8BOgqZXVt+JPgP19PQNzZ+lYP+MP6eZKNQqfRYofFEx+K9oyfdtGCqmoWJsBUi3nNOzhbOcg2jfP2GJzJBZ5g==
"@algolia/cache-browser-local-storage@4.4.0":
version "4.4.0"
resolved "https://registry.yarnpkg.com/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.4.0.tgz#f58055bdf798d7b31b6d5f86e465cb0fc7dd6694"
integrity sha512-2AiKgN7DpFypkRCRkpqH7waXXyFdcnsPWzmN8sLHrB/FfXqgmsQb3pGft+9YHZIDQ0vAnfgMxSGgMhMGW+0Qnw==
dependencies:
"@algolia/cache-common" "4.1.0"
"@algolia/cache-common" "4.4.0"
"@algolia/cache-common@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@algolia/cache-common/-/cache-common-4.1.0.tgz#ab895f8049ff7064ca1bfea504a56f97fd5d4683"
integrity sha512-ZvvK40bs1BWLErchleZL4ctHT2uH56uLMnpZPCuIk+H2PKddeiIQc/z2JDu2BHr68u513XIAAoQ+C+LgKNugmw==
"@algolia/cache-common@4.4.0":
version "4.4.0"
resolved "https://registry.yarnpkg.com/@algolia/cache-common/-/cache-common-4.4.0.tgz#bfe84790230f5d2de495238b29e9397c5ed2b26e"
integrity sha512-PrIgoMnXaDWUfwOekahro543pgcJfgRu/nd/ZQS5ffem3+Ow725eZY6HDpPaQ1k3cvLii9JH6V2sNJConjqUKA==
"@algolia/cache-common@4.3.0":
version "4.3.0"
resolved "https://registry.yarnpkg.com/@algolia/cache-common/-/cache-common-4.3.0.tgz#3a257b184bce678e524e354c4f4abd3235ccd24d"
integrity sha512-AHTbOn9lk0f5IkjssXXmDgnaZfsUJVZ61sqOH1W3LyJdAscDzCj0KtwijELn8FHlLXQak7+K93/O3Oct0uHncQ==
"@algolia/cache-in-memory@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@algolia/cache-in-memory/-/cache-in-memory-4.1.0.tgz#cb9b575df1ebe3befd198a50a444a7d181e50853"
integrity sha512-2382OXYFDeoPLA5vP9KP58ad15ows24ML5/io/T1N0xsZ0eVXDkT52qgaJw/esUfEkWScZ2R8kpesUa+qEP+kw==
"@algolia/cache-in-memory@4.4.0":
version "4.4.0"
resolved "https://registry.yarnpkg.com/@algolia/cache-in-memory/-/cache-in-memory-4.4.0.tgz#54a089094c2afa5b9cacab4b60a5f1ba29013a7c"
integrity sha512-9+XlUB0baDU/Dp9URRHPp6Q37YmTO0QmgPWt9+n+wqZrRL0jR3Jezr4jCT7RemqGMxBiR+YpnqaUv0orpb0ptw==
dependencies:
"@algolia/cache-common" "4.1.0"
"@algolia/cache-common" "4.4.0"
"@algolia/client-account@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@algolia/client-account/-/client-account-4.1.0.tgz#a31d26c22e6a56554ea4aa8552d153b1a1aa4363"
integrity sha512-GFINlsxAHM/GEeDBjoTx8+J1ra9SINQCuXi2C9QSLFClPKug2lzApm8niJJGXckhyZ2aDLb7drJ1qJ8bTspApw==
"@algolia/client-account@4.4.0":
version "4.4.0"
resolved "https://registry.yarnpkg.com/@algolia/client-account/-/client-account-4.4.0.tgz#7dbeff83e1c85d853b3ad224674a924e02b94d1b"
integrity sha512-Kynu3cMEs0clTLf674rtrCF+FWR/JwlQxKlIWsPzvLBRmNXdvYej9YBcNaOr4OTQFCCZn9JVE8ib91Z7J4IL1Q==
dependencies:
"@algolia/client-common" "4.1.0"
"@algolia/client-search" "4.1.0"
"@algolia/transporter" "4.1.0"
"@algolia/client-common" "4.4.0"
"@algolia/client-search" "4.4.0"
"@algolia/transporter" "4.4.0"
"@algolia/client-analytics@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@algolia/client-analytics/-/client-analytics-4.1.0.tgz#eb05ccb636351b2d6494b2affb6034b791236998"
integrity sha512-JMyZ9vXGbTJWiO66fWEu9uJ7GSYfouUyaq8W/6esADPtBbelf+Nc0NRlicOwHHJGwiJvWdvELafxrhkR1+KR8A==
"@algolia/client-analytics@4.4.0":
version "4.4.0"
resolved "https://registry.yarnpkg.com/@algolia/client-analytics/-/client-analytics-4.4.0.tgz#50dde68b067c615fc91434c98db9b5ca429be33d"
integrity sha512-GQyjQimKAc9sZbafxln9Wk7j4pEYiORv28MZkZ+0Bjt7WNXIeO7OgOOECVpQHm9buyV6hCKpNtJcbb5/syRzdQ==
dependencies:
"@algolia/client-common" "4.1.0"
"@algolia/client-search" "4.1.0"
"@algolia/requester-common" "4.1.0"
"@algolia/transporter" "4.1.0"
"@algolia/client-common" "4.4.0"
"@algolia/client-search" "4.4.0"
"@algolia/requester-common" "4.4.0"
"@algolia/transporter" "4.4.0"
"@algolia/client-common@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-4.1.0.tgz#cd3a71cef1e0d87476252cbee20b0da938f6221c"
integrity sha512-fjSMKeG54vAyQAhf+uz039/birTiLun8nDuCNx4CUbzGl97M0g96Q8jpsiZa0cjSNgh0VakMzn2GnHbS55W9/Q==
"@algolia/client-common@4.4.0":
version "4.4.0"
resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-4.4.0.tgz#b9fa987bc7a148f9756da59ada51fe2494a4aa9a"
integrity sha512-a3yr6UhzjWPHDG/8iGp9UvrDOm1aeHVWJIf0Nj/cIvqX5tNCEIo4IMe59ovApkDgLOIpt/cLsyhn9/FiPXRhJA==
dependencies:
"@algolia/requester-common" "4.1.0"
"@algolia/transporter" "4.1.0"
"@algolia/requester-common" "4.4.0"
"@algolia/transporter" "4.4.0"
"@algolia/client-common@4.3.0":
version "4.3.0"
resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-4.3.0.tgz#d386f67a8068e5ca2d2a00d37fab10a653744951"
integrity sha512-8Ohj6zXZkpwDKc8ZWVTZo2wPO4+LT5D258suGg/C6nh4UxOrFOp6QaqeQo8JZ1eqMqtfb3zv5SHgW4fZ00NCLQ==
"@algolia/client-recommendation@4.4.0":
version "4.4.0"
resolved "https://registry.yarnpkg.com/@algolia/client-recommendation/-/client-recommendation-4.4.0.tgz#82410f7a346ed8518b8dcd28bc47571e850ab74f"
integrity sha512-sBszbQH46rko6w2fdEG77ma8+fAg0SDkLZGxWhv4trgcnYGUBFl2dcpEPt/6koto9b4XYlf+eh+qi6iGvYqRPg==
dependencies:
"@algolia/requester-common" "4.3.0"
"@algolia/transporter" "4.3.0"
"@algolia/client-common" "4.4.0"
"@algolia/requester-common" "4.4.0"
"@algolia/transporter" "4.4.0"
"@algolia/client-recommendation@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@algolia/client-recommendation/-/client-recommendation-4.1.0.tgz#a0a26de4a6dd902d7ca55cf381cce3a7280d5b49"
integrity sha512-UEN/QgQwVtVH++yAs2uTuyZZQQ1p5Xs/7/FKT4Kh9/8NAyqDD49zuyq/giw8PRNhWc3C/9jiO7X4RKE8QrVWGw==
"@algolia/client-search@4.4.0", "@algolia/client-search@^4.4.0":
version "4.4.0"
resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-4.4.0.tgz#c1e107206f3ae719cd3a9877889eea5e5cbcdc62"
integrity sha512-jqWcxCUyPPHnHreoMb2PnN9iHTP+V/nL62R84XuTRDE3VgTnhm4ZnqyuRdzZQqaz+gNy5znav64TmQ9FN9WW5g==
dependencies:
"@algolia/client-common" "4.1.0"
"@algolia/requester-common" "4.1.0"
"@algolia/transporter" "4.1.0"
"@algolia/client-common" "4.4.0"
"@algolia/requester-common" "4.4.0"
"@algolia/transporter" "4.4.0"
"@algolia/client-search@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-4.1.0.tgz#07cc422af997e409968d3b74142e984aa71ae38c"
integrity sha512-bpCYMEXUdyiopEBSHHwnrRhNEwOLstIeb0Djz+/pVuTXEr3Xg3JUoAZ8xFsCVldcXaZQpbi1/T0y3ky6xUVzfw==
"@algolia/logger-common@4.4.0":
version "4.4.0"
resolved "https://registry.yarnpkg.com/@algolia/logger-common/-/logger-common-4.4.0.tgz#8115d95d5f6227f0127d33130a9c4622cde64f6f"
integrity sha512-2vjmSENLaKNuF+ytRDysfWxxgFG95WXCHwHbueThdPMCK3hskkwqJ0Y/pugKfzl+54mZxegb4BYfgcCeuaHVUw==
"@algolia/logger-console@4.4.0":
version "4.4.0"
resolved "https://registry.yarnpkg.com/@algolia/logger-console/-/logger-console-4.4.0.tgz#1e0eaaf0879f152f9a1fa333c4cd8cb55e071552"
integrity sha512-st/GUWyKvr6YM72OOfF+RmpdVGda3BPXbQ+chpntUq1WyVkyZXGjSmH1IcBVlua27GzxabwOUYON39cF3x10/g==
dependencies:
"@algolia/client-common" "4.1.0"
"@algolia/requester-common" "4.1.0"
"@algolia/transporter" "4.1.0"
"@algolia/logger-common" "4.4.0"
"@algolia/client-search@4.3.0":
version "4.3.0"
resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-4.3.0.tgz#9f28df3b97d0b26605b9d6c5e69ea0df39e81c53"
integrity sha512-KCgcIsNMW1/0F5OILiFTddbTAKduJHRvXQS4NxY1H9gQWMTVeWJS7VZQ/ukKBiUMLatwUQHJz2qpYm9fmqOjkQ==
"@algolia/requester-browser-xhr@4.4.0":
version "4.4.0"
resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.4.0.tgz#f5877397ed92d2d64d08846ea969aeb559a5efb6"
integrity sha512-V3a4hXlNch355GnWaT1f5QfXhROpsjT6sd0Znq29gAhwLqfBExhLW6Khdkv5pENC0Qy7ClVhdXFrBL9QCQer1g==
dependencies:
"@algolia/client-common" "4.3.0"
"@algolia/requester-common" "4.3.0"
"@algolia/transporter" "4.3.0"
"@algolia/requester-common" "4.4.0"
"@algolia/logger-common@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@algolia/logger-common/-/logger-common-4.1.0.tgz#05608dee38dfa35bfe37874683760140d471bfdc"
integrity sha512-QrE4Srf1LB7ekLzl68bFqlTrv7Wk7+GpsaGfB4xFZ9Pfv89My9p7qTVqdLlA44hEFY3fZ9csJp1/PFVucgNB4w==
"@algolia/requester-common@4.4.0":
version "4.4.0"
resolved "https://registry.yarnpkg.com/@algolia/requester-common/-/requester-common-4.4.0.tgz#0e977939aae32ff81a6d27480a71771a65db6051"
integrity sha512-jPinHlFJEFokxQ5b3JWyjQKKn+FMy0hH99PApzOgQAYOSiFRXiPEZp6LeIexDeLLu7Y3eRt/3nHvjPKa6PmRRw==
"@algolia/logger-common@4.3.0":
version "4.3.0"
resolved "https://registry.yarnpkg.com/@algolia/logger-common/-/logger-common-4.3.0.tgz#ab01dd0458f9e5c1dd8e9ea43d604d7e4b76ad33"
integrity sha512-vQ+aukjZkRAyO9iyINBefT366UtF/B9QoA1Kw8PlY67T6fYmklFgYp3LNH/e7h/gz0py5LYY/HIwSsaTKk8/VQ==
"@algolia/logger-console@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@algolia/logger-console/-/logger-console-4.1.0.tgz#099ee86716aea4c976345417397ddfa1338a5acc"
integrity sha512-sKELkiKIrj/tPRAdhOPNI0UxhK2uiIUXnGs/3ztAif6QX7vyE3lY19sj5pIVJctRvl8LW2UlzpBFGlcCDkho9Q==
"@algolia/requester-node-http@4.4.0":
version "4.4.0"
resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-4.4.0.tgz#6ffba93d54eeadf64cb1be67fae5c4e3f7c8f390"
integrity sha512-b7HC9C/GHxiV4+0GpCRTtjscvwarPr3dGm4CAhb6AkNjgjRcFUNr1NfsF75w3WVmzmt79/7QZihddztDdVMGjw==
dependencies:
"@algolia/logger-common" "4.1.0"
"@algolia/requester-common" "4.4.0"
"@algolia/requester-browser-xhr@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.1.0.tgz#a7ab63f184f3d0aa8e85ac73ce39c528271c6d9b"
integrity sha512-bLMfIAkOLs1/vGA09yxU0N5+bE0fSSvEH2ySqVssfWLMP+KRAvby2Goxm8BgI9xLkOvLbhazfQ4Ov2448VvA1g==
"@algolia/transporter@4.4.0":
version "4.4.0"
resolved "https://registry.yarnpkg.com/@algolia/transporter/-/transporter-4.4.0.tgz#6ec79aac43bc515c8e4f6d6e27dc8d8cd7112f7e"
integrity sha512-Xxzq91DEEeKIzT3DU46n4LEyTGAKZNtSHc2H9wvIY5MYwhZwEribmXXZ6k8W1FvBvzggv3juu0SP+xwGoR7F0w==
dependencies:
"@algolia/requester-common" "4.1.0"
"@algolia/requester-common@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@algolia/requester-common/-/requester-common-4.1.0.tgz#91907e9963e455b11862d1cca02fc1d1d961dbce"
integrity sha512-Cy0ciOv5uIm6wF+uLc9DHhxgPJtYQuy1f//hwJcW5mlPX/prPgxWwLXzWyyA+Ca7uU3q+0Y3cIFvEWM5pDxMEg==
"@algolia/requester-common@4.3.0":
version "4.3.0"
resolved "https://registry.yarnpkg.com/@algolia/requester-common/-/requester-common-4.3.0.tgz#1529e51082a9b43d324290f3c07b6acb7cc34cd8"
integrity sha512-1v73KyspJBiTzfyXupjHxikxTYjh5MoxI6mOIvAtQxRqc4ehUPAEdPCNHEvvLiCK96iKWzZaULmV0U7pj3yvTw==
"@algolia/requester-node-http@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-4.1.0.tgz#db0a224538691f6fab18ced27c548cf3b4017689"
integrity sha512-tXp6Pjx9dFgM5ccW6YfEN6v2Zqq8uGwhS1pyq03/aRYRBK60LptjG5jo++vrOytrQDOnIjcZtQzBQch2GjCVmw==
dependencies:
"@algolia/requester-common" "4.1.0"
"@algolia/transporter@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@algolia/transporter/-/transporter-4.1.0.tgz#18cb8837ca4079a23572a3b7dbefece71fb6fff3"
integrity sha512-Z7PjHazSC+KFLDuCFOjvRNgLfh7XOE4tXi0a9O3gBRup4Sk3VQCfTw4ygCF3rRx6uYbq192efLu0nL1E9azxLA==
dependencies:
"@algolia/cache-common" "4.1.0"
"@algolia/logger-common" "4.1.0"
"@algolia/requester-common" "4.1.0"
"@algolia/transporter@4.3.0":
version "4.3.0"
resolved "https://registry.yarnpkg.com/@algolia/transporter/-/transporter-4.3.0.tgz#17dcafcd20bb30d2bef8886c34e86c5d47e1c560"
integrity sha512-BTKHAtdQdfOJ0xzZkiyEK/2QVQJTiVgBZlOBfXp2gBtztjV26OqfW4n6Xz0o7eBRzLEwY1ot3mHF5QIVUjAsMg==
dependencies:
"@algolia/cache-common" "4.3.0"
"@algolia/logger-common" "4.3.0"
"@algolia/requester-common" "4.3.0"
"@algolia/cache-common" "4.4.0"
"@algolia/logger-common" "4.4.0"
"@algolia/requester-common" "4.4.0"
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4":
version "7.10.4"
@@ -2787,25 +2746,25 @@ ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.0, ajv@^6.12.2, ajv@^6.5.5:
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"
algoliasearch@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-4.1.0.tgz#d422ac0d115497021a6c96f4b9747dbaa63f164a"
integrity sha512-0lzjvqQZkJYPuv7LyQauMIMCFFzJWfUf3m9KuHjmFubwbnTDa87KCMXKouMJ0kWXXt6nTLNt0+2YRREOWx2PHw==
algoliasearch@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-4.4.0.tgz#25c356d8bdcf7e3f941633f61e1ac111ddcba404"
integrity sha512-Ag3wxe/nSodNl/1KbHibtkh7TNLptKE300/wnGVtszRjXivaWD6333nUpCumrYObHym/fHMHyLcmQYezXbAIWQ==
dependencies:
"@algolia/cache-browser-local-storage" "4.1.0"
"@algolia/cache-common" "4.1.0"
"@algolia/cache-in-memory" "4.1.0"
"@algolia/client-account" "4.1.0"
"@algolia/client-analytics" "4.1.0"
"@algolia/client-common" "4.1.0"
"@algolia/client-recommendation" "4.1.0"
"@algolia/client-search" "4.1.0"
"@algolia/logger-common" "4.1.0"
"@algolia/logger-console" "4.1.0"
"@algolia/requester-browser-xhr" "4.1.0"
"@algolia/requester-common" "4.1.0"
"@algolia/requester-node-http" "4.1.0"
"@algolia/transporter" "4.1.0"
"@algolia/cache-browser-local-storage" "4.4.0"
"@algolia/cache-common" "4.4.0"
"@algolia/cache-in-memory" "4.4.0"
"@algolia/client-account" "4.4.0"
"@algolia/client-analytics" "4.4.0"
"@algolia/client-common" "4.4.0"
"@algolia/client-recommendation" "4.4.0"
"@algolia/client-search" "4.4.0"
"@algolia/logger-common" "4.4.0"
"@algolia/logger-console" "4.4.0"
"@algolia/requester-browser-xhr" "4.4.0"
"@algolia/requester-common" "4.4.0"
"@algolia/requester-node-http" "4.4.0"
"@algolia/transporter" "4.4.0"
ansi-align@^3.0.0:
version "3.0.0"