Files
flipper/desktop/plugin-lib/src/pluginInstaller.tsx
Lorenzo Blasa ff6f98fc0d Import File implementation
Summary: Implementation was missing for the browser. This provides a default implementation.

Reviewed By: aigoncharov

Differential Revision: D48311198

fbshipit-source-id: fd067600f571234e0fbccfb90853b62f175ff8fb
2023-08-14 11:33:06 -07:00

289 lines
9.4 KiB
TypeScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
// Heavy use of nested promises but without sacrificing error propagation.
/* eslint-disable promise/no-nesting */
import path from 'path';
import fs from 'fs-extra';
import {promisify} from 'util';
import {PluginManager as PM} from 'live-plugin-manager';
import decompress from 'decompress';
import decompressTargz from 'decompress-targz';
import decompressUnzip from 'decompress-unzip';
import tmp from 'tmp';
import {InstalledPluginDetails} from 'flipper-common';
import {getInstalledPluginDetails, isPluginDir} from './getPluginDetails';
import {
getPluginVersionInstallationDir,
getPluginInstallationDir,
pluginInstallationDir,
legacyPluginInstallationDir,
} from './pluginPaths';
import pfilter from 'p-filter';
import pmap from 'p-map';
import semver from 'semver';
import {notNull} from './typeUtils';
function providePluginManagerNoDependencies(): PM {
return new PM({ignoredDependencies: [/.*/]});
}
async function installPluginFromTempDir(
sourceDir: string,
): Promise<InstalledPluginDetails> {
const pluginDetails = await getInstalledPluginDetails(sourceDir);
const {name, version} = pluginDetails;
const backupDir = path.join(await promisify(tmp.dir)(), `${name}-${version}`);
const destinationDir = getPluginVersionInstallationDir(name, version);
if (pluginDetails.specVersion == 1) {
throw new Error(
`Cannot install plugin ${pluginDetails.name} because it is packaged using the unsupported format v1. Please encourage the plugin author to update to v2, following the instructions on https://fbflipper.com/docs/extending/js-setup#migration-to-the-new-plugin-specification`,
);
}
try {
// Moving the existing destination dir to backup
if (await fs.pathExists(destinationDir)) {
await fs.move(destinationDir, backupDir, {overwrite: true});
}
await fs.move(sourceDir, destinationDir);
} catch (err) {
// Restore previous version from backup if installation failed
await fs.remove(destinationDir);
if (await fs.pathExists(backupDir)) {
await fs.move(backupDir, destinationDir, {overwrite: true});
}
throw err;
}
return await getInstalledPluginDetails(destinationDir);
}
async function getPluginRootDir(dir: string) {
// npm packages are tar.gz archives containing folder 'package' inside
const packageDir = path.join(dir, 'package');
const isNpmPackage = await fs.pathExists(packageDir);
// vsix packages are zip archives containing folder 'extension' inside
const extensionDir = path.join(dir, 'extension');
const isVsix = await fs.pathExists(extensionDir);
if (!isNpmPackage && !isVsix) {
throw new Error(
'Package format is invalid: directory "package" or "extensions" not found in the archive root',
);
}
return isNpmPackage ? packageDir : extensionDir;
}
export async function getInstalledPlugin(
name: string,
version: string,
): Promise<InstalledPluginDetails | null> {
const dir = getPluginVersionInstallationDir(name, version);
if (!(await fs.pathExists(dir))) {
return null;
}
return await getInstalledPluginDetails(dir);
}
export async function installPluginFromNpm(name: string) {
const tmpDir = await promisify(tmp.dir)();
try {
await fs.ensureDir(tmpDir);
const plugManNoDep = providePluginManagerNoDependencies();
plugManNoDep.options.pluginsPath = tmpDir;
const pluginInfo = await plugManNoDep.install(name);
return await installPluginFromTempDir(pluginInfo.location);
} finally {
await fs.remove(tmpDir);
}
}
export async function installPluginFromFileOrBuffer(
packagePath: string | Buffer,
): Promise<InstalledPluginDetails> {
const tmpDir = await promisify(tmp.dir)();
try {
const files = await decompress(packagePath, tmpDir, {
plugins: [decompressTargz(), decompressUnzip()],
});
if (!files.length) {
throw new Error('The package is not in tar.gz format or is empty');
}
const pluginDir = await getPluginRootDir(tmpDir);
return await installPluginFromTempDir(pluginDir);
} finally {
await fs.remove(tmpDir);
}
}
export async function removePlugin(name: string): Promise<void> {
await fs.remove(getPluginInstallationDir(name));
}
export async function removePlugins(names: Array<string>): Promise<void> {
await pmap(names, (name) => removePlugin(name));
}
export async function getAllInstalledPluginVersions(): Promise<
InstalledPluginDetails[]
> {
const pluginDirs = await getInstalledPluginVersionDirs();
const versionDirs = pluginDirs.map(([_, versionDirs]) => versionDirs).flat();
return await pmap(versionDirs, (versionDir) =>
getInstalledPluginDetails(versionDir).catch((err) => {
console.error(`Failed to load plugin details from ${versionDir}`, err);
return null;
}),
).then((versionDetails) => versionDetails.filter(notNull));
}
export async function getInstalledPlugins(): Promise<InstalledPluginDetails[]> {
const versionDirs = await getInstalledPluginVersionDirs();
return pmap(
versionDirs
.filter(([_, versionDirs]) => versionDirs.length > 0)
.map(([_, versionDirs]) => versionDirs[0]),
(latestVersionDir) =>
getInstalledPluginDetails(latestVersionDir).catch((err) => {
console.error(`Failed to load plugin from ${latestVersionDir}`, err);
return null;
}),
).then((plugins) => plugins.filter(notNull));
}
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(() => {}),
);
}
// 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(() => false),
),
)
.then((dirs) =>
pmap(dirs, (dir) =>
getInstalledPluginDetails(dir).catch(async (err) => {
console.error(
`Failed to load plugin from ${dir} on moving legacy plugins. Removing it.`,
err,
);
await fs.remove(dir);
return null;
}),
),
)
.then((plugins) =>
pmap(plugins.filter(notNull), (plugin) =>
fs.move(
plugin.dir,
getPluginVersionInstallationDir(plugin.name, plugin.version),
{overwrite: true},
),
),
);
await fs.remove(legacyPluginInstallationDir);
}
}
type InstalledPluginVersionDirs = [string, string[]][];
async function getInstalledPluginVersionDirs(): Promise<InstalledPluginVersionDirs> {
if (!(await fs.pathExists(pluginInstallationDir))) {
return [];
}
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.filter((d) => semver.valid(d)))
.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;
}, []),
),
);
}
export async function getAllInstalledPluginsInDir(
dir: string,
recursive: boolean = false,
): Promise<InstalledPluginDetails[]> {
const plugins: InstalledPluginDetails[] = [];
if (!((await fs.pathExists(dir)) && (await fs.stat(dir)).isDirectory())) {
console.log('defaultPlugins dir not found');
return plugins;
}
const items = await fs.readdir(dir);
await pmap(items, async (item) => {
const fullPath = path.join(dir, item);
if (await isPluginDir(fullPath)) {
try {
plugins.push(await getInstalledPluginDetails(fullPath));
} catch (err) {
console.error(`Failed to load plugin from ${fullPath}`);
}
} else if (recursive) {
plugins.push(...(await getAllInstalledPluginsInDir(fullPath, recursive)));
}
});
return plugins;
}