Fully convert ExtensionDiscovery to be injectable

- To fix unit tests

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton committed 2023-03-01 13:34:46 -05:00
1 parent 7f86a89cc2
commit ac6e3e18e2
57 files changed
+878 -1013

No files matched your search

@@ -1,17 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectionToken } from "@ogre-tools/injectable";
import type { BundledLensExtensionManifest, BundledLensExtensionContructor } from "../lens-extension";
export interface BundledExtension {
readonly manifest: BundledLensExtensionManifest;
main: () => Promise<BundledLensExtensionContructor | null>;
renderer: () => Promise<BundledLensExtensionContructor | null>;
}
export const bundledExtensionInjectionToken = getInjectionToken<BundledExtension>({
id: "bundled-extension-path",
});
@@ -1,63 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { ExtensionDiscovery } from "./extension-discovery";
import isCompatibleExtensionInjectable from "./is-compatible-extension/is-compatible-extension.injectable";
import extensionsStoreInjectable from "../extensions-store/extensions-store.injectable";
import extensionInstallationStateStoreInjectable from "../extension-installation-state-store/extension-installation-state-store.injectable";
import installExtensionInjectable from "../install-extension/install-extension.injectable";
import extensionPackageRootDirectoryInjectable from "../install-extension/extension-package-root-directory.injectable";
import readJsonFileInjectable from "../../common/fs/read-json-file.injectable";
import loggerInjectable from "../../common/logger.injectable";
import pathExistsInjectable from "../../common/fs/path-exists.injectable";
import watchInjectable from "../../common/fs/watch/watch.injectable";
import accessPathInjectable from "../../common/fs/access-path.injectable";
import copyInjectable from "../../common/fs/copy.injectable";
import ensureDirInjectable from "../../common/fs/ensure-dir.injectable";
import isProductionInjectable from "../../common/vars/is-production.injectable";
import lstatInjectable from "../../common/fs/lstat.injectable";
import readDirectoryInjectable from "../../common/fs/read-directory.injectable";
import fileSystemSeparatorInjectable from "../../common/path/separator.injectable";
import getBasenameOfPathInjectable from "../../common/path/get-basename.injectable";
import getDirnameOfPathInjectable from "../../common/path/get-dirname.injectable";
import getRelativePathInjectable from "../../common/path/get-relative-path.injectable";
import joinPathsInjectable from "../../common/path/join-paths.injectable";
import removePathInjectable from "../../common/fs/remove.injectable";
import homeDirectoryPathInjectable from "../../common/os/home-directory-path.injectable";
import lensResourcesDirInjectable from "../../common/vars/lens-resources-dir.injectable";
import installedExtensionsInjectable from "../../features/extensions/common/installed-extensions.injectable";
const extensionDiscoveryInjectable = getInjectable({
id: "extension-discovery",
instantiate: (di) => new ExtensionDiscovery({
extensionsStore: di.inject(extensionsStoreInjectable),
extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable),
isCompatibleExtension: di.inject(isCompatibleExtensionInjectable),
installExtension: di.inject(installExtensionInjectable),
extensionPackageRootDirectory: di.inject(extensionPackageRootDirectoryInjectable),
resourcesDirectory: di.inject(lensResourcesDirInjectable),
readJsonFile: di.inject(readJsonFileInjectable),
pathExists: di.inject(pathExistsInjectable),
watch: di.inject(watchInjectable),
logger: di.inject(loggerInjectable),
accessPath: di.inject(accessPathInjectable),
copy: di.inject(copyInjectable),
removePath: di.inject(removePathInjectable),
ensureDirectory: di.inject(ensureDirInjectable),
isProduction: di.inject(isProductionInjectable),
lstat: di.inject(lstatInjectable),
readDirectory: di.inject(readDirectoryInjectable),
fileSystemSeparator: di.inject(fileSystemSeparatorInjectable),
getBasenameOfPath: di.inject(getBasenameOfPathInjectable),
getDirnameOfPath: di.inject(getDirnameOfPathInjectable),
getRelativePath: di.inject(getRelativePathInjectable),
joinPaths: di.inject(joinPathsInjectable),
homeDirectoryPath: di.inject(homeDirectoryPathInjectable),
installedExtensions: di.inject(installedExtensionsInjectable),
}),
});
export default extensionDiscoveryInjectable;
@@ -1,147 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { FSWatcher } from "chokidar";
import { getDiForUnitTesting } from "../../main/getDiForUnitTesting";
import extensionDiscoveryInjectable from "../extension-discovery/extension-discovery.injectable";
import type { ExtensionDiscovery } from "../extension-discovery/extension-discovery";
import installExtensionInjectable from "../install-extension/install-extension.injectable";
import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import { delay } from "../../renderer/utils";
import { observable, runInAction, when } from "mobx";
import readJsonFileInjectable from "../../common/fs/read-json-file.injectable";
import pathExistsInjectable from "../../common/fs/path-exists.injectable";
import watchInjectable from "../../common/fs/watch/watch.injectable";
import extensionApiVersionInjectable from "../../common/vars/extension-api-version.injectable";
import removePathInjectable from "../../common/fs/remove.injectable";
import type { JoinPaths } from "../../common/path/join-paths.injectable";
import joinPathsInjectable from "../../common/path/join-paths.injectable";
import homeDirectoryPathInjectable from "../../common/os/home-directory-path.injectable";
import pathExistsSyncInjectable from "../../common/fs/path-exists-sync.injectable";
import readJsonSyncInjectable from "../../common/fs/read-json-sync.injectable";
import writeJsonSyncInjectable from "../../common/fs/write-json-sync.injectable";
describe("ExtensionDiscovery", () => {
let extensionDiscovery: ExtensionDiscovery;
let readJsonFileMock: jest.Mock;
let pathExistsMock: jest.Mock;
let watchMock: jest.Mock;
let joinPaths: JoinPaths;
let homeDirectoryPath: string;
beforeEach(() => {
const di = getDiForUnitTesting({ doGeneralOverrides: true });
di.override(directoryForUserDataInjectable, () => "/some-directory-for-user-data");
di.override(installExtensionInjectable, () => () => Promise.resolve());
di.override(extensionApiVersionInjectable, () => "5.0.0");
di.override(pathExistsSyncInjectable, () => () => { throw new Error("tried call pathExistsSync without override"); });
di.override(readJsonSyncInjectable, () => () => { throw new Error("tried call readJsonSync without override"); });
di.override(writeJsonSyncInjectable, () => () => { throw new Error("tried call writeJsonSync without override"); });
joinPaths = di.inject(joinPathsInjectable);
homeDirectoryPath = di.inject(homeDirectoryPathInjectable);
readJsonFileMock = jest.fn();
di.override(readJsonFileInjectable, () => readJsonFileMock);
pathExistsMock = jest.fn(() => Promise.resolve(true));
di.override(pathExistsInjectable, () => pathExistsMock);
watchMock = jest.fn();
di.override(watchInjectable, () => watchMock);
di.override(removePathInjectable, () => async () => {}); // allow deleting files for now
extensionDiscovery = di.inject(extensionDiscoveryInjectable);
});
it("emits add for added extension", async () => {
const letTestFinish = observable.box(false);
let addHandler!: (filePath: string) => void;
readJsonFileMock.mockImplementation((p) => {
expect(p).toBe(joinPaths(homeDirectoryPath, ".k8slens/extensions/my-extension/package.json"));
return {
name: "my-extension",
version: "1.0.0",
engines: {
lens: "5.0.0",
},
};
});
const mockWatchInstance = {
on: jest.fn((event: string, handler: typeof addHandler) => {
if (event === "add") {
addHandler = handler;
}
return mockWatchInstance;
}),
} as unknown as FSWatcher;
watchMock.mockImplementationOnce(() => mockWatchInstance);
// Need to force isLoaded to be true so that the file watching is started
extensionDiscovery.isLoaded = true;
await extensionDiscovery.watchExtensions();
extensionDiscovery.events.on("add", extension => {
expect(extension).toEqual({
absolutePath: expect.any(String),
id: "/some-directory-for-user-data/node_modules/my-extension/package.json",
isBundled: false,
isEnabled: false,
isCompatible: true,
manifest: {
name: "my-extension",
version: "1.0.0",
engines: {
lens: "5.0.0",
},
},
manifestPath: "/some-directory-for-user-data/node_modules/my-extension/package.json",
});
runInAction(() => letTestFinish.set(true));
});
addHandler(joinPaths(extensionDiscovery.localFolderPath, "/my-extension/package.json"));
await when(() => letTestFinish.get());
});
it("doesn't emit add for added file under extension", async () => {
let addHandler!: (filePath: string) => void;
const mockWatchInstance = {
on: jest.fn((event: string, handler: typeof addHandler) => {
if (event === "add") {
addHandler = handler;
}
return mockWatchInstance;
}),
} as unknown as FSWatcher;
watchMock.mockImplementationOnce(() => mockWatchInstance);
// Need to force isLoaded to be true so that the file watching is started
extensionDiscovery.isLoaded = true;
await extensionDiscovery.watchExtensions();
const onAdd = jest.fn();
extensionDiscovery.events.on("add", onAdd);
addHandler(joinPaths(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json"));
await delay(10);
expect(onAdd).not.toHaveBeenCalled();
});
});
@@ -1,425 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { ipcRenderer } from "electron";
import { EventEmitter } from "events";
import type { ObservableMap } from "mobx";
import { makeObservable, observable, reaction, when } from "mobx";
import { broadcastMessage, ipcMainHandle, ipcRendererOn } from "../../common/ipc";
import { isErrnoException, iter, toJS } from "../../common/utils";
import type { ExtensionsStore } from "../extensions-store/extensions-store";
import type { BundledLensExtensionManifest, LensExtensionId, LensExtensionManifest } from "../lens-extension";
import type { ExtensionInstallationStateStore } from "../extension-installation-state-store/extension-installation-state-store";
import { extensionDiscoveryStateChannel } from "../../common/ipc/extension-handling";
import { requestInitialExtensionDiscovery } from "../../renderer/ipc";
import type { ReadJson } from "../../common/fs/read-json-file.injectable";
import type { Logger } from "../../common/logger";
import type { PathExists } from "../../common/fs/path-exists.injectable";
import type { Watch } from "../../common/fs/watch/watch.injectable";
import type { Stats } from "fs";
import type { LStat } from "../../common/fs/lstat.injectable";
import type { ReadDirectory } from "../../common/fs/read-directory.injectable";
import type { EnsureDirectory } from "../../common/fs/ensure-dir.injectable";
import type { AccessPath } from "../../common/fs/access-path.injectable";
import type { Copy } from "../../common/fs/copy.injectable";
import type { JoinPaths } from "../../common/path/join-paths.injectable";
import type { GetBasenameOfPath } from "../../common/path/get-basename.injectable";
import type { GetDirnameOfPath } from "../../common/path/get-dirname.injectable";
import type { GetRelativePath } from "../../common/path/get-relative-path.injectable";
import type { RemovePath } from "../../common/fs/remove.injectable";
import type TypedEventEmitter from "typed-emitter";
interface Dependencies {
readonly extensionsStore: ExtensionsStore;
readonly extensionInstallationStateStore: ExtensionInstallationStateStore;
readonly extensionPackageRootDirectory: string;
readonly resourcesDirectory: string;
readonly logger: Logger;
readonly isProduction: boolean;
readonly fileSystemSeparator: string;
readonly homeDirectoryPath: string;
readonly installedExtensions: ObservableMap<LensExtensionId, InstalledExtension>;
isCompatibleExtension: (manifest: LensExtensionManifest) => boolean;
installExtension: (name: string) => Promise<void>;
readJsonFile: ReadJson;
pathExists: PathExists;
removePath: RemovePath;
lstat: LStat;
watch: Watch;
readDirectory: ReadDirectory;
ensureDirectory: EnsureDirectory;
accessPath: AccessPath;
copy: Copy;
joinPaths: JoinPaths;
getBasenameOfPath: GetBasenameOfPath;
getDirnameOfPath: GetDirnameOfPath;
getRelativePath: GetRelativePath;
}
export interface BaseInstalledExtension {
readonly id: LensExtensionId;
}
export interface BundledInstalledExtension extends BaseInstalledExtension {
readonly manifest: BundledLensExtensionManifest;
readonly isBundled: true;
readonly isCompatible: true;
readonly isEnabled: true;
}
export interface ExternalInstalledExtension extends BaseInstalledExtension {
readonly manifest: LensExtensionManifest;
readonly isBundled: false;
readonly isCompatible: boolean;
// Absolute path to the non-symlinked source folder,
// e.g. "/Users/user/.k8slens/extensions/helloworld"
readonly absolutePath: string;
// Absolute to the symlinked package.json file
readonly manifestPath: string;
isEnabled: boolean;
}
export type InstalledExtension = BundledInstalledExtension | ExternalInstalledExtension;
const logModule = "[EXTENSION-DISCOVERY]";
export const manifestFilename = "package.json";
interface ExtensionDiscoveryChannelMessage {
isLoaded: boolean;
}
/**
* Returns true if the lstat is for a directory-like file (e.g. isDirectory or symbolic link)
* @param lstat the stats to compare
*/
const isDirectoryLike = (lstat: Stats) => lstat.isDirectory() || lstat.isSymbolicLink();
interface ExtensionDiscoveryEvents {
add: (ext: InstalledExtension) => void;
remove: (ext: InstalledExtension) => void;
}
/**
* Discovers installed bundled and local extensions from the filesystem.
* Also watches for added and removed local extensions by watching the directory.
* Uses ExtensionInstaller to install dependencies for all of the extensions.
* This is also done when a new extension is copied to the local extensions directory.
* .init() must be called to start the directory watching.
* The class emits events for added and removed extensions:
* - "add": When extension is added. The event is of type InstalledExtension
* - "remove": When extension is removed. The event is of type LensExtensionId
*/
export class ExtensionDiscovery {
protected bundledFolderPath!: string;
private loadStarted = false;
// True if extensions have been loaded from the disk after app startup
@observable isLoaded = false;
get whenLoaded() {
return when(() => this.isLoaded);
}
public readonly events: TypedEventEmitter<ExtensionDiscoveryEvents> = new EventEmitter();
constructor(protected readonly dependencies: Dependencies) {
makeObservable(this);
}
get localFolderPath(): string {
return this.dependencies.joinPaths(this.dependencies.homeDirectoryPath, ".k8slens", "extensions");
}
get packageJsonPath(): string {
return this.dependencies.joinPaths(this.dependencies.extensionPackageRootDirectory, manifestFilename);
}
get nodeModulesPath(): string {
return this.dependencies.joinPaths(this.dependencies.extensionPackageRootDirectory, "node_modules");
}
/**
* Initializes the class and setups the file watcher for added/removed local extensions.
*/
async init(): Promise<void> {
if (ipcRenderer) {
await this.initRenderer();
} else {
await this.initMain();
}
}
async initRenderer(): Promise<void> {
const onMessage = ({ isLoaded }: ExtensionDiscoveryChannelMessage) => {
this.isLoaded = isLoaded;
};
requestInitialExtensionDiscovery().then(onMessage);
ipcRendererOn(extensionDiscoveryStateChannel, (_event, message: ExtensionDiscoveryChannelMessage) => {
onMessage(message);
});
}
async initMain(): Promise<void> {
ipcMainHandle(extensionDiscoveryStateChannel, () => this.toJSON());
reaction(() => this.toJSON(), () => {
this.broadcast();
});
}
/**
* Watches for added/removed local extensions.
* Dependencies are installed automatically after an extension folder is copied.
*/
watchExtensions() {
this.dependencies.logger.info(`${logModule} watching extension add/remove in ${this.localFolderPath}`);
this.dependencies.watch(this.localFolderPath, {
// For adding and removing symlinks to work, the depth has to be 1.
depth: 1,
ignoreInitial: true,
// Try to wait until the file has been completely copied.
// The OS might emit an event for added file even it's not completely written to the file-system.
awaitWriteFinish: {
// Wait 300ms until the file size doesn't change to consider the file written.
// For a small file like package.json this should be plenty of time.
stabilityThreshold: 300,
},
})
// Extension add is detected by watching "<extensionDir>/package.json" add
.on("add", this.handleWatchFileAdd)
// Extension remove is detected by watching "<extensionDir>" unlink
.on("unlinkDir", this.handleWatchUnlinkEvent)
// Extension remove is detected by watching "<extensionSymLink>" unlink
.on("unlink", this.handleWatchUnlinkEvent);
}
handleWatchFileAdd = async (manifestPath: string): Promise<void> => {
// e.g. "foo/package.json"
const relativePath = this.dependencies.getRelativePath(this.localFolderPath, manifestPath);
// Converts "foo/package.json" to ["foo", "package.json"], where length of 2 implies
// that the added file is in a folder under local folder path.
// This safeguards against a file watch being triggered under a sub-directory which is not an extension.
const isUnderLocalFolderPath = relativePath.split(this.dependencies.fileSystemSeparator).length === 2;
if (this.dependencies.getBasenameOfPath(manifestPath) === manifestFilename && isUnderLocalFolderPath) {
try {
this.dependencies.extensionInstallationStateStore.setInstallingFromMain(manifestPath);
const absPath = this.dependencies.getDirnameOfPath(manifestPath);
// this.loadExtensionFromPath updates this.packagesJson
const extension = await this.loadExtensionFromFolder(absPath);
if (extension) {
// Install dependencies for the new extension
await this.dependencies.installExtension(extension.absolutePath);
this.dependencies.installedExtensions.set(extension.id, extension);
this.dependencies.logger.info(`${logModule} Added extension ${extension.manifest.name}`);
this.events.emit("add", extension);
}
} catch (error) {
this.dependencies.logger.error(`${logModule}: failed to add extension: ${error}`, { error });
} finally {
this.dependencies.extensionInstallationStateStore.clearInstallingFromMain(manifestPath);
}
}
};
/**
* Handle any unlink event, filtering out non-package.json links so the delete code
* only happens once per extension.
* @param filePath The absolute path to either a folder or file in the extensions folder
*/
handleWatchUnlinkEvent = async (filePath: string): Promise<void> => {
// Check that the removed path is directly under this.localFolderPath
// Note that the watcher can create unlink events for subdirectories of the extension
const extensionFolderName = this.dependencies.getBasenameOfPath(filePath);
const expectedPath = this.dependencies.getRelativePath(this.localFolderPath, filePath);
if (expectedPath !== extensionFolderName) {
return;
}
const extension = iter.find(
this.dependencies.installedExtensions.values(),
(ext) => !ext.isBundled && ext.absolutePath === filePath,
);
if (!extension) {
this.dependencies.logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`);
return;
}
// If the extension is deleted manually while the application is running, also remove the symlink
await this.removeSymlinkByPackageName(extension.manifest.name);
this.dependencies.installedExtensions.delete(extension.id);
this.dependencies.logger.info(`${logModule} removed extension ${extension.manifest.name}`);
this.events.emit("remove", extension);
};
/**
* Remove the symlink under node_modules if exists.
* If we don't remove the symlink, the uninstall would leave a non-working symlink,
* which wouldn't be fixed if the extension was reinstalled, causing the extension not to work.
* @param name e.g. "@mirantis/lens-extension-cc"
*/
removeSymlinkByPackageName(name: string): Promise<void> {
return this.dependencies.removePath(this.getInstalledPath(name));
}
/**
* Uninstalls extension.
* The application will detect the folder unlink and remove the extension from the UI automatically.
* @param extensionId The ID of the extension to uninstall.
*/
async uninstallExtension(extensionId: LensExtensionId): Promise<void> {
const extension = this.dependencies.installedExtensions.get(extensionId);
if (!extension) {
return void this.dependencies.logger.warn(`${logModule} could not uninstall extension, not found`, { id: extensionId });
}
if (extension.isBundled) {
return void this.dependencies.logger.warn(`${logModule} could not uninstall extension, is bundled`, { id: extensionId });
}
const { manifest, absolutePath } = extension;
this.dependencies.logger.info(`${logModule} Uninstalling ${manifest.name}`);
await this.removeSymlinkByPackageName(manifest.name);
// fs.remove does nothing if the path doesn't exist anymore
await this.dependencies.removePath(absolutePath);
}
async load() {
if (this.loadStarted) {
// The class is simplified by only supporting .load() to be called once
throw new Error("ExtensionDiscovery.load() can be only be called once");
}
this.loadStarted = true;
this.dependencies.logger.info(
`${logModule} loading extensions from ${this.dependencies.extensionPackageRootDirectory}`,
);
await this.dependencies.removePath(this.dependencies.joinPaths(this.dependencies.extensionPackageRootDirectory, "package-lock.json"));
await this.dependencies.ensureDirectory(this.nodeModulesPath);
await this.dependencies.ensureDirectory(this.localFolderPath);
const userExtensions = await this.loadFromFolder(this.localFolderPath);
this.dependencies.installedExtensions.replace(userExtensions.map(ext => [ext.id, ext]));
this.isLoaded = true;
}
/**
* Returns the symlinked path to the extension folder,
* e.g. "/Users/<username>/Library/Application Support/Lens/node_modules/@publisher/extension"
*/
protected getInstalledPath(name: string): string {
return this.dependencies.joinPaths(this.nodeModulesPath, name);
}
/**
* Returns the symlinked path to the package.json,
* e.g. "/Users/<username>/Library/Application Support/Lens/node_modules/@publisher/extension/package.json"
*/
protected getInstalledManifestPath(name: string): string {
return this.dependencies.joinPaths(this.getInstalledPath(name), manifestFilename);
}
/**
* Returns InstalledExtension from path to package.json file.
* Also updates this.packagesJson.
*/
protected async loadExtensionFromFolder(folderPath: string): Promise<ExternalInstalledExtension | null> {
const manifestPath = this.dependencies.joinPaths(folderPath, manifestFilename);
try {
const manifest = await this.dependencies.readJsonFile(manifestPath) as unknown as LensExtensionManifest;
const id = this.getInstalledManifestPath(manifest.name);
const isEnabled = this.dependencies.extensionsStore.isEnabled(id);
const extensionDir = this.dependencies.getDirnameOfPath(manifestPath);
const npmPackage = this.dependencies.joinPaths(extensionDir, `${manifest.name}-${manifest.version}.tgz`);
const absolutePath = this.dependencies.isProduction && await this.dependencies.pathExists(npmPackage)
? npmPackage
: extensionDir;
const isCompatible = this.dependencies.isCompatibleExtension(manifest);
return {
id,
absolutePath,
manifestPath: id,
manifest,
isBundled: false,
isEnabled,
isCompatible,
};
} catch (error) {
if (isErrnoException(error) && error.code === "ENOTDIR") {
// ignore this error, probably from .DS_Store file
this.dependencies.logger.debug(`${logModule}: failed to load extension manifest through a not-dir-like at ${manifestPath}`);
} else {
this.dependencies.logger.error(`${logModule}: can't load extension manifest at ${manifestPath}: ${error}`);
}
return null;
}
}
async loadFromFolder(folderPath: string): Promise<ExternalInstalledExtension[]> {
const extensions: ExternalInstalledExtension[] = [];
const paths = await this.dependencies.readDirectory(folderPath);
for (const fileName of paths) {
const absPath = this.dependencies.joinPaths(folderPath, fileName);
try {
const lstat = await this.dependencies.lstat(absPath);
// skip non-directories
if (!isDirectoryLike(lstat)) {
continue;
}
} catch (error) {
if (isErrnoException(error) && error.code === "ENOENT") {
continue;
}
throw error;
}
const extension = await this.loadExtensionFromFolder(absPath);
if (extension) {
extensions.push(extension);
}
}
this.dependencies.logger.debug(`${logModule}: ${extensions.length} extensions loaded`, { folderPath, extensions });
return extensions;
}
toJSON(): ExtensionDiscoveryChannelMessage {
return toJS({
isLoaded: this.isLoaded,
});
}
broadcast(): void {
broadcastMessage(extensionDiscoveryStateChannel, this.toJSON());
}
}
@@ -1,16 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import extensionApiVersionInjectable from "../../../common/vars/extension-api-version.injectable";
import { isCompatibleExtension } from "./is-compatible-extension";
const isCompatibleExtensionInjectable = getInjectable({
id: "is-compatible-extension",
instantiate: (di) => isCompatibleExtension({
extensionApiVersion: di.inject(extensionApiVersionInjectable),
}),
});
export default isCompatibleExtensionInjectable;
@@ -1,37 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import semver from "semver";
import type { LensExtensionManifest } from "../../lens-extension";
interface Dependencies {
extensionApiVersion: string;
}
export const isCompatibleExtension = ({ extensionApiVersion }: Dependencies): ((manifest: LensExtensionManifest) => boolean) => {
return (manifest: LensExtensionManifest): boolean => {
const manifestLensEngine = manifest.engines.lens;
const validVersion = manifestLensEngine.match(/^[\^0-9]\d*\.\d+\b/); // must start from ^ or number
if (!validVersion) {
const errorInfo = [
`Invalid format for "manifest.engines.lens"="${manifestLensEngine}"`,
`Range versions can only be specified starting with '^'.`,
`Otherwise it's recommended to use plain %MAJOR.%MINOR to match with supported Lens version.`,
].join("\n");
throw new Error(errorInfo);
}
const { major: extMajor, minor: extMinor } = semver.coerce(manifestLensEngine, {
loose: true,
}) as semver.SemVer;
const supportedVersionsByExtension = semver.validRange(`^${extMajor}.${extMinor}`) as string;
return semver.satisfies(extensionApiVersion, supportedVersionsByExtension, {
loose: true,
includePrerelease: false,
});
};
};
@@ -9,7 +9,7 @@ import { getOrInsert } from "../../../common/utils";
import randomBytesInjectable from "../../../common/utils/random-bytes.injectable";
import joinPathsInjectable from "../../../common/path/join-paths.injectable";
import directoryForExtensionDataInjectable from "./directory-for-extension-data.injectable";
import ensureDirInjectable from "../../../common/fs/ensure-dir.injectable";
import ensureDirectoryInjectable from "../../../common/fs/ensure-directory.injectable";
import getHashInjectable from "./get-hash.injectable";
import getPathToLegacyPackageJsonInjectable from "./get-path-to-legacy-package-json.injectable";
import { registeredExtensionsInjectable } from "./registered-extensions.injectable";
@@ -23,7 +23,7 @@ const ensureHashedDirectoryForExtensionInjectable = getInjectable({
const randomBytes = di.inject(randomBytesInjectable);
const joinPaths = di.inject(joinPathsInjectable);
const directoryForExtensionData = di.inject(directoryForExtensionDataInjectable);
const ensureDirectory = di.inject(ensureDirInjectable);
const ensureDirectory = di.inject(ensureDirectoryInjectable);
const getHash = di.inject(getHashInjectable);
const getPathToLegacyPackageJson = di.inject(getPathToLegacyPackageJsonInjectable);
const registeredExtensions = di.inject(registeredExtensionsInjectable);
@@ -36,10 +36,8 @@ export class ExtensionsStore extends BaseStore<LensExtensionsStoreModel> {
readonly state = observable.map<LensExtensionId, LensExtensionState>();
isEnabled(extId: LensExtensionId): boolean {
// By default false, so that copied extensions are disabled by default.
// If user installs the extension from the UI, the Extensions component will specifically enable it.
return this.state.get(extId)?.enabled ?? false;
isEnabled(id: LensExtensionId): boolean {
return this.state.get(id)?.enabled ?? false;
}
@action
@@ -1,14 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
const extensionPackageRootDirectoryInjectable = getInjectable({
id: "extension-package-root-directory",
instantiate: (di) => di.inject(directoryForUserDataInjectable),
});
export default extensionPackageRootDirectoryInjectable;
@@ -1,111 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { fork } from "child_process";
import AwaitLock from "await-lock";
import pathToNpmCliInjectable from "../../common/app-paths/path-to-npm-cli.injectable";
import extensionPackageRootDirectoryInjectable from "./extension-package-root-directory.injectable";
import prefixedLoggerInjectable from "../../common/logger/prefixed-logger.injectable";
import readJsonFileInjectable from "../../common/fs/read-json-file.injectable";
import joinPathsInjectable from "../../common/path/join-paths.injectable";
import type { PackageJson } from "../common-api";
import writeJsonFileInjectable from "../../common/fs/write-json-file.injectable";
import { once } from "lodash";
import { isErrnoException } from "../../common/utils";
const baseNpmInstallArgs = [
"install",
"--save-optional",
"--audit=false",
"--fund=false",
// NOTE: we do not omit the `optional` dependencies because that is how we specify the non-bundled extensions
"--omit=dev",
"--omit=peer",
"--prefer-offline",
];
export type InstallExtension = (name: string) => Promise<void>;
const installExtensionInjectable = getInjectable({
id: "install-extension",
instantiate: (di): InstallExtension => {
const pathToNpmCli = di.inject(pathToNpmCliInjectable);
const extensionPackageRootDirectory = di.inject(extensionPackageRootDirectoryInjectable);
const readJsonFile = di.inject(readJsonFileInjectable);
const writeJsonFile = di.inject(writeJsonFileInjectable);
const joinPaths = di.inject(joinPathsInjectable);
const logger = di.inject(prefixedLoggerInjectable, "EXTENSION-INSTALLER");
const forkNpm = (...args: string[]) => new Promise<void>((resolve, reject) => {
const child = fork(pathToNpmCli, args, {
cwd: extensionPackageRootDirectory,
silent: true,
env: {},
});
let stderr = "";
child.stderr?.on("data", data => {
stderr += String(data);
});
child.on("close", (code) => {
if (code !== 0) {
reject(new Error(stderr));
} else {
resolve();
}
});
child.on("error", error => {
reject(error);
});
});
const packageJsonPath = joinPaths(extensionPackageRootDirectory, "package.json");
/**
* NOTES:
* - We have to keep the `package.json` because `npm install` removes files from `node_modules`
* if they are no longer in the `package.json`
* - In v6.2.X we saved bundled extensions as `"dependencies"` and external extensions as
* `"optionalDependencies"` at startup. This was done because `"optionalDependencies"` can
* fail to install and that is OK.
* - We continue to maintain this behavior here by only installing new dependencies as
* `"optionalDependencies"`
*/
const fixupPackageJson = once(async () => {
try {
const packageJson = await readJsonFile(packageJsonPath) as PackageJson;
delete packageJson.dependencies;
await writeJsonFile(packageJsonPath, packageJson);
} catch (error) {
if (isErrnoException(error) && error.code === "ENOENT") {
return;
}
throw error;
}
});
const installLock = new AwaitLock();
return async (name) => {
await installLock.acquireAsync();
await fixupPackageJson();
try {
logger.info(`installing package for extension "${name}"`);
await forkNpm(...baseNpmInstallArgs, name);
logger.info(`installed package for extension "${name}"`);
} finally {
installLock.release();
}
};
},
});
export default installExtensionInjectable;