mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Fully convert ExtensionDiscovery to be injectable
- To fix unit tests Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
parent
7f86a89cc2
commit
ac6e3e18e2
@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import { getGlobalOverride } from "../test-utils/get-global-override";
|
||||
import copyInjectable from "./copy.injectable";
|
||||
|
||||
export default getGlobalOverride(copyInjectable, () => async () => {
|
||||
throw new Error("tried to copy filepaths without override");
|
||||
});
|
||||
@ -7,12 +7,9 @@ import fsInjectable from "./fs.injectable";
|
||||
|
||||
export type EnsureDirectory = (dirPath: string) => Promise<void>;
|
||||
|
||||
const ensureDirInjectable = getInjectable({
|
||||
const ensureDirectoryInjectable = getInjectable({
|
||||
id: "ensure-dir",
|
||||
|
||||
// TODO: Remove usages of ensureDir from business logic.
|
||||
// TODO: Read, Write, Watch etc. operations should do this internally.
|
||||
instantiate: (di): EnsureDirectory => di.inject(fsInjectable).ensureDir,
|
||||
});
|
||||
|
||||
export default ensureDirInjectable;
|
||||
export default ensureDirectoryInjectable;
|
||||
@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import { getGlobalOverride } from "../test-utils/get-global-override";
|
||||
import lstatInjectable from "./lstat.injectable";
|
||||
|
||||
export default getGlobalOverride(lstatInjectable, () => async () => {
|
||||
throw new Error("tried to lstat a filepath without override");
|
||||
});
|
||||
@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import { getGlobalOverride } from "../test-utils/get-global-override";
|
||||
import readDirectoryInjectable from "./read-directory.injectable";
|
||||
|
||||
export default getGlobalOverride(readDirectoryInjectable, () => async () => {
|
||||
throw new Error("tried to read a directory's content without override");
|
||||
});
|
||||
@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import { getGlobalOverride } from "../test-utils/get-global-override";
|
||||
import removePathInjectable from "./remove.injectable";
|
||||
|
||||
export default getGlobalOverride(removePathInjectable, () => async () => {
|
||||
throw new Error("tried to remove path without override");
|
||||
});
|
||||
@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import { getGlobalOverride } from "../test-utils/get-global-override";
|
||||
import writeFileInjectable from "./write-file.injectable";
|
||||
|
||||
export default getGlobalOverride(writeFileInjectable, () => async () => {
|
||||
throw new Error("tried to write file without override");
|
||||
});
|
||||
@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
export const extensionDiscoveryStateChannel = "extension-discovery:state";
|
||||
@ -4,4 +4,4 @@
|
||||
*/
|
||||
|
||||
// @experimental
|
||||
export { bundledExtensionInjectionToken } from "../extensions/extension-discovery/bundled-extension-token";
|
||||
export { bundledExtensionInjectionToken } from "../features/extensions/common/bundled-extension-token";
|
||||
|
||||
@ -5,8 +5,8 @@
|
||||
import { getInjectionToken } from "@ogre-tools/injectable";
|
||||
import type { IComputedValue } from "mobx";
|
||||
export interface SyncBox<Value> {
|
||||
id: string;
|
||||
value: IComputedValue<Value>;
|
||||
readonly id: string;
|
||||
readonly value: IComputedValue<Value>;
|
||||
set: (value: Value) => void;
|
||||
}
|
||||
|
||||
|
||||
@ -12,6 +12,8 @@ export const defaultFontSize = 12;
|
||||
export const defaultTerminalFontFamily = "RobotoMono";
|
||||
export const defaultEditorFontFamily = "RobotoMono";
|
||||
|
||||
export const manifestFilename = "package.json";
|
||||
|
||||
// Apis
|
||||
export const apiPrefix = "/api"; // local router apis
|
||||
export const apiKubePrefix = "/api-kube"; // k8s cluster apis
|
||||
|
||||
@ -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;
|
||||
@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { getInjectionToken } from "@ogre-tools/injectable";
|
||||
import type { BundledLensExtensionManifest, BundledLensExtensionContructor } from "../lens-extension";
|
||||
import type { BundledLensExtensionManifest, BundledLensExtensionContructor } from "../../../extensions/lens-extension";
|
||||
|
||||
export interface BundledExtension {
|
||||
readonly manifest: BundledLensExtensionManifest;
|
||||
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import type { LensExtensionId, LensExtensionManifest } from "../../../extensions/lens-extension";
|
||||
|
||||
export interface InstalledExtension {
|
||||
readonly 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;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import { getGlobalOverrideForFunction } from "../../../../common/test-utils/get-global-override-for-function";
|
||||
import execNpmInjectable from "./exec-npm.injectable";
|
||||
|
||||
export default getGlobalOverrideForFunction(execNpmInjectable);
|
||||
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 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 directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
|
||||
import pathToNpmCliInjectable from "../../../../common/app-paths/path-to-npm-cli.injectable";
|
||||
import type { AsyncResult } from "../../../../common/utils/async-result";
|
||||
|
||||
export type ExecNpm = (...args: string[]) => Promise<AsyncResult<void, Error>>;
|
||||
|
||||
const execNpmInjectable = getInjectable({
|
||||
id: "exec-npm",
|
||||
instantiate: (di): ExecNpm => {
|
||||
const pathToNpmCli = di.inject(pathToNpmCliInjectable);
|
||||
const directoryForUserData = di.inject(directoryForUserDataInjectable);
|
||||
|
||||
return (...args) => new Promise((resolve) => {
|
||||
const child = fork(pathToNpmCli, args, {
|
||||
cwd: directoryForUserData,
|
||||
silent: true,
|
||||
env: {},
|
||||
});
|
||||
let stderr = "";
|
||||
|
||||
child.stderr?.on("data", data => {
|
||||
stderr += String(data);
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
resolve({
|
||||
callWasSuccessful: false,
|
||||
error: new Error(stderr),
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
callWasSuccessful: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", error => {
|
||||
resolve({
|
||||
callWasSuccessful: false,
|
||||
error,
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
causesSideEffects: true,
|
||||
});
|
||||
|
||||
export default execNpmInjectable;
|
||||
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 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";
|
||||
import joinPathsInjectable from "../../../../common/path/join-paths.injectable";
|
||||
|
||||
const extensionsNodeModulesDirectoryPathInjectable = getInjectable({
|
||||
id: "extensions-node-modules-directory-path",
|
||||
instantiate: (di) => {
|
||||
const directoryForUserData = di.inject(directoryForUserDataInjectable);
|
||||
const joinPaths = di.inject(joinPathsInjectable);
|
||||
|
||||
return joinPaths(directoryForUserData, "node_modules");
|
||||
},
|
||||
});
|
||||
|
||||
export default extensionsNodeModulesDirectoryPathInjectable;
|
||||
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 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 createSyncBoxInjectable from "../../../../common/utils/sync-box/create-sync-box.injectable";
|
||||
import { syncBoxInjectionToken } from "../../../../common/utils/sync-box/sync-box-injection-token";
|
||||
|
||||
const initialDiscoveryLoadCompletedInjectable = getInjectable({
|
||||
id: "initial-discovery-load-completed",
|
||||
instantiate: (di) => {
|
||||
const createSyncBox = di.inject(createSyncBoxInjectable);
|
||||
|
||||
return createSyncBox("initial-extension-discovery-load-complete", false);
|
||||
},
|
||||
injectionToken: syncBoxInjectionToken,
|
||||
});
|
||||
|
||||
export default initialDiscoveryLoadCompletedInjectable;
|
||||
@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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 AwaitLock from "await-lock";
|
||||
import { once } from "lodash";
|
||||
import type { PackageJson } from "type-fest";
|
||||
import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
|
||||
import readJsonFileInjectable from "../../../../common/fs/read-json-file.injectable";
|
||||
import writeJsonFileInjectable from "../../../../common/fs/write-json-file.injectable";
|
||||
import prefixedLoggerInjectable from "../../../../common/logger/prefixed-logger.injectable";
|
||||
import joinPathsInjectable from "../../../../common/path/join-paths.injectable";
|
||||
import { isErrnoException } from "../../../../common/utils";
|
||||
import execNpmInjectable from "./exec-npm.injectable";
|
||||
|
||||
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 InstallExtensionPackage = (name: string) => Promise<void>;
|
||||
|
||||
const installExtensionPackageInjectable = getInjectable({
|
||||
id: "install-extension-package",
|
||||
instantiate: (di): InstallExtensionPackage => {
|
||||
const logger = di.inject(prefixedLoggerInjectable, "EXTENSION-INSTALLER");
|
||||
const execNpm = di.inject(execNpmInjectable);
|
||||
const directoryForUserData = di.inject(directoryForUserDataInjectable);
|
||||
const joinPaths = di.inject(joinPathsInjectable);
|
||||
const readJsonFile = di.inject(readJsonFileInjectable);
|
||||
const writeJsonFile = di.inject(writeJsonFileInjectable);
|
||||
|
||||
const installLock = new AwaitLock();
|
||||
const packageJsonPath = joinPaths(directoryForUserData, "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;
|
||||
}
|
||||
});
|
||||
|
||||
return async (name) => {
|
||||
await installLock.acquireAsync();
|
||||
|
||||
logger.info(`Installing "${name}"`);
|
||||
|
||||
await fixupPackageJson();
|
||||
|
||||
const result = await execNpm(...baseNpmInstallArgs, name);
|
||||
|
||||
if (!result.callWasSuccessful) {
|
||||
logger.warn(`Failed to install "${name}"`, result.error);
|
||||
}
|
||||
|
||||
installLock.release();
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default installExtensionPackageInjectable;
|
||||
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 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 homeDirectoryPathInjectable from "../../../../common/os/home-directory-path.injectable";
|
||||
import joinPathsInjectable from "../../../../common/path/join-paths.injectable";
|
||||
|
||||
const localExtensionsDirectoryPathInjectable = getInjectable({
|
||||
id: "local-extensions-directory-path",
|
||||
instantiate: (di) => {
|
||||
const joinPaths = di.inject(joinPathsInjectable);
|
||||
const homeDirectoryPath = di.inject(homeDirectoryPathInjectable);
|
||||
|
||||
return joinPaths(homeDirectoryPath, ".k8slens", "extensions");
|
||||
},
|
||||
});
|
||||
|
||||
export default localExtensionsDirectoryPathInjectable;
|
||||
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 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 prefixedLoggerInjectable from "../../../../common/logger/prefixed-logger.injectable";
|
||||
|
||||
const extensionDiscoveryLoggerInjectable = getInjectable({
|
||||
id: "extension-discovery-logger",
|
||||
instantiate: (di) => di.inject(prefixedLoggerInjectable, "EXTENSION-DISCOVERY"),
|
||||
});
|
||||
|
||||
export default extensionDiscoveryLoggerInjectable;
|
||||
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 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 removePathInjectable from "../../../../common/fs/remove.injectable";
|
||||
import getExtensionInstallPathInjectable from "../main/get-extension-install-path.injectable";
|
||||
|
||||
/**
|
||||
* 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"
|
||||
*/
|
||||
export type RemoveExtensionSymlinkByName = (name: string) => Promise<void>;
|
||||
|
||||
const removeExtensionSymlinkByNameInjectable = getInjectable({
|
||||
id: "remove-extension-symlink-by-name",
|
||||
instantiate: (di): RemoveExtensionSymlinkByName => {
|
||||
const removePath = di.inject(removePathInjectable);
|
||||
const getExtensionInstallPath = di.inject(getExtensionInstallPathInjectable);
|
||||
|
||||
return (name) => removePath(getExtensionInstallPath(name));
|
||||
},
|
||||
});
|
||||
|
||||
export default removeExtensionSymlinkByNameInjectable;
|
||||
@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 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 removePathInjectable from "../../../../common/fs/remove.injectable";
|
||||
import type { LensExtensionId } from "../../../../extensions/lens-extension";
|
||||
import installedExtensionsInjectable from "../../common/installed-extensions.injectable";
|
||||
import extensionDiscoveryLoggerInjectable from "./logger.injectable";
|
||||
import removeExtensionSymlinkByNameInjectable from "./remove-extension-symlink-by-name.injectable";
|
||||
|
||||
/**
|
||||
* The application will detect the folder unlink and remove the extension from the UI automatically.
|
||||
* @param id The ID of the extension to uninstall.
|
||||
*/
|
||||
export type RemoveExtensionFiles = (id: LensExtensionId) => Promise<void>;
|
||||
|
||||
const removeExtensionFilesInjectable = getInjectable({
|
||||
id: "remove-extension-files",
|
||||
instantiate: (di): RemoveExtensionFiles => {
|
||||
const installedExtensions = di.inject(installedExtensionsInjectable);
|
||||
const removePath = di.inject(removePathInjectable);
|
||||
const logger = di.inject(extensionDiscoveryLoggerInjectable);
|
||||
const removeExtensionSymlinkByName = di.inject(removeExtensionSymlinkByNameInjectable);
|
||||
|
||||
return async (id): Promise<void> => {
|
||||
const extension = installedExtensions.get(id);
|
||||
|
||||
if (!extension) {
|
||||
return logger.warn(`could not uninstall extension, not found`, { id });
|
||||
}
|
||||
|
||||
if (extension.isBundled) {
|
||||
return logger.warn(`could not uninstall extension, is bundled`, { id });
|
||||
}
|
||||
|
||||
const { manifest, absolutePath } = extension;
|
||||
|
||||
logger.info(`Uninstalling ${manifest.name}`);
|
||||
|
||||
await removeExtensionSymlinkByName(manifest.name);
|
||||
|
||||
// fs.remove does nothing if the path doesn't exist anymore
|
||||
await removePath(absolutePath);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default removeExtensionFilesInjectable;
|
||||
@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 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 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 fileSystemSeparatorInjectable from "../../../../common/path/separator.injectable";
|
||||
import { manifestFilename } from "../../../../common/vars";
|
||||
import extensionInstallationStateStoreInjectable from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
|
||||
import installedExtensionsInjectable from "../../common/installed-extensions.injectable";
|
||||
import installExtensionPackageInjectable from "../common/install-package.injectable";
|
||||
import localExtensionsDirectoryPathInjectable from "../common/local-extensions-directory-path.injectable";
|
||||
import extensionDiscoveryLoggerInjectable from "../common/logger.injectable";
|
||||
import loadUserExtensionFromFolderInjectable from "./load-user-extension-from-folder.injectable";
|
||||
|
||||
const extensionFileAddedInjectable = getInjectable({
|
||||
id: "extension-file-added",
|
||||
instantiate: (di) => {
|
||||
const getRelativePath = di.inject(getRelativePathInjectable);
|
||||
const localExtensionsDirectoryPath = di.inject(localExtensionsDirectoryPathInjectable);
|
||||
const fileSystemSeparator = di.inject(fileSystemSeparatorInjectable);
|
||||
const getBasenameOfPath = di.inject(getBasenameOfPathInjectable);
|
||||
const extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable);
|
||||
const getDirnameOfPath = di.inject(getDirnameOfPathInjectable);
|
||||
const loadUserExtensionFromFolder = di.inject(loadUserExtensionFromFolderInjectable);
|
||||
const installExtensionPackage = di.inject(installExtensionPackageInjectable);
|
||||
const installedExtensions = di.inject(installedExtensionsInjectable);
|
||||
const logger = di.inject(extensionDiscoveryLoggerInjectable);
|
||||
|
||||
return async (manifestPath: string): Promise<void> => {
|
||||
// e.g. "foo/package.json"
|
||||
const relativePath = getRelativePath(localExtensionsDirectoryPath, 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(fileSystemSeparator).length === 2;
|
||||
|
||||
if (getBasenameOfPath(manifestPath) === manifestFilename && isUnderLocalFolderPath) {
|
||||
try {
|
||||
extensionInstallationStateStore.setInstallingFromMain(manifestPath);
|
||||
const absPath = getDirnameOfPath(manifestPath);
|
||||
|
||||
// this.loadExtensionFromPath updates this.packagesJson
|
||||
const extension = await loadUserExtensionFromFolder(absPath);
|
||||
|
||||
if (extension) {
|
||||
// Install dependencies for the new extension
|
||||
await installExtensionPackage(extension.absolutePath);
|
||||
|
||||
installedExtensions.set(extension.id, extension);
|
||||
logger.info(`Added extension ${extension.manifest.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`failed to add extension: ${error}`, { error });
|
||||
} finally {
|
||||
extensionInstallationStateStore.clearInstallingFromMain(manifestPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default extensionFileAddedInjectable;
|
||||
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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 getBasenameOfPathInjectable from "../../../../common/path/get-basename.injectable";
|
||||
import getRelativePathInjectable from "../../../../common/path/get-relative-path.injectable";
|
||||
import { iter } from "../../../../common/utils";
|
||||
import installedExtensionsInjectable from "../../common/installed-extensions.injectable";
|
||||
import removeExtensionInstanceInjectable from "../../loader/common/remove-instance.injectable";
|
||||
import localExtensionsDirectoryPathInjectable from "../common/local-extensions-directory-path.injectable";
|
||||
import extensionDiscoveryLoggerInjectable from "../common/logger.injectable";
|
||||
import removeExtensionSymlinkByNameInjectable from "../common/remove-extension-symlink-by-name.injectable";
|
||||
|
||||
const extensionFileRemovedInjectable = getInjectable({
|
||||
id: "extension-file-removed",
|
||||
instantiate: (di) => {
|
||||
const getBasenameOfPath = di.inject(getBasenameOfPathInjectable);
|
||||
const getRelativePath = di.inject(getRelativePathInjectable);
|
||||
const removeExtensionSymlinkByName = di.inject(removeExtensionSymlinkByNameInjectable);
|
||||
const removeExtensionInstance = di.inject(removeExtensionInstanceInjectable);
|
||||
const localExtensionsDirectoryPath = di.inject(localExtensionsDirectoryPathInjectable);
|
||||
const installedExtensions = di.inject(installedExtensionsInjectable);
|
||||
const logger = di.inject(extensionDiscoveryLoggerInjectable);
|
||||
|
||||
return async (filePath: string): Promise<void> => {
|
||||
// Check that the removed path is directly under this.dependencies.localExtensionsDirectoryPath
|
||||
// Note that the watcher can create unlink events for subdirectories of the extension
|
||||
const extensionFolderName = getBasenameOfPath(filePath);
|
||||
const expectedPath = getRelativePath(localExtensionsDirectoryPath, filePath);
|
||||
|
||||
if (expectedPath !== extensionFolderName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const extension = iter.find(
|
||||
installedExtensions.values(),
|
||||
(ext) => !ext.isBundled && ext.absolutePath === filePath,
|
||||
);
|
||||
|
||||
if (!extension) {
|
||||
logger.warn(`extension ${extensionFolderName} not found, can't remove`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If the extension is deleted manually while the application is running, also remove the symlink
|
||||
await removeExtensionSymlinkByName(extension.manifest.name);
|
||||
|
||||
installedExtensions.delete(extension.id);
|
||||
logger.info(`removed extension ${extension.manifest.name}`);
|
||||
removeExtensionInstance(extension.id);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default extensionFileRemovedInjectable;
|
||||
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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 joinPathsInjectable from "../../../../common/path/join-paths.injectable";
|
||||
import extensionsNodeModulesDirectoryPathInjectable from "../common/extension-node-modules-directory-path.injectable";
|
||||
|
||||
export type GetExtensionInstallPath = (name: string) => string;
|
||||
|
||||
const getExtensionInstallPathInjectable = getInjectable({
|
||||
id: "get-extension-install-path",
|
||||
instantiate: (di): GetExtensionInstallPath => {
|
||||
const joinPaths = di.inject(joinPathsInjectable);
|
||||
const extensionsNodeModulesDirectoryPath = di.inject(extensionsNodeModulesDirectoryPathInjectable);
|
||||
|
||||
return (name) => joinPaths(extensionsNodeModulesDirectoryPath, name);
|
||||
},
|
||||
});
|
||||
|
||||
export default getExtensionInstallPathInjectable;
|
||||
@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 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 semver from "semver";
|
||||
import extensionApiVersionInjectable from "../../../../common/vars/extension-api-version.injectable";
|
||||
import type { LensExtensionManifest } from "../../../../extensions/lens-extension";
|
||||
|
||||
export type IsCompatibleExtension = (manifest: LensExtensionManifest) => boolean;
|
||||
|
||||
const isCompatibleExtensionInjectable = getInjectable({
|
||||
id: "is-compatible-extension",
|
||||
instantiate: (di): IsCompatibleExtension => {
|
||||
const extensionApiVersion = di.inject(extensionApiVersionInjectable);
|
||||
|
||||
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,
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default isCompatibleExtensionInjectable;
|
||||
@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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 type { Stats } from "fs-extra";
|
||||
import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
|
||||
import ensureDirectoryInjectable from "../../../../common/fs/ensure-directory.injectable";
|
||||
import lstatInjectable from "../../../../common/fs/lstat.injectable";
|
||||
import readDirectoryInjectable from "../../../../common/fs/read-directory.injectable";
|
||||
import removePathInjectable from "../../../../common/fs/remove.injectable";
|
||||
import joinPathsInjectable from "../../../../common/path/join-paths.injectable";
|
||||
import { isErrnoException } from "../../../../common/utils";
|
||||
import type { InstalledExtension } from "../../../../extensions/extension-discovery/extension-discovery";
|
||||
import installedExtensionsInjectable from "../../common/installed-extensions.injectable";
|
||||
import extensionsNodeModulesDirectoryPathInjectable from "../common/extension-node-modules-directory-path.injectable";
|
||||
import initialDiscoveryLoadCompletedInjectable from "../common/initial-load-completed.injectable";
|
||||
import localExtensionsDirectoryPathInjectable from "../common/local-extensions-directory-path.injectable";
|
||||
import extensionDiscoveryLoggerInjectable from "../common/logger.injectable";
|
||||
import loadUserExtensionFromFolderInjectable from "./load-user-extension-from-folder.injectable";
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
export type LoadInitialExtensions = () => Promise<void>;
|
||||
|
||||
const loadInitialExtensionsInjectable = getInjectable({
|
||||
id: "load-initial-extensions",
|
||||
instantiate: (di): LoadInitialExtensions => {
|
||||
const directoryForUserData = di.inject(directoryForUserDataInjectable);
|
||||
const logger = di.inject(extensionDiscoveryLoggerInjectable);
|
||||
const removePath = di.inject(removePathInjectable);
|
||||
const joinPaths = di.inject(joinPathsInjectable);
|
||||
const ensureDirectory = di.inject(ensureDirectoryInjectable);
|
||||
const readDirectory = di.inject(readDirectoryInjectable);
|
||||
const lstat = di.inject(lstatInjectable);
|
||||
const installedExtensions = di.inject(installedExtensionsInjectable);
|
||||
const initialDiscoveryLoadCompleted = di.inject(initialDiscoveryLoadCompletedInjectable);
|
||||
const extensionsNodeModulesDirectoryPath = di.inject(extensionsNodeModulesDirectoryPathInjectable);
|
||||
const localExtensionsDirectoryPath = di.inject(localExtensionsDirectoryPathInjectable);
|
||||
const loadUserExtensionFromFolder = di.inject(loadUserExtensionFromFolderInjectable);
|
||||
|
||||
return async () => {
|
||||
logger.info(`loading extensions from ${directoryForUserData}`);
|
||||
|
||||
await removePath(joinPaths(directoryForUserData, "package-lock.json"));
|
||||
await ensureDirectory(extensionsNodeModulesDirectoryPath);
|
||||
await ensureDirectory(localExtensionsDirectoryPath);
|
||||
|
||||
const userExtensions: InstalledExtension[] = [];
|
||||
const paths = await readDirectory(localExtensionsDirectoryPath);
|
||||
|
||||
for (const fileName of paths) {
|
||||
const absPath = joinPaths(localExtensionsDirectoryPath, fileName);
|
||||
|
||||
try {
|
||||
const stats = await lstat(absPath);
|
||||
|
||||
// skip non-directories
|
||||
if (!isDirectoryLike(stats)) {
|
||||
continue;
|
||||
}
|
||||
} catch (error) {
|
||||
if (isErrnoException(error) && error.code === "ENOENT") {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const extension = await loadUserExtensionFromFolder(absPath);
|
||||
|
||||
if (extension) {
|
||||
userExtensions.push(extension);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`${userExtensions.length} extensions loaded from "${localExtensionsDirectoryPath}"`, userExtensions.map(ext => `${ext.manifest.name}@${ext.manifest.version}`));
|
||||
|
||||
installedExtensions.replace(userExtensions.map(ext => [ext.id, ext]));
|
||||
initialDiscoveryLoadCompleted.set(true);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default loadInitialExtensionsInjectable;
|
||||
@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 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 pathExistsInjectable from "../../../../common/fs/path-exists.injectable";
|
||||
import readJsonFileInjectable from "../../../../common/fs/read-json-file.injectable";
|
||||
import getDirnameOfPathInjectable from "../../../../common/path/get-dirname.injectable";
|
||||
import joinPathsInjectable from "../../../../common/path/join-paths.injectable";
|
||||
import { isErrnoException } from "../../../../common/utils";
|
||||
import { manifestFilename } from "../../../../common/vars";
|
||||
import isProductionInjectable from "../../../../common/vars/is-production.injectable";
|
||||
import isCompatibleExtensionInjectable from "./is-compatible-extension.injectable";
|
||||
import extensionsStoreInjectable from "../../../../extensions/extensions-store/extensions-store.injectable";
|
||||
import type { LensExtensionManifest } from "../../../../extensions/lens-extension";
|
||||
import extensionDiscoveryLoggerInjectable from "../common/logger.injectable";
|
||||
import getExtensionInstallPathInjectable from "./get-extension-install-path.injectable";
|
||||
import type { InstalledExtension } from "../../common/installed-extension";
|
||||
|
||||
export type LoadUserExtensionFromFolder = (folderPath: string) => Promise<InstalledExtension | null>;
|
||||
|
||||
const loadUserExtensionFromFolderInjectable = getInjectable({
|
||||
id: "load-user-extension-from-folder",
|
||||
instantiate: (di): LoadUserExtensionFromFolder => {
|
||||
const joinPaths = di.inject(joinPathsInjectable);
|
||||
const readJsonFile = di.inject(readJsonFileInjectable);
|
||||
const extensionsStore = di.inject(extensionsStoreInjectable);
|
||||
const getDirnameOfPath = di.inject(getDirnameOfPathInjectable);
|
||||
const isProduction = di.inject(isProductionInjectable);
|
||||
const pathExists = di.inject(pathExistsInjectable);
|
||||
const isCompatibleExtension = di.inject(isCompatibleExtensionInjectable);
|
||||
const logger = di.inject(extensionDiscoveryLoggerInjectable);
|
||||
const getExtensionInstallPath = di.inject(getExtensionInstallPathInjectable);
|
||||
|
||||
return async (folderPath) => {
|
||||
const manifestPath = joinPaths(folderPath, manifestFilename);
|
||||
|
||||
try {
|
||||
const manifest = await readJsonFile(manifestPath) as unknown as LensExtensionManifest;
|
||||
const id = joinPaths(getExtensionInstallPath(manifest.name), manifestFilename);
|
||||
const extensionDir = getDirnameOfPath(manifestPath);
|
||||
const npmPackage = joinPaths(extensionDir, `${manifest.name}-${manifest.version}.tgz`);
|
||||
const absolutePath = isProduction && await pathExists(npmPackage)
|
||||
? npmPackage
|
||||
: extensionDir;
|
||||
|
||||
return {
|
||||
id,
|
||||
absolutePath,
|
||||
manifestPath: id,
|
||||
manifest,
|
||||
isBundled: false,
|
||||
isEnabled: extensionsStore.isEnabled(id),
|
||||
isCompatible: isCompatibleExtension(manifest),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isErrnoException(error) && error.code === "ENOTDIR") {
|
||||
// ignore this error, probably from .DS_Store file
|
||||
logger.debug(`failed to load extension manifest through a not-dir-like at ${manifestPath}`);
|
||||
} else {
|
||||
logger.error(`can't load extension manifest at ${manifestPath}: ${error}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default loadUserExtensionFromFolderInjectable;
|
||||
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 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 { onLoadOfApplicationInjectionToken } from "../../../../main/start-main-application/runnable-tokens/on-load-of-application-injection-token";
|
||||
import runLoadInitialExtensionsInjectable from "../../loader/main/run-load-initial-extensions.injectable";
|
||||
import watchForExtensionsInjectable from "./watch-extensions.injectable";
|
||||
|
||||
const runWatchForExtensionsInjectable = getInjectable({
|
||||
id: "run-watch-for-extensions",
|
||||
instantiate: (di) => ({
|
||||
id: "run-watch-for-extensions",
|
||||
run: async () => {
|
||||
const watchForExtensions = di.inject(watchForExtensionsInjectable);
|
||||
|
||||
watchForExtensions();
|
||||
},
|
||||
runAfter: di.inject(runLoadInitialExtensionsInjectable),
|
||||
}),
|
||||
injectionToken: onLoadOfApplicationInjectionToken,
|
||||
});
|
||||
|
||||
export default runWatchForExtensionsInjectable;
|
||||
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import { getGlobalOverride } from "../../../../common/test-utils/get-global-override";
|
||||
import watchExtensionsInjectable from "./watch-extensions.injectable";
|
||||
|
||||
export default getGlobalOverride(watchExtensionsInjectable, () => () => {});
|
||||
@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 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 watchInjectable from "../../../../common/fs/watch/watch.injectable";
|
||||
import localExtensionsDirectoryPathInjectable from "../common/local-extensions-directory-path.injectable";
|
||||
import extensionDiscoveryLoggerInjectable from "../common/logger.injectable";
|
||||
import extensionFileAddedInjectable from "./extension-file-add.injectable";
|
||||
import extensionFileRemovedInjectable from "./extension-file-removed.injectable";
|
||||
|
||||
export type WatchForExtensions = () => void;
|
||||
|
||||
const watchForExtensionsInjectable = getInjectable({
|
||||
id: "watch-for-extensions",
|
||||
instantiate: (di): WatchForExtensions => {
|
||||
const extensionFileAdded = di.inject(extensionFileAddedInjectable);
|
||||
const extensionFileRemoved = di.inject(extensionFileRemovedInjectable);
|
||||
const logger = di.inject(extensionDiscoveryLoggerInjectable);
|
||||
const watch = di.inject(watchInjectable);
|
||||
const localExtensionsDirectoryPath = di.inject(localExtensionsDirectoryPathInjectable);
|
||||
|
||||
return () => {
|
||||
logger.info(`watching extension add/remove in ${localExtensionsDirectoryPath}`);
|
||||
|
||||
watch(localExtensionsDirectoryPath, {
|
||||
// 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", extensionFileAdded)
|
||||
// Extension remove is detected by watching "<extensionDir>" unlink
|
||||
.on("unlinkDir", extensionFileRemoved)
|
||||
// Extension remove is detected by watching "<extensionSymLink>" unlink
|
||||
.on("unlink", extensionFileRemoved);
|
||||
};
|
||||
},
|
||||
causesSideEffects: true,
|
||||
});
|
||||
|
||||
export default watchForExtensionsInjectable;
|
||||
@ -23,13 +23,11 @@ const autoInitExtensionsInjectable = getInjectable({
|
||||
const finalizeExtensionLoading = di.inject(finalizeExtensionLoadingInjectable);
|
||||
|
||||
return async () => {
|
||||
logger.info("auto initializing extensions");
|
||||
logger.info("🧩 Initializing...");
|
||||
|
||||
const bundledExtensions = await loadBundledExtensions();
|
||||
const userExtensions = await loadUserExtensions(installedExtensions.toJSON());
|
||||
const loadedExtensions = await finalizeExtensionLoading([
|
||||
...bundledExtensions,
|
||||
...userExtensions,
|
||||
...await loadBundledExtensions(),
|
||||
...await loadUserExtensions(installedExtensions.toJSON()),
|
||||
]);
|
||||
|
||||
// Setup reaction to load extensions on JSON changes
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import { getGlobalOverrideForFunction } from "../../../../common/test-utils/get-global-override-for-function";
|
||||
import importInstalledExtensionInjectable from "./import-installed-extension.injectable";
|
||||
|
||||
export default getGlobalOverrideForFunction(importInstalledExtensionInjectable);
|
||||
@ -46,6 +46,7 @@ const importInstalledExtensionInjectable = getInjectable({
|
||||
return null;
|
||||
};
|
||||
},
|
||||
causesSideEffects: true,
|
||||
});
|
||||
|
||||
export default importInstalledExtensionInjectable;
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 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 { onLoadOfApplicationInjectionToken } from "../../../../main/library";
|
||||
import autoInitExtensionsInjectable from "../common/auto-init-extensions.injectable";
|
||||
|
||||
const runAutoInitExtensionsInjectable = getInjectable({
|
||||
id: "run-auto-init-extensions",
|
||||
instantiate: (di) => ({
|
||||
id: "run-auto-init-extensions",
|
||||
run: async () => {
|
||||
const autoInitExtensions = di.inject(autoInitExtensionsInjectable);
|
||||
|
||||
await autoInitExtensions();
|
||||
},
|
||||
}),
|
||||
injectionToken: onLoadOfApplicationInjectionToken,
|
||||
});
|
||||
|
||||
export default runAutoInitExtensionsInjectable;
|
||||
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 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 { onLoadOfApplicationInjectionToken } from "../../../../main/library";
|
||||
import loadInitialExtensionsInjectable from "../../discovery/main/load-initial-extensions.injectable";
|
||||
import runAutoInitExtensionsInjectable from "./run-auto-init-extensions.injectable";
|
||||
|
||||
const runLoadInitialExtensionsInjectable = getInjectable({
|
||||
id: "run-load-initial-extensions",
|
||||
instantiate: (di) => ({
|
||||
id: "run-load-initial-extensions",
|
||||
run: async () => {
|
||||
const loadInitialExtensions = di.inject(loadInitialExtensionsInjectable);
|
||||
|
||||
await loadInitialExtensions();
|
||||
},
|
||||
runAfter: di.inject(runAutoInitExtensionsInjectable),
|
||||
}),
|
||||
injectionToken: onLoadOfApplicationInjectionToken,
|
||||
});
|
||||
|
||||
export default runLoadInitialExtensionsInjectable;
|
||||
@ -24,7 +24,7 @@ import kubectlBinaryNameInjectable from "../kubectl/binary-name.injectable";
|
||||
import kubectlDownloadingNormalizedArchInjectable from "../kubectl/normalized-arch.injectable";
|
||||
import broadcastMessageInjectable from "../../common/ipc/broadcast-message.injectable";
|
||||
import writeJsonSyncInjectable from "../../common/fs/write-json-sync.injectable";
|
||||
import ensureDirInjectable from "../../common/fs/ensure-dir.injectable";
|
||||
import ensureDirectoryInjectable from "../../common/fs/ensure-directory.injectable";
|
||||
import type { GetBasenameOfPath } from "../../common/path/get-basename.injectable";
|
||||
import getBasenameOfPathInjectable from "../../common/path/get-basename.injectable";
|
||||
|
||||
@ -45,7 +45,7 @@ describe("kube auth proxy tests", () => {
|
||||
di.override(directoryForTempInjectable, () => "/some-directory-for-temp");
|
||||
|
||||
const writeJsonSync = di.inject(writeJsonSyncInjectable);
|
||||
const ensureDir = di.inject(ensureDirInjectable);
|
||||
const ensureDir = di.inject(ensureDirectoryInjectable);
|
||||
|
||||
getBasenameOfPath = di.inject(getBasenameOfPathInjectable);
|
||||
|
||||
|
||||
@ -7,7 +7,6 @@ import { chunk } from "lodash/fp";
|
||||
import type { DiContainer } from "@ogre-tools/injectable";
|
||||
import { isInjectable } from "@ogre-tools/injectable";
|
||||
import spawnInjectable from "./child-process/spawn.injectable";
|
||||
import initializeExtensionsInjectable from "./start-main-application/runnables/initialize-extensions.injectable";
|
||||
import setupIpcMainHandlersInjectable from "./electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.injectable";
|
||||
import setupLensProxyInjectable from "./start-main-application/runnables/setup-lens-proxy.injectable";
|
||||
import setupSyncingOfWeblinksInjectable from "./start-main-application/runnables/setup-syncing-of-weblinks.injectable";
|
||||
@ -82,7 +81,6 @@ export function getDiForUnitTesting(opts: { doGeneralOverrides?: boolean } = {})
|
||||
// TODO: Reorganize code in Runnables to get rid of requirement for override
|
||||
const overrideRunnablesHavingSideEffects = (di: DiContainer) => {
|
||||
[
|
||||
initializeExtensionsInjectable,
|
||||
initializeClusterManagerInjectable,
|
||||
setupIpcMainHandlersInjectable,
|
||||
setupLensProxyInjectable,
|
||||
|
||||
@ -1,56 +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 loggerInjectable from "../../../common/logger.injectable";
|
||||
import extensionDiscoveryInjectable from "../../../extensions/extension-discovery/extension-discovery.injectable";
|
||||
import autoInitExtensionsInjectable from "../../../features/extensions/loader/common/auto-init-extensions.injectable";
|
||||
import removeExtensionInstanceInjectable from "../../../features/extensions/loader/common/remove-instance.injectable";
|
||||
import showErrorPopupInjectable from "../../electron-app/features/show-error-popup.injectable";
|
||||
import { onLoadOfApplicationInjectionToken } from "../runnable-tokens/on-load-of-application-injection-token";
|
||||
|
||||
const initializeExtensionsInjectable = getInjectable({
|
||||
id: "initialize-extensions",
|
||||
|
||||
instantiate: (di) => {
|
||||
const logger = di.inject(loggerInjectable);
|
||||
const extensionDiscovery = di.inject(extensionDiscoveryInjectable);
|
||||
const showErrorPopup = di.inject(showErrorPopupInjectable);
|
||||
const removeExtensionInstance = di.inject(removeExtensionInstanceInjectable);
|
||||
const autoInitExtensions = di.inject(autoInitExtensionsInjectable);
|
||||
|
||||
return {
|
||||
id: "initialize-extensions",
|
||||
run: async () => {
|
||||
logger.info("🧩 Initializing extensions");
|
||||
|
||||
await extensionDiscovery.init();
|
||||
|
||||
await autoInitExtensions();
|
||||
|
||||
try {
|
||||
await extensionDiscovery.load();
|
||||
extensionDiscovery.events.on("remove", (ext) => removeExtensionInstance(ext.id));
|
||||
|
||||
// Start watching after bundled extensions are loaded
|
||||
extensionDiscovery.watchExtensions();
|
||||
} catch (error: any) {
|
||||
showErrorPopup(
|
||||
"Lens Error",
|
||||
`Could not load extensions${error?.message ? `: ${error.message}` : ""}`,
|
||||
);
|
||||
|
||||
console.error(error);
|
||||
console.trace();
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
causesSideEffects: true,
|
||||
|
||||
injectionToken: onLoadOfApplicationInjectionToken,
|
||||
});
|
||||
|
||||
export default initializeExtensionsInjectable;
|
||||
@ -5,7 +5,7 @@
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import { afterApplicationIsLoadedInjectionToken } from "../../runnable-tokens/after-application-is-loaded-injection-token";
|
||||
import directoryForKubeConfigsInjectable from "../../../../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable";
|
||||
import ensureDirInjectable from "../../../../common/fs/ensure-dir.injectable";
|
||||
import ensureDirectoryInjectable from "../../../../common/fs/ensure-directory.injectable";
|
||||
import kubeconfigSyncManagerInjectable from "../../../catalog-sources/kubeconfig-sync/manager.injectable";
|
||||
import addKubeconfigSyncAsEntitySourceInjectable from "./add-source.injectable";
|
||||
|
||||
@ -15,7 +15,7 @@ const startKubeConfigSyncInjectable = getInjectable({
|
||||
instantiate: (di) => {
|
||||
const directoryForKubeConfigs = di.inject(directoryForKubeConfigsInjectable);
|
||||
const kubeConfigSyncManager = di.inject(kubeconfigSyncManagerInjectable);
|
||||
const ensureDir = di.inject(ensureDirInjectable);
|
||||
const ensureDir = di.inject(ensureDirectoryInjectable);
|
||||
|
||||
return {
|
||||
id: "start-kubeconfig-sync",
|
||||
|
||||
@ -6,13 +6,12 @@
|
||||
import "@testing-library/jest-dom/extend-expect";
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import type { ExtensionDiscovery, InstalledExtension } from "../../../../extensions/extension-discovery/extension-discovery";
|
||||
import type { InstalledExtension } from "../../../../extensions/extension-discovery/extension-discovery";
|
||||
import { ConfirmDialog } from "../../confirm-dialog";
|
||||
import { Extensions } from "../extensions";
|
||||
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
|
||||
import type { DiRender } from "../../test-utils/renderFor";
|
||||
import { renderFor } from "../../test-utils/renderFor";
|
||||
import extensionDiscoveryInjectable from "../../../../extensions/extension-discovery/extension-discovery.injectable";
|
||||
import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
|
||||
import directoryForDownloadsInjectable from "../../../../common/app-paths/directory-for-downloads/directory-for-downloads.injectable";
|
||||
import assert from "assert";
|
||||
@ -20,8 +19,8 @@ import type { InstallExtensionFromInput } from "../install-extension-from-input.
|
||||
import installExtensionFromInputInjectable from "../install-extension-from-input.injectable";
|
||||
import type { ExtensionInstallationStateStore } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store";
|
||||
import extensionInstallationStateStoreInjectable from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
|
||||
import type { ObservableMap } from "mobx";
|
||||
import { observable, when } from "mobx";
|
||||
import type { IObservableValue, ObservableMap } from "mobx";
|
||||
import { computed, observable, when } from "mobx";
|
||||
import type { RemovePath } from "../../../../common/fs/remove.injectable";
|
||||
import removePathInjectable from "../../../../common/fs/remove.injectable";
|
||||
import type { DownloadBinary } from "../../../../common/fetch/download-binary.injectable";
|
||||
@ -29,15 +28,19 @@ import downloadBinaryInjectable from "../../../../common/fetch/download-binary.i
|
||||
import currentlyInClusterFrameInjectable from "../../../routes/currently-in-cluster-frame.injectable";
|
||||
import type { LensExtensionId } from "../../../../extensions/lens-extension";
|
||||
import installedExtensionsInjectable from "../../../../features/extensions/common/installed-extensions.injectable";
|
||||
import initialDiscoveryLoadCompletedInjectable from "../../../../features/extensions/discovery/common/initial-load-completed.injectable";
|
||||
import type { RemoveExtensionFiles } from "../../../../features/extensions/discovery/common/uninstall-extension.injectable";
|
||||
import removeExtensionFilesInjectable from "../../../../features/extensions/discovery/common/uninstall-extension.injectable";
|
||||
|
||||
describe("Extensions", () => {
|
||||
let installedExtensions: ObservableMap<LensExtensionId, InstalledExtension>;
|
||||
let extensionDiscovery: ExtensionDiscovery;
|
||||
let installExtensionFromInput: jest.MockedFunction<InstallExtensionFromInput>;
|
||||
let extensionInstallationStateStore: ExtensionInstallationStateStore;
|
||||
let render: DiRender;
|
||||
let deleteFileMock: jest.MockedFunction<RemovePath>;
|
||||
let downloadBinary: jest.MockedFunction<DownloadBinary>;
|
||||
let isLoaded: IObservableValue<boolean>;
|
||||
let removeExtensionFilesMock: jest.MockedFunction<RemoveExtensionFiles>;
|
||||
|
||||
beforeEach(() => {
|
||||
const di = getDiForUnitTesting({ doGeneralOverrides: true });
|
||||
@ -48,6 +51,14 @@ describe("Extensions", () => {
|
||||
|
||||
render = renderFor(di);
|
||||
|
||||
isLoaded = observable.box(false);
|
||||
|
||||
di.override(initialDiscoveryLoadCompletedInjectable, () => ({
|
||||
id: "some",
|
||||
set: value => isLoaded.set(value),
|
||||
value: computed(() => isLoaded.get()),
|
||||
}));
|
||||
|
||||
installExtensionFromInput = jest.fn();
|
||||
di.override(installExtensionFromInputInjectable, () => installExtensionFromInput);
|
||||
|
||||
@ -58,7 +69,6 @@ describe("Extensions", () => {
|
||||
di.override(downloadBinaryInjectable, () => downloadBinary);
|
||||
|
||||
installedExtensions = di.inject(installedExtensionsInjectable);
|
||||
extensionDiscovery = di.inject(extensionDiscoveryInjectable);
|
||||
extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable);
|
||||
|
||||
installedExtensions.set("extensionId", {
|
||||
@ -75,11 +85,12 @@ describe("Extensions", () => {
|
||||
isCompatible: true,
|
||||
});
|
||||
|
||||
extensionDiscovery.uninstallExtension = jest.fn(() => Promise.resolve());
|
||||
removeExtensionFilesMock = jest.fn();
|
||||
di.override(removeExtensionFilesInjectable, () => removeExtensionFilesMock);
|
||||
});
|
||||
|
||||
it("disables uninstall and disable buttons while uninstalling", async () => {
|
||||
extensionDiscovery.isLoaded = true;
|
||||
isLoaded.set(true);
|
||||
|
||||
render((
|
||||
<>
|
||||
@ -103,7 +114,7 @@ describe("Extensions", () => {
|
||||
fireEvent.click(await screen.findByText("Yes"));
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(extensionDiscovery.uninstallExtension).toHaveBeenCalled();
|
||||
expect(removeExtensionFilesMock).toHaveBeenCalled();
|
||||
fireEvent.click(menuTrigger);
|
||||
expect(screen.getByText("Disable")).toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByText("Uninstall")).toHaveAttribute("aria-disabled", "true");
|
||||
@ -155,14 +166,14 @@ describe("Extensions", () => {
|
||||
});
|
||||
|
||||
it("displays spinner while extensions are loading", () => {
|
||||
extensionDiscovery.isLoaded = false;
|
||||
isLoaded.set(false);
|
||||
const { container } = render(<Extensions />);
|
||||
|
||||
expect(container.querySelector(".Spinner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not display the spinner while extensions are not loading", async () => {
|
||||
extensionDiscovery.isLoaded = true;
|
||||
isLoaded.set(true);
|
||||
const { container } = render(<Extensions />);
|
||||
|
||||
expect(container.querySelector(".Spinner")).not.toBeInTheDocument();
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import extensionDiscoveryInjectable from "../../../../extensions/extension-discovery/extension-discovery.injectable";
|
||||
import { validatePackage } from "./validate-package";
|
||||
import { getMessageFromError } from "../get-message-from-error/get-message-from-error";
|
||||
import React from "react";
|
||||
@ -14,6 +13,7 @@ import writeFileInjectable from "../../../../common/fs/write-file.injectable";
|
||||
import joinPathsInjectable from "../../../../common/path/join-paths.injectable";
|
||||
import tempDirectoryPathInjectable from "../../../../common/os/temp-directory-path.injectable";
|
||||
import showErrorNotificationInjectable from "../../notifications/show-error-notification.injectable";
|
||||
import extensionsNodeModulesDirectoryPathInjectable from "../../../../features/extensions/discovery/common/extension-node-modules-directory-path.injectable";
|
||||
|
||||
export interface InstallRequestValidated {
|
||||
fileName: string;
|
||||
@ -28,7 +28,7 @@ export type CreateTempFilesAndValidate = (request: InstallRequest) => Promise<In
|
||||
const createTempFilesAndValidateInjectable = getInjectable({
|
||||
id: "create-temp-files-and-validate",
|
||||
instantiate: (di): CreateTempFilesAndValidate => {
|
||||
const extensionDiscovery = di.inject(extensionDiscoveryInjectable);
|
||||
const extensionsNodeModulesDirectoryPath = di.inject(extensionsNodeModulesDirectoryPathInjectable);
|
||||
const logger = di.inject(loggerInjectable);
|
||||
const writeFile = di.inject(writeFileInjectable);
|
||||
const joinPaths = di.inject(joinPathsInjectable);
|
||||
@ -49,7 +49,7 @@ const createTempFilesAndValidateInjectable = getInjectable({
|
||||
await writeFile(tempFile, data);
|
||||
const manifest = await validatePackage(tempFile);
|
||||
const id = joinPaths(
|
||||
extensionDiscovery.nodeModulesPath,
|
||||
extensionsNodeModulesDirectoryPath,
|
||||
manifest.name,
|
||||
"package.json",
|
||||
);
|
||||
|
||||
@ -3,9 +3,9 @@
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import extensionDiscoveryInjectable from "../../../../extensions/extension-discovery/extension-discovery.injectable";
|
||||
import { sanitizeExtensionName } from "../../../../extensions/lens-extension";
|
||||
import path from "path";
|
||||
import localExtensionsDirectoryPathInjectable from "../../../../features/extensions/discovery/common/local-extensions-directory-path.injectable";
|
||||
|
||||
export type GetExtensionDestFolder = (name: string) => string;
|
||||
|
||||
@ -13,9 +13,9 @@ const getExtensionDestFolderInjectable = getInjectable({
|
||||
id: "get-extension-dest-folder",
|
||||
|
||||
instantiate: (di): GetExtensionDestFolder => {
|
||||
const extensionDiscovery = di.inject(extensionDiscoveryInjectable);
|
||||
const localExtensionsDirectoryPath = di.inject(localExtensionsDirectoryPathInjectable);
|
||||
|
||||
return (name) => path.join(extensionDiscovery.localFolderPath, sanitizeExtensionName(name));
|
||||
return (name) => path.join(localExtensionsDirectoryPath, sanitizeExtensionName(name));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -4,8 +4,8 @@
|
||||
*/
|
||||
import type { LensExtensionManifest } from "../../../../extensions/lens-extension";
|
||||
import { hasTypedProperty, isObject, isString, listTarEntries, readFileFromTar } from "../../../../common/utils";
|
||||
import { manifestFilename } from "../../../../extensions/extension-discovery/extension-discovery";
|
||||
import path from "path";
|
||||
import { manifestFilename } from "../../../../common/vars";
|
||||
|
||||
export async function validatePackage(filePath: string): Promise<LensExtensionManifest> {
|
||||
const tarFiles = await listTarEntries(filePath);
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
import styles from "./installed-extensions.module.scss";
|
||||
import React, { useMemo } from "react";
|
||||
import type {
|
||||
ExtensionDiscovery,
|
||||
InstalledExtension,
|
||||
} from "../../../extensions/extension-discovery/extension-discovery";
|
||||
import { Icon } from "../icon";
|
||||
@ -17,13 +16,14 @@ import { cssNames, toJS } from "../../utils";
|
||||
import { observer } from "mobx-react";
|
||||
import type { Row } from "react-table";
|
||||
import type { LensExtensionId } from "../../../extensions/lens-extension";
|
||||
import extensionDiscoveryInjectable
|
||||
from "../../../extensions/extension-discovery/extension-discovery.injectable";
|
||||
|
||||
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import extensionInstallationStateStoreInjectable
|
||||
from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
|
||||
import type { ExtensionInstallationStateStore } from "../../../extensions/extension-installation-state-store/extension-installation-state-store";
|
||||
import type { IComputedValue } from "mobx";
|
||||
import initialDiscoveryLoadCompletedInjectable from "../../../features/extensions/discovery/common/initial-load-completed.injectable";
|
||||
|
||||
export interface InstalledExtensionsProps {
|
||||
extensions: InstalledExtension[];
|
||||
@ -33,7 +33,7 @@ export interface InstalledExtensionsProps {
|
||||
}
|
||||
|
||||
interface Dependencies {
|
||||
extensionDiscovery: ExtensionDiscovery;
|
||||
initialDiscoveryLoadCompleted: IComputedValue<boolean>;
|
||||
extensionInstallationStateStore: ExtensionInstallationStateStore;
|
||||
}
|
||||
|
||||
@ -46,7 +46,7 @@ function getStatus(extension: InstalledExtension) {
|
||||
}
|
||||
|
||||
const NonInjectedInstalledExtensions = observer(({
|
||||
extensionDiscovery,
|
||||
initialDiscoveryLoadCompleted,
|
||||
extensionInstallationStateStore,
|
||||
extensions,
|
||||
uninstall,
|
||||
@ -148,7 +148,7 @@ const NonInjectedInstalledExtensions = observer(({
|
||||
}), [toJS(extensions), extensionInstallationStateStore.anyUninstalling],
|
||||
);
|
||||
|
||||
if (!extensionDiscovery.isLoaded) {
|
||||
if (!initialDiscoveryLoadCompleted.get()) {
|
||||
return <div><Spinner center /></div>;
|
||||
}
|
||||
|
||||
@ -183,8 +183,8 @@ const NonInjectedInstalledExtensions = observer(({
|
||||
|
||||
export const InstalledExtensions = withInjectables<Dependencies, InstalledExtensionsProps>(NonInjectedInstalledExtensions, {
|
||||
getProps: (di, props) => ({
|
||||
extensionDiscovery: di.inject(extensionDiscoveryInjectable),
|
||||
extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable),
|
||||
...props,
|
||||
extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable),
|
||||
initialDiscoveryLoadCompleted: di.inject(initialDiscoveryLoadCompletedInjectable).value,
|
||||
}),
|
||||
});
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import extensionInstallationStateStoreInjectable from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
|
||||
import extensionDiscoveryInjectable from "../../../extensions/extension-discovery/extension-discovery.injectable";
|
||||
import loggerInjectable from "../../../common/logger.injectable";
|
||||
import type { LensExtensionId } from "../../../extensions/lens-extension";
|
||||
import { extensionDisplayName } from "../../../extensions/lens-extension";
|
||||
@ -15,18 +14,19 @@ import showSuccessNotificationInjectable from "../notifications/show-success-not
|
||||
import showErrorNotificationInjectable from "../notifications/show-error-notification.injectable";
|
||||
import installedUserExtensionsInjectable from "../../../features/extensions/common/user-extensions.injectable";
|
||||
import getInstalledExtensionInjectable from "../../../features/extensions/common/get-installed-extension.injectable";
|
||||
import removeExtensionFilesInjectable from "../../../features/extensions/discovery/common/uninstall-extension.injectable";
|
||||
|
||||
const uninstallExtensionInjectable = getInjectable({
|
||||
id: "uninstall-extension",
|
||||
|
||||
instantiate: (di) => {
|
||||
const extensionDiscovery = di.inject(extensionDiscoveryInjectable);
|
||||
const extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable);
|
||||
const logger = di.inject(loggerInjectable);
|
||||
const showSuccessNotification = di.inject(showSuccessNotificationInjectable);
|
||||
const showErrorNotification = di.inject(showErrorNotificationInjectable);
|
||||
const installedUserExtensions = di.inject(installedUserExtensionsInjectable);
|
||||
const getInstalledExtension = di.inject(getInstalledExtensionInjectable);
|
||||
const removeExtensionFiles = di.inject(removeExtensionFilesInjectable);
|
||||
|
||||
return async (extensionId: LensExtensionId): Promise<boolean> => {
|
||||
const ext = getInstalledExtension(extensionId);
|
||||
@ -44,7 +44,7 @@ const uninstallExtensionInjectable = getInjectable({
|
||||
logger.debug(`[EXTENSIONS]: trying to uninstall ${extensionId}`);
|
||||
extensionInstallationStateStore.setUninstalling(extensionId);
|
||||
|
||||
await extensionDiscovery.uninstallExtension(extensionId);
|
||||
await removeExtensionFiles(extensionId);
|
||||
|
||||
// wait for the ExtensionLoader to actually uninstall the extension
|
||||
await when(() => !installedUserExtensions.get().has(extensionId));
|
||||
|
||||
@ -249,27 +249,36 @@ export const getApplicationBuilder = () => {
|
||||
close: () => {},
|
||||
loadFile: async () => {},
|
||||
loadUrl: async () => {
|
||||
console.log("+beforeWindowStarts");
|
||||
|
||||
for (const callback of beforeWindowStartCallbacks) {
|
||||
await callback(windowDi);
|
||||
}
|
||||
|
||||
console.log("-beforeWindowStarts");
|
||||
|
||||
const startFrame = windowDi.inject(startFrameInjectable);
|
||||
|
||||
console.log("start frame");
|
||||
await startFrame();
|
||||
console.log("+afterWindowStarts");
|
||||
|
||||
for (const callback of afterWindowStartCallbacks) {
|
||||
await callback(windowDi);
|
||||
}
|
||||
|
||||
console.log("-afterWindowStarts");
|
||||
const history = windowDi.inject(historyInjectable);
|
||||
|
||||
const render = renderFor(windowDi);
|
||||
|
||||
console.log("renderFor");
|
||||
|
||||
rendered = render(
|
||||
<Router history={history}>
|
||||
<environment.RootComponent />
|
||||
</Router>,
|
||||
);
|
||||
console.log("finished application-builder loadUrl");
|
||||
},
|
||||
|
||||
send: (arg) => {
|
||||
@ -294,13 +303,18 @@ export const getApplicationBuilder = () => {
|
||||
const startApplication = async ({ shouldStartHidden }: { shouldStartHidden: boolean }) => {
|
||||
mainDi.inject(lensProxyPortInjectable).set(42);
|
||||
|
||||
console.log("beforeApplicationStartCallbacks");
|
||||
|
||||
for (const callback of beforeApplicationStartCallbacks) {
|
||||
await callback(mainDi);
|
||||
}
|
||||
|
||||
mainDi.override(shouldStartHiddenInjectable, () => shouldStartHidden);
|
||||
console.log("startMainApplication");
|
||||
await startMainApplication();
|
||||
|
||||
console.log("afterApplicationStartCallbacks");
|
||||
|
||||
for (const callback of afterApplicationStartCallbacks) {
|
||||
await callback(mainDi);
|
||||
}
|
||||
|
||||
@ -1,22 +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 extensionDiscoveryInjectable from "../../extensions/extension-discovery/extension-discovery.injectable";
|
||||
import { beforeFrameStartsSecondInjectionToken } from "../before-frame-starts/tokens";
|
||||
|
||||
const initializeExtensionDiscoveryInjectable = getInjectable({
|
||||
id: "initialize-extension-discovery",
|
||||
instantiate: (di) => ({
|
||||
id: "initialize-extension-discovery",
|
||||
run: async () => {
|
||||
const extensionDiscovery = di.inject(extensionDiscoveryInjectable);
|
||||
|
||||
await extensionDiscovery.init();
|
||||
},
|
||||
}),
|
||||
injectionToken: beforeFrameStartsSecondInjectionToken,
|
||||
});
|
||||
|
||||
export default initializeExtensionDiscoveryInjectable;
|
||||
@ -6,7 +6,6 @@
|
||||
import { clusterActivateHandler, clusterDisconnectHandler, clusterSetFrameIdHandler, clusterStates } from "../../common/ipc/cluster";
|
||||
import type { ClusterId, ClusterState } from "../../common/cluster-types";
|
||||
import { windowActionHandleChannel, windowLocationChangedChannel, windowOpenAppMenuAsContextMenuChannel, type WindowAction } from "../../common/ipc/window";
|
||||
import { extensionDiscoveryStateChannel } from "../../common/ipc/extension-handling";
|
||||
import { toJS } from "../utils";
|
||||
import type { Location } from "history";
|
||||
import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api";
|
||||
@ -55,7 +54,3 @@ export function requestClusterDisconnection(clusterId: ClusterId, force?: boolea
|
||||
export function requestInitialClusterStates(): Promise<{ id: string; state: ClusterState }[]> {
|
||||
return requestMain(clusterStates);
|
||||
}
|
||||
|
||||
export function requestInitialExtensionDiscovery(): Promise<{ isLoaded: boolean }> {
|
||||
return requestMain(extensionDiscoveryStateChannel);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user