mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Restructure to monorepo (#6907)
* wip: restructure to monorepo Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * refactor create-release-pr to a package Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * build fixes Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * github workflow fixes Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fix typo Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * add webpack-env types to core Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fix github workflows Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * refactor/fix integration tests Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * lint fix Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * yarn run dev Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * eslint settings for vscode Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * move templates to right package Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * open-lens build fixes Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * integration test fix Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fix nx task dependencies Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * use bash shell for unit tests in test workflow Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fix test:unit for windows Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fix win-ca webpack error in open-lens Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fix win-ca webpack error in open-lens Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fix build:app on windows Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * remove ELECTRON_BUILDER_EXTRA_ARGS Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * sync src/ from master Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * remove Makefile from core Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
1 parent
0066ca4ea2
commit
2657df2293
3321 files changed
+28699
-11447
No files matched your search
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 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 { LensExtensionConstructor, LensExtensionManifest } from "../lens-extension";
|
||||
|
||||
export interface BundledExtension {
|
||||
readonly manifest: LensExtensionManifest;
|
||||
main: () => LensExtensionConstructor | null;
|
||||
renderer: () => LensExtensionConstructor | null;
|
||||
}
|
||||
|
||||
export const bundledExtensionInjectionToken = getInjectionToken<BundledExtension>({
|
||||
id: "bundled-extension-path",
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 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 extensionLoaderInjectable from "../extension-loader/extension-loader.injectable";
|
||||
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 "../extension-installer/install-extension/install-extension.injectable";
|
||||
import extensionPackageRootDirectoryInjectable from "../extension-installer/extension-package-root-directory/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";
|
||||
|
||||
const extensionDiscoveryInjectable = getInjectable({
|
||||
id: "extension-discovery",
|
||||
|
||||
instantiate: (di) => new ExtensionDiscovery({
|
||||
extensionLoader: di.inject(extensionLoaderInjectable),
|
||||
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),
|
||||
}),
|
||||
});
|
||||
|
||||
export default extensionDiscoveryInjectable;
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 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 "../extension-installer/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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* 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 { makeObservable, observable, reaction, when } from "mobx";
|
||||
import { broadcastMessage, ipcMainHandle, ipcRendererOn } from "../../common/ipc";
|
||||
import { isErrnoException, toJS } from "../../common/utils";
|
||||
import type { ExtensionsStore } from "../extensions-store/extensions-store";
|
||||
import type { ExtensionLoader } from "../extension-loader";
|
||||
import type { 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 extensionLoader: ExtensionLoader;
|
||||
readonly extensionsStore: ExtensionsStore;
|
||||
readonly extensionInstallationStateStore: ExtensionInstallationStateStore;
|
||||
readonly extensionPackageRootDirectory: string;
|
||||
readonly resourcesDirectory: string;
|
||||
readonly logger: Logger;
|
||||
readonly isProduction: boolean;
|
||||
readonly fileSystemSeparator: string;
|
||||
readonly homeDirectoryPath: string;
|
||||
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 InstalledExtension {
|
||||
id: LensExtensionId;
|
||||
|
||||
readonly manifest: LensExtensionManifest;
|
||||
|
||||
// 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;
|
||||
readonly isBundled: boolean; // defined in project root's package.json
|
||||
readonly isCompatible: boolean;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
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 LoadFromFolderOptions {
|
||||
isBundled?: boolean;
|
||||
}
|
||||
|
||||
interface ExtensionDiscoveryEvents {
|
||||
add: (ext: InstalledExtension) => void;
|
||||
remove: (extId: LensExtensionId) => 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;
|
||||
private extensions: Map<string, InstalledExtension> = new Map();
|
||||
|
||||
// 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.
|
||||
*/
|
||||
async watchExtensions(): Promise<void> {
|
||||
this.dependencies.logger.info(`${logModule} watching extension add/remove in ${this.localFolderPath}`);
|
||||
|
||||
// Wait until .load() has been called and has been resolved
|
||||
await this.whenLoaded;
|
||||
|
||||
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) {
|
||||
// Remove a broken symlink left by a previous installation if it exists.
|
||||
await this.dependencies.removePath(extension.manifestPath);
|
||||
|
||||
// Install dependencies for the new extension
|
||||
await this.dependencies.installExtension(extension.absolutePath);
|
||||
|
||||
this.extensions.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;
|
||||
}
|
||||
|
||||
for (const extension of this.extensions.values()) {
|
||||
if (extension.absolutePath !== filePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const extensionName = extension.manifest.name;
|
||||
|
||||
// If the extension is deleted manually while the application is running, also remove the symlink
|
||||
await this.removeSymlinkByPackageName(extensionName);
|
||||
|
||||
// The path to the manifest file is the lens extension id
|
||||
// Note: that we need to use the symlinked path
|
||||
const lensExtensionId = extension.manifestPath;
|
||||
|
||||
this.extensions.delete(extension.id);
|
||||
this.dependencies.logger.info(`${logModule} removed extension ${extensionName}`);
|
||||
this.events.emit("remove", lensExtensionId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.dependencies.logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.extensions.get(extensionId) ?? this.dependencies.extensionLoader.getExtension(extensionId);
|
||||
|
||||
if (!extension) {
|
||||
return void this.dependencies.logger.warn(`${logModule} could not uninstall extension, not found`, { 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(): Promise<Map<LensExtensionId, InstalledExtension>> {
|
||||
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 extensions = await this.ensureExtensions();
|
||||
|
||||
this.isLoaded = true;
|
||||
|
||||
return extensions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 getByManifest(manifestPath: string, { isBundled = false } = {}): Promise<InstalledExtension | null> {
|
||||
try {
|
||||
const manifest = await this.dependencies.readJsonFile(manifestPath) as unknown as LensExtensionManifest;
|
||||
const id = isBundled ? manifestPath : this.getInstalledManifestPath(manifest.name);
|
||||
const isEnabled = this.dependencies.extensionsStore.isEnabled({ id, isBundled });
|
||||
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 = isBundled || this.dependencies.isCompatibleExtension(manifest);
|
||||
|
||||
return {
|
||||
id,
|
||||
absolutePath,
|
||||
manifestPath: id,
|
||||
manifest,
|
||||
isBundled,
|
||||
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 ensureExtensions(): Promise<Map<LensExtensionId, InstalledExtension>> {
|
||||
const userExtensions = await this.loadFromFolder(this.localFolderPath);
|
||||
|
||||
return this.extensions = new Map(userExtensions.map(extension => [extension.id, extension]));
|
||||
}
|
||||
|
||||
async loadFromFolder(folderPath: string): Promise<InstalledExtension[]> {
|
||||
const extensions: InstalledExtension[] = [];
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads extension from absolute path, updates this.packagesJson to include it and returns the extension.
|
||||
* @param folderPath Folder path to extension
|
||||
*/
|
||||
async loadExtensionFromFolder(folderPath: string, { isBundled = false }: LoadFromFolderOptions = {}): Promise<InstalledExtension | null> {
|
||||
const manifestPath = this.dependencies.joinPaths(folderPath, manifestFilename);
|
||||
|
||||
return this.getByManifest(manifestPath, { isBundled });
|
||||
}
|
||||
|
||||
toJSON(): ExtensionDiscoveryChannelMessage {
|
||||
return toJS({
|
||||
isLoaded: this.isLoaded,
|
||||
});
|
||||
}
|
||||
|
||||
broadcast(): void {
|
||||
broadcastMessage(extensionDiscoveryStateChannel, this.toJSON());
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 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;
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
};
|
||||
};
|
||||
Reference in new issue
Block a user