Keep multiple installed versions of each plugin
Summary:
This diff changes directory structure for installed plugins to allow installation of multiple versions simultaneously, e.g. to to allow downloading new plugin version while user is still using the previous one, and to have possibility of fast rollback to the previous installed if necessary. The new folder for installed plugins is located in `~/.flipper/installed-plugins` and has the following structure:
flipper-plugin-reactotron
1.0.0
...
package.json
1.0.1
...
package.json
flipper-plugin-network
0.67.1
...
package.json
0.67.2
...
package.json
The tricky part here is that we also need to migrate already installed plugins from the old folder `~/.flipper/thirdparty` to the new folder and maintain the new structure for them.
Another tricky part is that we need to periodically cleanup old versions. For now we will just keep 2 versions of each plugin. Cleanup is performed in background right after Flipper startup.
Reviewed By: mweststrate
Differential Revision: D25393474
fbshipit-source-id: 26617ac26114148f797cc3d6765a42242edc205e
This commit is contained in:
committed by
Facebook GitHub Bot
parent
9c5f59e109
commit
02d695cb28
@@ -39,6 +39,7 @@
|
||||
"lodash.memoize": "^4.1.2",
|
||||
"open": "^7.0.0",
|
||||
"openssl-wrapper": "^0.3.4",
|
||||
"p-filter": "^2.1.0",
|
||||
"p-map": "^4.0.0",
|
||||
"promise-retry": "^2.0.1",
|
||||
"promisify-child-process": "^4.1.0",
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
getUpdatablePlugins,
|
||||
removePlugin,
|
||||
UpdatablePluginDetails,
|
||||
InstalledPluginDetails,
|
||||
PluginDetails,
|
||||
} from 'flipper-plugin-lib';
|
||||
import {installPluginFromNpm} from 'flipper-plugin-lib';
|
||||
import {State as AppState} from '../../reducers';
|
||||
@@ -92,7 +92,7 @@ const RestartBar = styled(FlexColumn)({
|
||||
});
|
||||
|
||||
type PropsFromState = {
|
||||
installedPlugins: InstalledPluginDetails[];
|
||||
installedPlugins: PluginDetails[];
|
||||
};
|
||||
|
||||
type DispatchFromProps = {
|
||||
@@ -289,7 +289,7 @@ function InstallButton(props: {
|
||||
function useNPMSearch(
|
||||
query: string,
|
||||
onInstall: () => void,
|
||||
installedPlugins: InstalledPluginDetails[],
|
||||
installedPlugins: PluginDetails[],
|
||||
): TableRows_immutable {
|
||||
useEffect(() => {
|
||||
reportUsage(`${TAG}:open`);
|
||||
|
||||
@@ -14,14 +14,14 @@ 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 type {PluginDetails} 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 {
|
||||
function getStore(installedPlugins: PluginDetails[] = []): Store {
|
||||
return configureStore([])({
|
||||
application: {sessionId: 'mysession'},
|
||||
pluginManager: {installedPlugins},
|
||||
@@ -95,9 +95,7 @@ test('load PluginInstaller list with one plugin installed', async () => {
|
||||
samplePluginDetails2,
|
||||
]),
|
||||
);
|
||||
const store = getStore([
|
||||
{...samplePluginDetails1, installationStatus: 'installed'},
|
||||
]);
|
||||
const store = getStore([samplePluginDetails1]);
|
||||
const component = (
|
||||
<Provider store={store}>
|
||||
<PluginInstaller
|
||||
|
||||
@@ -10,12 +10,17 @@
|
||||
import {Store} from '../reducers/index';
|
||||
import {Logger} from '../fb-interfaces/Logger';
|
||||
import {registerInstalledPlugins} from '../reducers/pluginManager';
|
||||
import {getInstalledPlugins} from 'flipper-plugin-lib';
|
||||
import {
|
||||
getInstalledPlugins,
|
||||
cleanupOldInstalledPluginVersions,
|
||||
} from 'flipper-plugin-lib';
|
||||
|
||||
const maxInstalledPluginVersionsToKeep = 2;
|
||||
|
||||
function refreshInstalledPlugins(store: Store) {
|
||||
getInstalledPlugins().then((plugins) =>
|
||||
store.dispatch(registerInstalledPlugins(plugins)),
|
||||
);
|
||||
cleanupOldInstalledPluginVersions(maxInstalledPluginVersionsToKeep)
|
||||
.then(() => getInstalledPlugins())
|
||||
.then((plugins) => store.dispatch(registerInstalledPlugins(plugins)));
|
||||
}
|
||||
|
||||
export default (store: Store, _logger: Logger) => {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import {default as reducer, registerInstalledPlugins} from '../pluginManager';
|
||||
import {InstalledPluginDetails} from 'flipper-plugin-lib';
|
||||
import {PluginDetails} from 'flipper-plugin-lib';
|
||||
|
||||
test('reduce empty registerInstalledPlugins', () => {
|
||||
const result = reducer(undefined, registerInstalledPlugins([]));
|
||||
@@ -27,8 +27,7 @@ const EXAMPLE_PLUGIN = {
|
||||
title: 'test',
|
||||
id: 'test',
|
||||
entry: '/plugins/test/lib/index.js',
|
||||
installationStatus: 'installed',
|
||||
} as InstalledPluginDetails;
|
||||
} as PluginDetails;
|
||||
|
||||
test('reduce registerInstalledPlugins, clear again', () => {
|
||||
const result = reducer(undefined, registerInstalledPlugins([EXAMPLE_PLUGIN]));
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
*/
|
||||
|
||||
import {Actions} from './';
|
||||
import {InstalledPluginDetails} from 'flipper-plugin-lib';
|
||||
import {PluginDetails} from 'flipper-plugin-lib';
|
||||
|
||||
export type State = {
|
||||
installedPlugins: InstalledPluginDetails[];
|
||||
installedPlugins: PluginDetails[];
|
||||
};
|
||||
|
||||
export type Action = {
|
||||
type: 'REGISTER_INSTALLED_PLUGINS';
|
||||
payload: InstalledPluginDetails[];
|
||||
payload: PluginDetails[];
|
||||
};
|
||||
|
||||
const INITIAL_STATE: State = {
|
||||
@@ -37,9 +37,7 @@ export default function reducer(
|
||||
}
|
||||
}
|
||||
|
||||
export const registerInstalledPlugins = (
|
||||
payload: InstalledPluginDetails[],
|
||||
): Action => ({
|
||||
export const registerInstalledPlugins = (payload: PluginDetails[]): Action => ({
|
||||
type: 'REGISTER_INSTALLED_PLUGINS',
|
||||
payload,
|
||||
});
|
||||
|
||||
@@ -9,20 +9,16 @@
|
||||
|
||||
import path from 'path';
|
||||
import fs from 'fs-extra';
|
||||
import pMap from 'p-map';
|
||||
import {
|
||||
PluginDetails,
|
||||
getSourcePlugins,
|
||||
getInstalledPlugins,
|
||||
finishPendingPluginInstallations,
|
||||
moveInstalledPluginsFromLegacyDir,
|
||||
} from 'flipper-plugin-lib';
|
||||
import os from 'os';
|
||||
import {getStaticPath} from '../utils/pathUtils';
|
||||
|
||||
const pluginCache = path.join(os.homedir(), '.flipper', 'plugins');
|
||||
|
||||
// Load "dynamic" plugins, e.g. those which are either installed or loaded from sources for development purposes.
|
||||
// This opposed to "static" plugins which are already included into Flipper bundle.
|
||||
// This opposed to "default" plugins which are included into Flipper bundle.
|
||||
export default async function loadDynamicPlugins(): Promise<PluginDetails[]> {
|
||||
if (process.env.FLIPPER_FAST_REFRESH) {
|
||||
console.log(
|
||||
@@ -30,63 +26,38 @@ export default async function loadDynamicPlugins(): Promise<PluginDetails[]> {
|
||||
);
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
await finishPendingPluginInstallations();
|
||||
} catch (err) {
|
||||
console.error('❌ Failed to finish pending installations', err);
|
||||
}
|
||||
await moveInstalledPluginsFromLegacyDir().catch((ex) =>
|
||||
console.error(
|
||||
'Eror while migrating installed plugins from legacy folder',
|
||||
ex,
|
||||
),
|
||||
);
|
||||
const staticPath = getStaticPath();
|
||||
const defaultPlugins = new Set<string>(
|
||||
(
|
||||
await fs.readJson(path.join(staticPath, 'defaultPlugins', 'index.json'))
|
||||
).map((p: any) => p.name) as string[],
|
||||
);
|
||||
const dynamicPlugins = [
|
||||
...(await getInstalledPlugins()),
|
||||
...(await getSourcePlugins()).filter((p) => !defaultPlugins.has(p.name)),
|
||||
];
|
||||
await fs.ensureDir(pluginCache);
|
||||
const compilations = pMap(
|
||||
dynamicPlugins,
|
||||
(plugin) => {
|
||||
return loadPlugin(plugin);
|
||||
},
|
||||
{concurrency: 4},
|
||||
const [installedPlugins, unfilteredSourcePlugins] = await Promise.all([
|
||||
getInstalledPlugins(),
|
||||
getSourcePlugins(),
|
||||
]);
|
||||
const sourcePlugins = unfilteredSourcePlugins.filter(
|
||||
(p) => !defaultPlugins.has(p.name),
|
||||
);
|
||||
const compiledDynamicPlugins = (await compilations).filter(
|
||||
(c) => c !== null,
|
||||
) as PluginDetails[];
|
||||
if (installedPlugins.length > 0) {
|
||||
console.log(
|
||||
`✅ Loaded ${dynamicPlugins.length} dynamic plugins: ${dynamicPlugins
|
||||
`✅ Loaded ${
|
||||
installedPlugins.length
|
||||
} installed plugins: ${installedPlugins.map((x) => x.title).join(', ')}.`,
|
||||
);
|
||||
}
|
||||
if (sourcePlugins.length > 0) {
|
||||
console.log(
|
||||
`✅ Loaded ${sourcePlugins.length} source plugins: ${sourcePlugins
|
||||
.map((x) => x.title)
|
||||
.join(', ')}.`,
|
||||
);
|
||||
return compiledDynamicPlugins;
|
||||
}
|
||||
async function loadPlugin(
|
||||
pluginDetails: PluginDetails,
|
||||
): Promise<PluginDetails | null> {
|
||||
const {specVersion, version, entry, name} = pluginDetails;
|
||||
if (specVersion > 1) {
|
||||
if (await fs.pathExists(entry)) {
|
||||
return pluginDetails;
|
||||
} else {
|
||||
console.error(
|
||||
`❌ Plugin ${name} is ignored, because its entry point not found: ${entry}.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// Try to load cached version of legacy plugin
|
||||
const entry = path.join(pluginCache, `${name}@${version || '0.0.0'}.js`);
|
||||
if (await fs.pathExists(entry)) {
|
||||
console.log(`🥫 Using cached version of legacy plugin ${name}...`);
|
||||
return pluginDetails;
|
||||
} else {
|
||||
console.error(
|
||||
`❌ Plugin ${name} is ignored, because it is defined by the unsupported spec v1 and could not be compiled.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return [...installedPlugins, ...sourcePlugins];
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* @format
|
||||
*/
|
||||
|
||||
jest.mock('../getInstalledPlugins');
|
||||
jest.mock('../pluginInstaller');
|
||||
jest.mock('../getNpmHostedPlugins');
|
||||
|
||||
import {getUpdatablePlugins} from '../getUpdatablePlugins';
|
||||
@@ -15,10 +15,10 @@ import {
|
||||
getNpmHostedPlugins,
|
||||
NpmPackageDescriptor,
|
||||
} from '../getNpmHostedPlugins';
|
||||
import type {InstalledPluginDetails} from '../getInstalledPlugins';
|
||||
import {getInstalledPlugins} from '../getInstalledPlugins';
|
||||
import {getInstalledPlugins} from '../pluginInstaller';
|
||||
import {mocked} from 'ts-jest/utils';
|
||||
import type {Package} from 'npm-api';
|
||||
import PluginDetails from '../PluginDetails';
|
||||
|
||||
jest.mock('npm-api', () => {
|
||||
return jest.fn().mockImplementation(() => {
|
||||
@@ -54,7 +54,7 @@ jest.mock('npm-api', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const installedPlugins: InstalledPluginDetails[] = [
|
||||
const installedPlugins: PluginDetails[] = [
|
||||
{
|
||||
name: 'flipper-plugin-hello',
|
||||
entry: './test/index.js',
|
||||
@@ -67,7 +67,6 @@ const installedPlugins: InstalledPluginDetails[] = [
|
||||
title: 'Hello',
|
||||
description: 'World?',
|
||||
isDefault: false,
|
||||
installationStatus: 'installed',
|
||||
},
|
||||
{
|
||||
name: 'flipper-plugin-world',
|
||||
@@ -81,7 +80,6 @@ const installedPlugins: InstalledPluginDetails[] = [
|
||||
title: 'World',
|
||||
description: 'Hello?',
|
||||
isDefault: false,
|
||||
installationStatus: 'pending',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,95 +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 mockfs from 'mock-fs';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import {consoleMock} from 'flipper-test-utils';
|
||||
import {finishPendingPluginInstallations} from '../pluginInstaller';
|
||||
import {
|
||||
pluginPendingInstallationDir,
|
||||
pluginInstallationDir,
|
||||
} from '../pluginPaths';
|
||||
|
||||
describe('pluginInstaller', () => {
|
||||
const realConsole = global.console;
|
||||
global.console = consoleMock as any;
|
||||
|
||||
afterAll(() => {
|
||||
global.console = realConsole;
|
||||
});
|
||||
|
||||
beforeEach(() => {});
|
||||
|
||||
afterEach(() => {
|
||||
mockfs.restore();
|
||||
});
|
||||
|
||||
test('finish pending plugin installations', async () => {
|
||||
mockfs({
|
||||
[pluginPendingInstallationDir]: {
|
||||
'flipper-plugin-test1': {
|
||||
'1.2.0': {
|
||||
'index.ts': '',
|
||||
'package.json': '',
|
||||
},
|
||||
},
|
||||
'flipper-plugin-test2': {
|
||||
'0.3.0': {
|
||||
'index.js': '',
|
||||
'0.3.0.js': '',
|
||||
'package.json': '',
|
||||
},
|
||||
'0.2.0': {
|
||||
'index.js': '',
|
||||
'0.2.0.js': '',
|
||||
'package.json': '',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await finishPendingPluginInstallations();
|
||||
|
||||
expect(await fs.readdir(pluginInstallationDir)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
".watchmanconfig",
|
||||
"flipper-plugin-test1",
|
||||
"flipper-plugin-test2",
|
||||
]
|
||||
`);
|
||||
|
||||
expect(
|
||||
await fs.readdir(
|
||||
path.join(pluginInstallationDir, 'flipper-plugin-test1'),
|
||||
),
|
||||
).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"index.ts",
|
||||
"package.json",
|
||||
]
|
||||
`);
|
||||
|
||||
expect(
|
||||
await fs.readdir(
|
||||
path.join(pluginInstallationDir, 'flipper-plugin-test2'),
|
||||
),
|
||||
).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"0.3.0.js",
|
||||
"index.js",
|
||||
"package.json",
|
||||
]
|
||||
`);
|
||||
|
||||
expect(
|
||||
await fs.readdir(pluginPendingInstallationDir),
|
||||
).toMatchInlineSnapshot(`Array []`);
|
||||
});
|
||||
});
|
||||
@@ -1,104 +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 fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import semver from 'semver';
|
||||
import {
|
||||
pluginPendingInstallationDir,
|
||||
pluginInstallationDir,
|
||||
} from './pluginPaths';
|
||||
import PluginDetails from './PluginDetails';
|
||||
import {getPluginDetailsFromDir} 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 getPluginDetailsFromDir(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 getPluginDetailsFromDir(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;
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import {PluginDetails} from './PluginDetails';
|
||||
import {getPluginInstallationDir, pluginCacheDir} from './pluginPaths';
|
||||
import {getPluginVersionInstallationDir, pluginCacheDir} from './pluginPaths';
|
||||
|
||||
export async function getPluginDetails(pluginDir: string, packageJson: any) {
|
||||
const specVersion =
|
||||
@@ -37,7 +37,10 @@ export async function getPluginDetailsFromDir(
|
||||
}
|
||||
|
||||
export async function getPluginDetailsFromPackageJson(packageJson: any) {
|
||||
const pluginDir = getPluginInstallationDir(packageJson.name);
|
||||
const pluginDir = getPluginVersionInstallationDir(
|
||||
packageJson.name,
|
||||
packageJson.version,
|
||||
);
|
||||
return await getPluginDetails(pluginDir, packageJson);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,19 +8,21 @@
|
||||
*/
|
||||
|
||||
import PluginDetails from './PluginDetails';
|
||||
import {getInstalledPlugins} from './getInstalledPlugins';
|
||||
import {getInstalledPlugins} from './pluginInstaller';
|
||||
import semver from 'semver';
|
||||
import {getNpmHostedPlugins, NpmPackageDescriptor} from './getNpmHostedPlugins';
|
||||
import NpmApi from 'npm-api';
|
||||
import {getPluginDetails} from './getPluginDetails';
|
||||
import {getPluginInstallationDir} from './pluginPaths';
|
||||
import {
|
||||
getPluginDetails,
|
||||
getPluginDetailsFromPackageJson,
|
||||
} from './getPluginDetails';
|
||||
import {getPluginVersionInstallationDir} from './pluginPaths';
|
||||
import pmap from 'p-map';
|
||||
import {notNull} from './typeUtils';
|
||||
const npmApi = new NpmApi();
|
||||
|
||||
export type UpdateResult =
|
||||
| {kind: 'not-installed'; version: string}
|
||||
| {kind: 'pending'}
|
||||
| {kind: 'up-to-date'}
|
||||
| {kind: 'error'; error: Error}
|
||||
| {kind: 'update-available'; version: string};
|
||||
@@ -50,7 +52,10 @@ export async function getUpdatablePlugins(
|
||||
) {
|
||||
const pkg = await npmApi.repo(npmPackageDescriptor.name).package();
|
||||
const npmPluginDetails = await getPluginDetails(
|
||||
getPluginInstallationDir(npmPackageDescriptor.name),
|
||||
getPluginVersionInstallationDir(
|
||||
npmPackageDescriptor.name,
|
||||
npmPackageDescriptor.version,
|
||||
),
|
||||
pkg,
|
||||
);
|
||||
return {
|
||||
@@ -62,10 +67,7 @@ export async function getUpdatablePlugins(
|
||||
};
|
||||
}
|
||||
}
|
||||
const updateStatus: UpdateResult =
|
||||
installedPlugin.installationStatus === 'installed'
|
||||
? {kind: 'up-to-date'}
|
||||
: {kind: 'pending'};
|
||||
const updateStatus: UpdateResult = {kind: 'up-to-date'};
|
||||
return {
|
||||
...installedPlugin,
|
||||
updateStatus,
|
||||
@@ -89,10 +91,7 @@ export async function getUpdatablePlugins(
|
||||
async (notInstalledPlugin) => {
|
||||
try {
|
||||
const pkg = await npmApi.repo(notInstalledPlugin.name).package();
|
||||
const npmPluginDetails = await getPluginDetails(
|
||||
getPluginInstallationDir(notInstalledPlugin.name),
|
||||
pkg,
|
||||
);
|
||||
const npmPluginDetails = await getPluginDetailsFromPackageJson(pkg);
|
||||
if (npmPluginDetails.specVersion === 1) {
|
||||
return null;
|
||||
}
|
||||
@@ -106,6 +105,7 @@ export async function getUpdatablePlugins(
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`Failed to load details from npm for plugin ${notInstalledPlugin.name}`,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
export * from './PluginDetails';
|
||||
export * from './getPluginDetails';
|
||||
export * from './pluginInstaller';
|
||||
export * from './getInstalledPlugins';
|
||||
export * from './getUpdatablePlugins';
|
||||
export * from './getSourcePlugins';
|
||||
export * from './pluginPaths';
|
||||
|
||||
@@ -18,14 +18,16 @@ import tmp from 'tmp';
|
||||
import PluginDetails from './PluginDetails';
|
||||
import {getPluginDetailsFromDir} from './getPluginDetails';
|
||||
import {
|
||||
getPluginInstallationDir,
|
||||
getPluginPendingInstallationDir,
|
||||
getPluginPendingInstallationsDir,
|
||||
pluginInstallationDir,
|
||||
pluginPendingInstallationDir,
|
||||
getPluginVersionInstallationDir,
|
||||
getPluginDirNameFromPackageName,
|
||||
getPluginInstallationDir,
|
||||
pluginInstallationDir,
|
||||
legacyPluginInstallationDir,
|
||||
} from './pluginPaths';
|
||||
import pfilter from 'p-filter';
|
||||
import pmap from 'p-map';
|
||||
import semver from 'semver';
|
||||
import {notNull} from './typeUtils';
|
||||
|
||||
const getTmpDir = promisify(tmp.dir) as () => Promise<string>;
|
||||
|
||||
@@ -39,8 +41,7 @@ async function installPluginFromTempDir(
|
||||
const pluginDetails = await getPluginDetailsFromDir(sourceDir);
|
||||
const {name, version} = pluginDetails;
|
||||
const backupDir = path.join(await getTmpDir(), `${name}-${version}`);
|
||||
const installationsDir = getPluginPendingInstallationsDir(name);
|
||||
const destinationDir = getPluginPendingInstallationDir(name, version);
|
||||
const destinationDir = getPluginVersionInstallationDir(name, version);
|
||||
|
||||
if (pluginDetails.specVersion == 1) {
|
||||
throw new Error(
|
||||
@@ -53,17 +54,7 @@ async function installPluginFromTempDir(
|
||||
if (await fs.pathExists(destinationDir)) {
|
||||
await fs.move(destinationDir, backupDir, {overwrite: true});
|
||||
}
|
||||
|
||||
await fs.move(sourceDir, destinationDir);
|
||||
|
||||
// Cleaning up all the previously downloaded packages, because we've got the newest one.
|
||||
const otherPackages = await fs.readdir(installationsDir);
|
||||
for (const otherPackage of otherPackages) {
|
||||
const otherPackageDir = path.join(installationsDir, otherPackage);
|
||||
if (otherPackageDir !== destinationDir) {
|
||||
await fs.remove(otherPackageDir);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Restore previous version from backup if installation failed
|
||||
await fs.remove(destinationDir);
|
||||
@@ -95,21 +86,15 @@ async function getPluginRootDir(dir: string) {
|
||||
|
||||
export async function getInstalledPlugin(
|
||||
name: string,
|
||||
version: string,
|
||||
): Promise<PluginDetails | null> {
|
||||
const dir = getPluginInstallationDir(name);
|
||||
const dir = getPluginVersionInstallationDir(name, version);
|
||||
if (!(await fs.pathExists(dir))) {
|
||||
return null;
|
||||
}
|
||||
return await getPluginDetailsFromDir(dir);
|
||||
}
|
||||
|
||||
export async function isPluginPendingInstallation(
|
||||
name: string,
|
||||
version: string,
|
||||
) {
|
||||
return await fs.pathExists(getPluginPendingInstallationDir(name, version));
|
||||
}
|
||||
|
||||
export async function installPluginFromNpm(name: string) {
|
||||
const tmpDir = await getTmpDir();
|
||||
try {
|
||||
@@ -146,63 +131,105 @@ export async function installPluginFromFile(
|
||||
}
|
||||
|
||||
export async function removePlugin(name: string): Promise<void> {
|
||||
await Promise.all([
|
||||
fs.remove(getPluginInstallationDir(name)),
|
||||
fs.remove(getPluginPendingInstallationsDir(name)),
|
||||
]);
|
||||
await fs.remove(getPluginInstallationDir(name));
|
||||
}
|
||||
|
||||
export async function finishPendingPluginInstallations() {
|
||||
if (!(await fs.pathExists(pluginPendingInstallationDir))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fs.ensureDir(pluginInstallationDir);
|
||||
// create empty watchman config (required by metro's file watcher)
|
||||
const watchmanConfigPath = path.join(
|
||||
pluginInstallationDir,
|
||||
'.watchmanconfig',
|
||||
export async function getInstalledPlugins(): Promise<PluginDetails[]> {
|
||||
const versionDirs = await getInstalledPluginVersionDirs();
|
||||
return pmap(
|
||||
versionDirs
|
||||
.filter(([_, versionDirs]) => versionDirs.length > 0)
|
||||
.map(([_, versionDirs]) => versionDirs[0]),
|
||||
(latestVersionDir) => getPluginDetailsFromDir(latestVersionDir),
|
||||
);
|
||||
if (!(await fs.pathExists(watchmanConfigPath))) {
|
||||
await fs.writeFile(watchmanConfigPath, '{}');
|
||||
}
|
||||
const pendingPlugins = await fs.readdir(pluginPendingInstallationDir);
|
||||
for (const pendingPlugin of pendingPlugins) {
|
||||
const pendingInstallationsDir = getPluginPendingInstallationsDir(
|
||||
pendingPlugin,
|
||||
|
||||
export async function cleanupOldInstalledPluginVersions(
|
||||
maxNumberOfVersionsToKeep: number,
|
||||
): Promise<void> {
|
||||
const versionDirs = await getInstalledPluginVersionDirs();
|
||||
const versionDirsToDelete = versionDirs
|
||||
.map(([_, versionDirs]) => versionDirs.slice(maxNumberOfVersionsToKeep))
|
||||
.flat();
|
||||
await pmap(versionDirsToDelete, (versionDirToDelete) =>
|
||||
fs.remove(versionDirToDelete).catch(() => {}),
|
||||
);
|
||||
const pendingVersions = (
|
||||
await fs.readdir(pendingInstallationsDir)
|
||||
).sort((v1, v2) => semver.compare(v2, v1, true)); // sort versions in descending order
|
||||
if (pendingVersions.length === 0) {
|
||||
await fs.remove(pendingInstallationsDir);
|
||||
continue;
|
||||
}
|
||||
const version = pendingVersions[0];
|
||||
const pendingInstallation = path.join(pendingInstallationsDir, version);
|
||||
const installationDir = getPluginInstallationDir(pendingPlugin);
|
||||
const backupDir = path.join(await getTmpDir(), pendingPlugin);
|
||||
try {
|
||||
if (await fs.pathExists(installationDir)) {
|
||||
await fs.move(installationDir, backupDir, {overwrite: true});
|
||||
}
|
||||
await fs.move(pendingInstallation, installationDir, {overwrite: true});
|
||||
await fs.remove(pendingInstallationsDir);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Error while finishing pending installation for ${pendingPlugin}`,
|
||||
err,
|
||||
|
||||
// Before that we installed all plugins to "thirdparty" folder and only kept
|
||||
// a single version for each of them. Now we install plugins to "installed-plugins"
|
||||
// folder and keep multiple versions. This function checks if the legacy folder exists and
|
||||
// moves all the plugins installed there to the new folder.
|
||||
export async function moveInstalledPluginsFromLegacyDir() {
|
||||
if (await fs.pathExists(legacyPluginInstallationDir)) {
|
||||
await fs
|
||||
.readdir(legacyPluginInstallationDir)
|
||||
.then((dirs) =>
|
||||
dirs.map((dir) => path.join(legacyPluginInstallationDir, dir)),
|
||||
)
|
||||
.then((dirs) =>
|
||||
pfilter(dirs, (dir) =>
|
||||
fs
|
||||
.lstat(dir)
|
||||
.then((lstat) => lstat.isDirectory())
|
||||
.catch(() => Promise.resolve(false)),
|
||||
),
|
||||
)
|
||||
.then((dirs) =>
|
||||
pmap(dirs, (dir) => getPluginDetailsFromDir(dir).catch(() => null)),
|
||||
)
|
||||
.then((plugins) =>
|
||||
pmap(plugins.filter(notNull), (plugin) =>
|
||||
fs.move(
|
||||
plugin.dir,
|
||||
getPluginVersionInstallationDir(plugin.name, plugin.version),
|
||||
{overwrite: true},
|
||||
),
|
||||
),
|
||||
);
|
||||
// in case of error, keep the previously installed version
|
||||
await fs.remove(installationDir);
|
||||
if (await fs.pathExists(backupDir)) {
|
||||
await fs.move(backupDir, installationDir, {overwrite: true});
|
||||
}
|
||||
} finally {
|
||||
await fs.remove(backupDir);
|
||||
await fs.remove(legacyPluginInstallationDir);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error while finishing plugin pending installations', err);
|
||||
}
|
||||
|
||||
type InstalledPluginVersionDirs = [string, string[]][];
|
||||
|
||||
async function getInstalledPluginVersionDirs(): Promise<
|
||||
InstalledPluginVersionDirs
|
||||
> {
|
||||
return await fs
|
||||
.readdir(pluginInstallationDir)
|
||||
.then((dirs) => dirs.map((dir) => path.join(pluginInstallationDir, dir)))
|
||||
.then((dirs) =>
|
||||
pfilter(dirs, (dir) =>
|
||||
fs
|
||||
.lstat(dir)
|
||||
.then((lstat) => lstat.isDirectory())
|
||||
.catch(() => false),
|
||||
),
|
||||
)
|
||||
.then((dirs) =>
|
||||
pmap(dirs, (dir) =>
|
||||
fs
|
||||
.readdir(dir)
|
||||
.then((versionDirs) =>
|
||||
versionDirs.sort((v1, v2) => semver.compare(v2, v1, true)),
|
||||
)
|
||||
.then((versionDirs) =>
|
||||
versionDirs.map((versionDir) => path.join(dir, versionDir)),
|
||||
)
|
||||
.then((versionDirs) =>
|
||||
pfilter(versionDirs, (versionDir) =>
|
||||
fs
|
||||
.lstat(versionDir)
|
||||
.then((lstat) => lstat.isDirectory())
|
||||
.catch(() => false),
|
||||
),
|
||||
),
|
||||
).then((allDirs) =>
|
||||
allDirs.reduce<InstalledPluginVersionDirs>((agg, versionDirs, i) => {
|
||||
agg.push([dirs[i], versionDirs]);
|
||||
return agg;
|
||||
}, []),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,11 +14,14 @@ import expandTilde from 'expand-tilde';
|
||||
|
||||
const flipperDataDir = path.join(homedir(), '.flipper');
|
||||
|
||||
export const pluginInstallationDir = path.join(flipperDataDir, 'thirdparty');
|
||||
|
||||
export const pluginPendingInstallationDir = path.join(
|
||||
export const legacyPluginInstallationDir = path.join(
|
||||
flipperDataDir,
|
||||
'pending',
|
||||
'thirdparty',
|
||||
);
|
||||
|
||||
export const pluginInstallationDir = path.join(
|
||||
flipperDataDir,
|
||||
'installed-plugins',
|
||||
);
|
||||
|
||||
export const pluginCacheDir = path.join(flipperDataDir, 'plugins');
|
||||
@@ -43,27 +46,20 @@ export async function getPluginSourceFolders(): Promise<string[]> {
|
||||
return pluginFolders.map(expandTilde).filter(fs.existsSync);
|
||||
}
|
||||
|
||||
export function getPluginPendingInstallationDir(
|
||||
name: string,
|
||||
version: string,
|
||||
): string {
|
||||
return path.join(getPluginPendingInstallationsDir(name), version);
|
||||
}
|
||||
|
||||
export function getPluginPendingInstallationsDir(name: string): string {
|
||||
return path.join(
|
||||
pluginPendingInstallationDir,
|
||||
getPluginDirNameFromPackageName(name),
|
||||
);
|
||||
}
|
||||
|
||||
export function getPluginInstallationDir(name: string): string {
|
||||
export function getPluginInstallationDir(name: string) {
|
||||
return path.join(
|
||||
pluginInstallationDir,
|
||||
getPluginDirNameFromPackageName(name),
|
||||
);
|
||||
}
|
||||
|
||||
export function getPluginVersionInstallationDir(
|
||||
name: string,
|
||||
version: string,
|
||||
): string {
|
||||
return path.join(getPluginInstallationDir(name), version);
|
||||
}
|
||||
|
||||
export function getPluginDirNameFromPackageName(name: string) {
|
||||
return name.replace('/', '__');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user