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
@@ -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',
|
||||
);
|
||||
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 getInstalledPlugins(): Promise<PluginDetails[]> {
|
||||
const versionDirs = await getInstalledPluginVersionDirs();
|
||||
return pmap(
|
||||
versionDirs
|
||||
.filter(([_, versionDirs]) => versionDirs.length > 0)
|
||||
.map(([_, versionDirs]) => versionDirs[0]),
|
||||
(latestVersionDir) => getPluginDetailsFromDir(latestVersionDir),
|
||||
);
|
||||
}
|
||||
|
||||
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(() => 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},
|
||||
),
|
||||
),
|
||||
);
|
||||
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,
|
||||
);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error while finishing plugin pending installations', err);
|
||||
await fs.remove(legacyPluginInstallationDir);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}, []),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user