Plugin Marketplace API

Summary: Extracted plugin marketplace API to a separate file and updated it to load full plugin manifests.

Reviewed By: passy

Differential Revision: D25181759

fbshipit-source-id: a63f9ce16249ccc170df148cef5c209fdc6d4d6d
This commit is contained in:
Anton Nikolaev
2020-12-15 09:28:58 -08:00
committed by Facebook GitHub Bot
parent 658b3e8a91
commit 5b26f36672
11 changed files with 152 additions and 58 deletions

View File

@@ -0,0 +1,60 @@
/**
* 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 {DownloadablePluginDetails} from 'flipper-plugin-lib';
export function createMockDownloadablePluginDetails(
params: {
id?: string;
name?: string;
version?: string;
title?: string;
flipperEngineVersion?: string;
downloadUrl?: string;
gatekeeper?: string;
lastUpdated?: Date;
} = {},
): DownloadablePluginDetails {
const {id, version, title, flipperEngineVersion, gatekeeper, lastUpdated} = {
id: 'test',
version: '3.0.1',
flipperEngineVersion: '0.46.0',
lastUpdated: new Date(1591226525 * 1000),
...params,
};
const lowercasedID = id.toLowerCase();
const name = params.name || `flipper-plugin-${lowercasedID}`;
const details: DownloadablePluginDetails = {
name: name || `flipper-plugin-${lowercasedID}`,
id: id,
bugs: {
email: 'bugs@localhost',
url: 'bugs.localhost',
},
category: 'tools',
description: 'Description of Test Plugin',
dir: `/Users/mock/.flipper/thirdparty/${name}`,
entry: `/Users/mock/.flipper/thirdparty/${name}/dist/bundle.js`,
flipperSDKVersion: flipperEngineVersion,
engines: {
flipper: flipperEngineVersion,
},
gatekeeper: gatekeeper ?? `GK_${lowercasedID}`,
icon: 'internet',
isDefault: false,
main: 'dist/bundle.js',
source: 'src/index.tsx',
specVersion: 2,
title: title ?? id,
version: version,
downloadUrl: `http://localhost/${lowercasedID}/${version}`,
lastUpdated: lastUpdated,
};
return details;
}

View File

@@ -12,7 +12,7 @@ import {args} from '@oclif/parser';
import fs from 'fs-extra'; import fs from 'fs-extra';
import path from 'path'; import path from 'path';
import {runBuild} from 'flipper-pkg-lib'; import {runBuild} from 'flipper-pkg-lib';
import {getPluginDetails} from 'flipper-plugin-lib'; import {getPluginDetailsFromDir} from 'flipper-plugin-lib';
export default class Bundle extends Command { export default class Bundle extends Command {
public static description = 'transpiles and bundles plugin'; public static description = 'transpiles and bundles plugin';
@@ -55,7 +55,7 @@ export default class Bundle extends Command {
`package.json is not found in plugin source directory ${inputDirectory}.`, `package.json is not found in plugin source directory ${inputDirectory}.`,
); );
} }
const plugin = await getPluginDetails(inputDirectory); const plugin = await getPluginDetailsFromDir(inputDirectory);
const out = path.resolve(inputDirectory, plugin.main); const out = path.resolve(inputDirectory, plugin.main);
await fs.ensureDir(path.dirname(out)); await fs.ensureDir(path.dirname(out));

View File

@@ -16,7 +16,7 @@ import * as path from 'path';
import * as yarn from '../utils/yarn'; import * as yarn from '../utils/yarn';
import cli from 'cli-ux'; import cli from 'cli-ux';
import {runBuild} from 'flipper-pkg-lib'; import {runBuild} from 'flipper-pkg-lib';
import {getPluginDetails} from 'flipper-plugin-lib'; import {getPluginDetailsFromDir} from 'flipper-plugin-lib';
async function deriveOutputFileName(inputDirectory: string): Promise<string> { async function deriveOutputFileName(inputDirectory: string): Promise<string> {
const packageJson = await readJSON(path.join(inputDirectory, 'package.json')); const packageJson = await readJSON(path.join(inputDirectory, 'package.json'));
@@ -116,7 +116,7 @@ export default class Pack extends Command {
cli.action.stop(); cli.action.stop();
cli.action.start('Reading plugin details'); cli.action.start('Reading plugin details');
const plugin = await getPluginDetails(inputDirectory); const plugin = await getPluginDetailsFromDir(inputDirectory);
const out = path.resolve(inputDirectory, plugin.main); const out = path.resolve(inputDirectory, plugin.main);
cli.action.stop(`done. Source: ${plugin.source}. Main: ${plugin.main}.`); cli.action.stop(`done. Source: ${plugin.source}. Main: ${plugin.main}.`);

View File

@@ -7,7 +7,7 @@
* @format * @format
*/ */
export default interface PluginDetails { export interface PluginDetails {
dir: string; dir: string;
name: string; name: string;
specVersion: number; specVersion: number;
@@ -22,9 +22,19 @@ export default interface PluginDetails {
icon?: string; icon?: string;
description?: string; description?: string;
category?: string; category?: string;
engines?: {
[name: string]: string;
};
bugs?: { bugs?: {
email?: string; email?: string;
url?: string; url?: string;
}; };
flipperSDKVersion?: string; flipperSDKVersion?: string;
} }
export interface DownloadablePluginDetails extends PluginDetails {
downloadUrl: string;
lastUpdated: Date;
}
export default PluginDetails;

View File

@@ -9,7 +9,7 @@
import fs from 'fs-extra'; import fs from 'fs-extra';
import path from 'path'; import path from 'path';
import getPluginDetails from '../getPluginDetails'; import {getPluginDetailsFromDir} from '../getPluginDetails';
import {pluginInstallationDir} from '../pluginPaths'; import {pluginInstallationDir} from '../pluginPaths';
import {normalizePath} from 'flipper-test-utils'; import {normalizePath} from 'flipper-test-utils';
@@ -31,7 +31,7 @@ test('getPluginDetailsV1', async () => {
}; };
jest.mock('fs-extra', () => jest.fn()); jest.mock('fs-extra', () => jest.fn());
fs.readJson = jest.fn().mockImplementation(() => pluginV1); fs.readJson = jest.fn().mockImplementation(() => pluginV1);
const details = await getPluginDetails(pluginPath); const details = await getPluginDetailsFromDir(pluginPath);
details.dir = normalizePath(details.dir); details.dir = normalizePath(details.dir);
details.entry = normalizePath(details.entry); details.entry = normalizePath(details.entry);
expect(details).toMatchInlineSnapshot(` expect(details).toMatchInlineSnapshot(`
@@ -69,7 +69,7 @@ test('getPluginDetailsV2', async () => {
}; };
jest.mock('fs-extra', () => jest.fn()); jest.mock('fs-extra', () => jest.fn());
fs.readJson = jest.fn().mockImplementation(() => pluginV2); fs.readJson = jest.fn().mockImplementation(() => pluginV2);
const details = await getPluginDetails(pluginPath); const details = await getPluginDetailsFromDir(pluginPath);
details.dir = normalizePath(details.dir); details.dir = normalizePath(details.dir);
details.entry = normalizePath(details.entry); details.entry = normalizePath(details.entry);
expect(details).toMatchInlineSnapshot(` expect(details).toMatchInlineSnapshot(`
@@ -107,7 +107,7 @@ test('id used as title if the latter omited', async () => {
}; };
jest.mock('fs-extra', () => jest.fn()); jest.mock('fs-extra', () => jest.fn());
fs.readJson = jest.fn().mockImplementation(() => pluginV2); fs.readJson = jest.fn().mockImplementation(() => pluginV2);
const details = await getPluginDetails(pluginPath); const details = await getPluginDetailsFromDir(pluginPath);
details.dir = normalizePath(details.dir); details.dir = normalizePath(details.dir);
details.entry = normalizePath(details.entry); details.entry = normalizePath(details.entry);
expect(details).toMatchInlineSnapshot(` expect(details).toMatchInlineSnapshot(`
@@ -144,7 +144,7 @@ test('name without "flipper-plugin-" prefix is used as title if the latter omite
}; };
jest.mock('fs-extra', () => jest.fn()); jest.mock('fs-extra', () => jest.fn());
fs.readJson = jest.fn().mockImplementation(() => pluginV2); fs.readJson = jest.fn().mockImplementation(() => pluginV2);
const details = await getPluginDetails(pluginPath); const details = await getPluginDetailsFromDir(pluginPath);
details.dir = normalizePath(details.dir); details.dir = normalizePath(details.dir);
details.entry = normalizePath(details.entry); details.entry = normalizePath(details.entry);
expect(details).toMatchInlineSnapshot(` expect(details).toMatchInlineSnapshot(`
@@ -184,7 +184,7 @@ test('flipper-plugin-version is parsed', async () => {
}; };
jest.mock('fs-extra', () => jest.fn()); jest.mock('fs-extra', () => jest.fn());
fs.readJson = jest.fn().mockImplementation(() => pluginV2); fs.readJson = jest.fn().mockImplementation(() => pluginV2);
const details = await getPluginDetails(pluginPath); const details = await getPluginDetailsFromDir(pluginPath);
details.dir = normalizePath(details.dir); details.dir = normalizePath(details.dir);
details.entry = normalizePath(details.entry); details.entry = normalizePath(details.entry);
expect(details).toMatchInlineSnapshot(` expect(details).toMatchInlineSnapshot(`

View File

@@ -15,7 +15,7 @@ import {
pluginInstallationDir, pluginInstallationDir,
} from './pluginPaths'; } from './pluginPaths';
import PluginDetails from './PluginDetails'; import PluginDetails from './PluginDetails';
import getPluginDetails from './getPluginDetails'; import {getPluginDetailsFromDir} from './getPluginDetails';
import pmap from 'p-map'; import pmap from 'p-map';
import {notNull} from './typeUtils'; import {notNull} from './typeUtils';
@@ -40,7 +40,7 @@ async function getFullyInstalledPlugins(): Promise<PluginDetails[]> {
return undefined; return undefined;
} }
try { try {
return await getPluginDetails(pluginDir); return await getPluginDetailsFromDir(pluginDir);
} catch (e) { } catch (e) {
console.error(`Failed to load plugin from ${pluginDir}`, e); console.error(`Failed to load plugin from ${pluginDir}`, e);
return undefined; return undefined;
@@ -71,7 +71,7 @@ async function getPendingInstallationPlugins(): Promise<PluginDetails[]> {
return undefined; return undefined;
} }
try { try {
return await getPluginDetails(pluginDir); return await getPluginDetailsFromDir(pluginDir);
} catch (e) { } catch (e) {
console.error(`Failed to load plugin from ${pluginDir}`, e); console.error(`Failed to load plugin from ${pluginDir}`, e);
return undefined; return undefined;

View File

@@ -9,15 +9,10 @@
import fs from 'fs-extra'; import fs from 'fs-extra';
import path from 'path'; import path from 'path';
import PluginDetails from './PluginDetails'; import {PluginDetails} from './PluginDetails';
import {pluginCacheDir} from './pluginPaths'; import {getPluginInstallationDir, pluginCacheDir} from './pluginPaths';
export default async function ( export async function getPluginDetails(pluginDir: string, packageJson: any) {
pluginDir: string,
packageJson?: any,
): Promise<PluginDetails> {
packageJson =
packageJson || (await fs.readJson(path.join(pluginDir, 'package.json')));
const specVersion = const specVersion =
packageJson.$schema && packageJson.$schema &&
packageJson.$schema === packageJson.$schema ===
@@ -34,6 +29,31 @@ export default async function (
} }
} }
export async function getPluginDetailsFromDir(
pluginDir: string,
): Promise<PluginDetails> {
const packageJson = await fs.readJson(path.join(pluginDir, 'package.json'));
return await getPluginDetails(pluginDir, packageJson);
}
export async function getPluginDetailsFromPackageJson(packageJson: any) {
const pluginDir = getPluginInstallationDir(packageJson.name);
return await getPluginDetails(pluginDir, packageJson);
}
export async function getDownloadablePluginDetails(
packageJson: any,
downloadUrl: string,
lastUpdated: Date,
) {
const details = await getPluginDetailsFromPackageJson(packageJson);
return {
...details,
downloadUrl,
lastUpdated,
};
}
// Plugins packaged using V1 are distributed as sources and compiled in run-time. // Plugins packaged using V1 are distributed as sources and compiled in run-time.
async function getPluginDetailsV1( async function getPluginDetailsV1(
pluginDir: string, pluginDir: string,

View File

@@ -12,8 +12,8 @@ import {getInstalledPlugins} from './getInstalledPlugins';
import semver from 'semver'; import semver from 'semver';
import {getNpmHostedPlugins, NpmPackageDescriptor} from './getNpmHostedPlugins'; import {getNpmHostedPlugins, NpmPackageDescriptor} from './getNpmHostedPlugins';
import NpmApi from 'npm-api'; import NpmApi from 'npm-api';
import getPluginDetails from './getPluginDetails'; import {getPluginDetails} from './getPluginDetails';
import {getPluginInstallationDir} from './pluginInstaller'; import {getPluginInstallationDir} from './pluginPaths';
import pmap from 'p-map'; import pmap from 'p-map';
import {notNull} from './typeUtils'; import {notNull} from './typeUtils';
const npmApi = new NpmApi(); const npmApi = new NpmApi();

View File

@@ -7,10 +7,10 @@
* @format * @format
*/ */
export {default as PluginDetails} from './PluginDetails'; export * from './PluginDetails';
export {default as getPluginDetails} from './getPluginDetails'; export * from './getPluginDetails';
export * from './pluginInstaller'; export * from './pluginInstaller';
export * from './getInstalledPlugins'; export * from './getInstalledPlugins';
export * from './getUpdatablePlugins'; export * from './getUpdatablePlugins';
export * from './getSourcePlugins'; export * from './getSourcePlugins';
export {getPluginSourceFolders} from './pluginPaths'; export * from './pluginPaths';

View File

@@ -16,10 +16,14 @@ import decompressTargz from 'decompress-targz';
import decompressUnzip from 'decompress-unzip'; import decompressUnzip from 'decompress-unzip';
import tmp from 'tmp'; import tmp from 'tmp';
import PluginDetails from './PluginDetails'; import PluginDetails from './PluginDetails';
import getPluginDetails from './getPluginDetails'; import {getPluginDetailsFromDir} from './getPluginDetails';
import { import {
getPluginInstallationDir,
getPluginPendingInstallationDir,
getPluginPendingInstallationsDir,
pluginInstallationDir, pluginInstallationDir,
pluginPendingInstallationDir, pluginPendingInstallationDir,
getPluginDirNameFromPackageName,
} from './pluginPaths'; } from './pluginPaths';
import semver from 'semver'; import semver from 'semver';
@@ -29,35 +33,10 @@ function providePluginManagerNoDependencies(): PM {
return new PM({ignoredDependencies: [/.*/]}); return new PM({ignoredDependencies: [/.*/]});
} }
function getPluginPendingInstallationDir(
name: string,
version: string,
): string {
return path.join(getPluginPendingInstallationsDir(name), version);
}
function getPluginPendingInstallationsDir(name: string): string {
return path.join(
pluginPendingInstallationDir,
replaceInvalidPathSegmentCharacters(name),
);
}
export function getPluginInstallationDir(name: string): string {
return path.join(
pluginInstallationDir,
replaceInvalidPathSegmentCharacters(name),
);
}
function replaceInvalidPathSegmentCharacters(name: string) {
return name.replace('/', '__');
}
async function installPluginFromTempDir( async function installPluginFromTempDir(
sourceDir: string, sourceDir: string,
): Promise<PluginDetails> { ): Promise<PluginDetails> {
const pluginDetails = await getPluginDetails(sourceDir); const pluginDetails = await getPluginDetailsFromDir(sourceDir);
const {name, version} = pluginDetails; const {name, version} = pluginDetails;
const backupDir = path.join(await getTmpDir(), `${name}-${version}`); const backupDir = path.join(await getTmpDir(), `${name}-${version}`);
const installationsDir = getPluginPendingInstallationsDir(name); const installationsDir = getPluginPendingInstallationsDir(name);
@@ -93,7 +72,7 @@ async function installPluginFromTempDir(
} }
throw err; throw err;
} }
return await getPluginDetails(destinationDir); return await getPluginDetailsFromDir(destinationDir);
} }
async function getPluginRootDir(dir: string) { async function getPluginRootDir(dir: string) {
@@ -121,7 +100,7 @@ export async function getInstalledPlugin(
if (!(await fs.pathExists(dir))) { if (!(await fs.pathExists(dir))) {
return null; return null;
} }
return await getPluginDetails(dir); return await getPluginDetailsFromDir(dir);
} }
export async function isPluginPendingInstallation( export async function isPluginPendingInstallation(
@@ -140,7 +119,7 @@ export async function installPluginFromNpm(name: string) {
await plugManNoDep.install(name); await plugManNoDep.install(name);
const pluginTempDir = path.join( const pluginTempDir = path.join(
tmpDir, tmpDir,
replaceInvalidPathSegmentCharacters(name), getPluginDirNameFromPackageName(name),
); );
await installPluginFromTempDir(pluginTempDir); await installPluginFromTempDir(pluginTempDir);
} finally { } finally {

View File

@@ -12,7 +12,7 @@ import {homedir} from 'os';
import fs from 'fs-extra'; import fs from 'fs-extra';
import expandTilde from 'expand-tilde'; import expandTilde from 'expand-tilde';
export const flipperDataDir = path.join(homedir(), '.flipper'); const flipperDataDir = path.join(homedir(), '.flipper');
export const pluginInstallationDir = path.join(flipperDataDir, 'thirdparty'); export const pluginInstallationDir = path.join(flipperDataDir, 'thirdparty');
@@ -42,3 +42,28 @@ export async function getPluginSourceFolders(): Promise<string[]> {
pluginFolders.push(path.resolve(__dirname, '..', '..', 'plugins', 'fb')); pluginFolders.push(path.resolve(__dirname, '..', '..', 'plugins', 'fb'));
return pluginFolders.map(expandTilde).filter(fs.existsSync); 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 {
return path.join(
pluginInstallationDir,
getPluginDirNameFromPackageName(name),
);
}
export function getPluginDirNameFromPackageName(name: string) {
return name.replace('/', '__');
}