From e48707151a3b17506f4663a7118260ab3bb7958f Mon Sep 17 00:00:00 2001 From: Anton Nikolaev Date: Wed, 16 Sep 2020 06:30:20 -0700 Subject: [PATCH] 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 --- desktop/app/package.json | 3 - .../chrome/plugin-manager/PluginInstaller.tsx | 145 ++--- .../__tests__/PluginInstaller.node.tsx | 183 +++--- .../PluginInstaller.node.tsx.snap | 590 ++++++++++++++++++ desktop/app/src/dispatcher/pluginManager.tsx | 4 +- .../reducers/__tests__/pluginManager.node.tsx | 19 +- desktop/app/src/reducers/pluginManager.tsx | 12 +- desktop/app/src/utils/pluginManager.tsx | 57 -- desktop/plugin-lib/package.json | 4 + .../src/__tests__/getUpdatablePlugins.node.ts | 131 ++++ desktop/plugin-lib/src/getInstalledPlugins.ts | 104 +++ desktop/plugin-lib/src/getNpmHostedPlugins.ts | 46 ++ desktop/plugin-lib/src/getUpdatablePlugins.ts | 120 ++++ desktop/plugin-lib/src/index.ts | 2 + desktop/plugin-lib/src/pluginInstaller.ts | 84 +-- desktop/plugin-lib/src/typeUtils.ts | 15 + desktop/types/npm-api.d.ts | 1 + desktop/yarn.lock | 237 +++---- 18 files changed, 1274 insertions(+), 483 deletions(-) create mode 100644 desktop/app/src/chrome/plugin-manager/__tests__/__snapshots__/PluginInstaller.node.tsx.snap delete mode 100644 desktop/app/src/utils/pluginManager.tsx create mode 100644 desktop/plugin-lib/src/__tests__/getUpdatablePlugins.node.ts create mode 100644 desktop/plugin-lib/src/getInstalledPlugins.ts create mode 100644 desktop/plugin-lib/src/getNpmHostedPlugins.ts create mode 100644 desktop/plugin-lib/src/getUpdatablePlugins.ts create mode 100644 desktop/plugin-lib/src/typeUtils.ts diff --git a/desktop/app/package.json b/desktop/app/package.json index b1e44e019..90c82f516 100644 --- a/desktop/app/package.json +++ b/desktop/app/package.json @@ -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", diff --git a/desktop/app/src/chrome/plugin-manager/PluginInstaller.tsx b/desktop/app/src/chrome/plugin-manager/PluginInstaller.tsx index 79c076dad..ac6fd70a0 100644 --- a/desktop/app/src/chrome/plugin-manager/PluginInstaller.tsx +++ b/desktop/app/src/chrome/plugin-manager/PluginInstaller.tsx @@ -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, -): Map { - 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} /> @@ -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( 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, - findPluginUpdates: ( - currentPlugins: PluginMap, - ) => Promise<[string, UpdateResult][]>, + onInstall: () => void, + installedPlugins: InstalledPluginDetails[], ): TableRows_immutable { - const index = useMemo(searchClientFactory, []); - useEffect(() => { reportUsage(`${TAG}:open`); }, []); + const [searchResults, setSearchResults] = useState( + [], + ); + 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>(new Map()); - useEffect(() => { (async () => { let cancelled = false; - const {hits} = await reportPlatformFailures( - index.search('', { - query, - filters: 'keywords:flipper-plugin', - hitsPerPage: 20, - }) as Promise>, + 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( ({pluginManager: {installedPlugins}}) => ({ installedPlugins, }), (dispatch: Dispatch>) => ({ refreshInstalledPlugins: () => { - getPendingAndInstalledPlugins().then((plugins) => + getInstalledPlugins().then((plugins) => dispatch(registerInstalledPlugins(plugins)), ); }, diff --git a/desktop/app/src/chrome/plugin-manager/__tests__/PluginInstaller.node.tsx b/desktop/app/src/chrome/plugin-manager/__tests__/PluginInstaller.node.tsx index bf4bc1a3e..10a9e5260 100644 --- a/desktop/app/src/chrome/plugin-manager/__tests__/PluginInstaller.node.tsx +++ b/desktop/app/src/chrome/plugin-manager/__tests__/PluginInstaller.node.tsx @@ -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([ - [ - 'example', - { - name: 'example', - version: '0.1.0', - description: 'Gaze into the death crystal', - dir: '/plugins/example', - specVersion: 2, - source: 'src/index.ts', - isDefault: false, - main: 'lib/index.js', - title: 'Example', - id: 'Example', - entry: '/plugins/example/lib/index.js', - }, - ], - [ - 'ricksybusiness', - { - name: 'ricksybusiness', - version: '1.0.0', - description: 'Rick Die Rickpeat', - dir: '/plugins/example', - specVersion: 2, - source: 'src/index.ts', - isDefault: false, - main: 'lib/index.js', - title: 'ricksybusiness', - id: 'ricksybusiness', - entry: '/plugins/ricksybusiness/lib/index.js', - }, - ], - ]); - const updates = new Map([ - ['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", - }, - } - `); +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', + 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, + updateStatus: { + kind: 'not-installed', + version: '0.1.0', + }, +}; + +const samplePluginDetails2: UpdatablePluginDetails = { + 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, + updateStatus: { + kind: 'not-installed', + version: '0.2.0', + }, +}; + +const SEARCH_RESULTS = [samplePluginDetails1, samplePluginDetails2]; + +afterEach(() => { + getUpdatablePluginsMock.mockClear(); +}); + +test('load PluginInstaller list', async () => { + getUpdatablePluginsMock.mockReturnValue(Promise.resolve(SEARCH_RESULTS)); + const component = ( + + + + ); + 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 = ( + + + + ); + const {container, getByText} = render(component); + await waitForElement(() => getByText('hello')); + expect(getUpdatablePluginsMock.mock.calls.length).toBe(1); + expect(container).toMatchSnapshot(); }); diff --git a/desktop/app/src/chrome/plugin-manager/__tests__/__snapshots__/PluginInstaller.node.tsx.snap b/desktop/app/src/chrome/plugin-manager/__tests__/__snapshots__/PluginInstaller.node.tsx.snap new file mode 100644 index 000000000..47216530b --- /dev/null +++ b/desktop/app/src/chrome/plugin-manager/__tests__/__snapshots__/PluginInstaller.node.tsx.snap @@ -0,0 +1,590 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`load PluginInstaller list 1`] = ` +
+
+
+ +
+
+
+
+
+
+
+ Name +
+
+
+
+
+
+ Version +
+
+
+
+
+
+ Description +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ + hello + +
+
+ + 0.1.0 + +
+
+
+ + World? + +
+ +
+ +
+
+
+
+ Install +
+
+
+
+
+ + world + +
+
+ + 0.2.0 + +
+
+
+ + Hello? + +
+ +
+ +
+
+
+
+ Install +
+
+
+
+
+
+
+
+ +
+ dots-3-circle +
+
+
+
+
+
+
+
+
+
+ Install +
+
+
+
+
+
+`; + +exports[`load PluginInstaller list with one plugin installed 1`] = ` +
+
+
+ +
+
+
+
+
+
+
+ Name +
+
+
+
+
+
+ Version +
+
+
+
+
+
+ Description +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ + hello + +
+
+ + 0.1.0 + +
+
+
+ + World? + +
+ +
+ +
+
+
+
+ Remove +
+
+
+
+
+ + world + +
+
+ + 0.2.0 + +
+
+
+ + Hello? + +
+ +
+ +
+
+
+
+ Install +
+
+
+
+
+
+
+
+ +
+ dots-3-circle +
+
+
+
+
+
+
+
+
+
+ Install +
+
+
+
+
+
+`; diff --git a/desktop/app/src/dispatcher/pluginManager.tsx b/desktop/app/src/dispatcher/pluginManager.tsx index d33e8a732..4f831fe1d 100644 --- a/desktop/app/src/dispatcher/pluginManager.tsx +++ b/desktop/app/src/dispatcher/pluginManager.tsx @@ -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)), ); } diff --git a/desktop/app/src/reducers/__tests__/pluginManager.node.tsx b/desktop/app/src/reducers/__tests__/pluginManager.node.tsx index 0d3dfc6cf..85aed7787 100644 --- a/desktop/app/src/reducers/__tests__/pluginManager.node.tsx +++ b/desktop/app/src/reducers/__tests__/pluginManager.node.tsx @@ -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: []}); }); diff --git a/desktop/app/src/reducers/pluginManager.tsx b/desktop/app/src/reducers/pluginManager.tsx index 74857b501..0b9871de2 100644 --- a/desktop/app/src/reducers/pluginManager.tsx +++ b/desktop/app/src/reducers/pluginManager.tsx @@ -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, }); diff --git a/desktop/app/src/utils/pluginManager.tsx b/desktop/app/src/utils/pluginManager.tsx deleted file mode 100644 index f40b13bc9..000000000 --- a/desktop/app/src/utils/pluginManager.tsx +++ /dev/null @@ -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}]), - ), - ); -} diff --git a/desktop/plugin-lib/package.json b/desktop/plugin-lib/package.json index 0c12cc39e..075d716b0 100644 --- a/desktop/plugin-lib/package.json +++ b/desktop/plugin-lib/package.json @@ -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" }, diff --git a/desktop/plugin-lib/src/__tests__/getUpdatablePlugins.node.ts b/desktop/plugin-lib/src/__tests__/getUpdatablePlugins.node.ts new file mode 100644 index 000000000..379f88f34 --- /dev/null +++ b/desktop/plugin-lib/src/__tests__/getUpdatablePlugins.node.ts @@ -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", + } + `); +}); diff --git a/desktop/plugin-lib/src/getInstalledPlugins.ts b/desktop/plugin-lib/src/getInstalledPlugins.ts new file mode 100644 index 000000000..16e517969 --- /dev/null +++ b/desktop/plugin-lib/src/getInstalledPlugins.ts @@ -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 { + 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 { + 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 { + const map = new Map( + (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; +} diff --git a/desktop/plugin-lib/src/getNpmHostedPlugins.ts b/desktop/plugin-lib/src/getNpmHostedPlugins.ts new file mode 100644 index 000000000..200403498 --- /dev/null +++ b/desktop/plugin-lib/src/getNpmHostedPlugins.ts @@ -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 { + const index = provideSearchIndex(); + args = Object.assign( + { + query: '', + filters: 'keywords:flipper-plugin', + hitsPerPage: 50, + }, + args, + ); + const {hits} = await index.search( + args.query || '', + args, + ); + return hits; +} diff --git a/desktop/plugin-lib/src/getUpdatablePlugins.ts b/desktop/plugin-lib/src/getUpdatablePlugins.ts new file mode 100644 index 000000000..ba325e717 --- /dev/null +++ b/desktop/plugin-lib/src/getUpdatablePlugins.ts @@ -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 { + const npmApi = new NpmApi(); + const installedPlugins = await getInstalledPlugins(); + const npmHostedPlugins = new Map( + (await getNpmHostedPlugins()).map((p) => [p.name, p]), + ); + const annotatedInstalledPlugins = await pmap( + installedPlugins, + async (installedPlugin): Promise => { + 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)), + ]; +} diff --git a/desktop/plugin-lib/src/index.ts b/desktop/plugin-lib/src/index.ts index d9e98e764..68c79f310 100644 --- a/desktop/plugin-lib/src/index.ts +++ b/desktop/plugin-lib/src/index.ts @@ -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'; diff --git a/desktop/plugin-lib/src/pluginInstaller.ts b/desktop/plugin-lib/src/pluginInstaller.ts index 11d7bdb3e..9b3823390 100644 --- a/desktop/plugin-lib/src/pluginInstaller.ts +++ b/desktop/plugin-lib/src/pluginInstaller.ts @@ -23,8 +23,6 @@ import { } from './pluginPaths'; import semver from 'semver'; -export type PluginMap = Map; - const getTmpDir = promisify(tmp.dir) as () => Promise; 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 { - 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 { - 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 { - 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 { - await fs.remove(getPluginInstallationDir(name)); + await Promise.all([ + fs.remove(getPluginInstallationDir(name)), + fs.remove(getPluginPendingInstallationsDir(name)), + ]); } export async function finishPendingPluginInstallations() { diff --git a/desktop/plugin-lib/src/typeUtils.ts b/desktop/plugin-lib/src/typeUtils.ts new file mode 100644 index 000000000..1b173d37a --- /dev/null +++ b/desktop/plugin-lib/src/typeUtils.ts @@ -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(x: T | null | undefined): x is T { + return x !== null && x !== undefined; +} diff --git a/desktop/types/npm-api.d.ts b/desktop/types/npm-api.d.ts index 5e001d23c..2a47cb140 100644 --- a/desktop/types/npm-api.d.ts +++ b/desktop/types/npm-api.d.ts @@ -32,5 +32,6 @@ declare module 'npm-api' { export interface Package { name: string; version: string; + [name: string]: string; } } diff --git a/desktop/yarn.lock b/desktop/yarn.lock index ea9e5e25e..ade567eb1 100644 --- a/desktop/yarn.lock +++ b/desktop/yarn.lock @@ -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"