Summary: Implementation was missing for the browser. This provides a default implementation. Reviewed By: aigoncharov Differential Revision: D48311198 fbshipit-source-id: fd067600f571234e0fbccfb90853b62f175ff8fb
289 lines
9.4 KiB
TypeScript
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;
|
|
}
|