diff --git a/.azure-pipelines.yml b/.azure-pipelines.yml index 637ab32175..86c83c39a1 100644 --- a/.azure-pipelines.yml +++ b/.azure-pipelines.yml @@ -6,18 +6,18 @@ pr: trigger: branches: include: - - '*' + - '*' tags: include: - - "*" + - "*" jobs: - job: Windows pool: vmImage: windows-2019 strategy: matrix: - node_14.x: - node_version: 14.x + node_12.x: + node_version: 12.x steps: - powershell: | $CI_BUILD_TAG = git describe --tags @@ -58,8 +58,8 @@ jobs: vmImage: macOS-10.14 strategy: matrix: - node_14.x: - node_version: 14.x + node_12.x: + node_version: 12.x steps: - script: CI_BUILD_TAG=`git describe --tags` && echo "##vso[task.setvariable variable=CI_BUILD_TAG]$CI_BUILD_TAG" displayName: Set the tag name as an environment variable @@ -104,8 +104,8 @@ jobs: vmImage: ubuntu-16.04 strategy: matrix: - node_14.x: - node_version: 14.x + node_12.x: + node_version: 12.x steps: - script: CI_BUILD_TAG=`git describe --tags` && echo "##vso[task.setvariable variable=CI_BUILD_TAG]$CI_BUILD_TAG" displayName: Set the tag name as an environment variable diff --git a/package.json b/package.json index c12ad4ba57..567ec7577f 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "bundledHelmVersion": "3.3.4" }, "engines": { - "node": ">=12 <=14" + "node": ">=12 <13" }, "lingui": { "locales": [ diff --git a/src/common/utils/downloadFile.ts b/src/common/utils/downloadFile.ts index 02b9bbd797..a58e9242b4 100644 --- a/src/common/utils/downloadFile.ts +++ b/src/common/utils/downloadFile.ts @@ -11,24 +11,23 @@ export interface DownloadFileTicket { cancel(): void; } -export function downloadFile(opts: DownloadFileOptions): DownloadFileTicket { - const { url, gzip = true } = opts; +export function downloadFile({ url, gzip = true }: DownloadFileOptions): DownloadFileTicket { const fileChunks: Buffer[] = []; const req = request(url, { gzip }); const promise: Promise = new Promise((resolve, reject) => { req.on("data", (chunk: Buffer) => { fileChunks.push(chunk); }); - req.on("complete", () => { - resolve(Buffer.concat(fileChunks)); - }); - req.on("error", err => { + req.once("error", err => { reject({ url, err }); }); + req.once("complete", () => { + resolve(Buffer.concat(fileChunks)); + }); }); return { - url: url, - promise: promise, + url, + promise, cancel() { req.abort(); } diff --git a/src/common/utils/tar.ts b/src/common/utils/tar.ts index 2fb7fc7de6..60cf71d533 100644 --- a/src/common/utils/tar.ts +++ b/src/common/utils/tar.ts @@ -25,10 +25,10 @@ export function readFileFromTar(tarFilePath: string, opts: ReadFileFromTarOpts): entry.on("data", chunk => { fileChunks.push(chunk); }); - entry.on("error", err => { + entry.once("error", err => { reject(`Reading ${entry.path} error: ${err}`); }); - entry.on("end", () => { + entry.once("end", () => { resolve(Buffer.concat(fileChunks)); }); }, diff --git a/src/common/vars.ts b/src/common/vars.ts index 1957a6dcff..b37f3c2135 100644 --- a/src/common/vars.ts +++ b/src/common/vars.ts @@ -41,3 +41,5 @@ export const apiKubePrefix = "/api-kube"; // k8s cluster apis // Links export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues"; export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI"; +export const docsUrl = "https://docs.k8slens.dev/"; +export const supportUrl = "https://docs.k8slens.dev/latest/support/"; diff --git a/src/extensions/extension-manager.ts b/src/extensions/extension-manager.ts index 45455a14e8..0e51eeb666 100644 --- a/src/extensions/extension-manager.ts +++ b/src/extensions/extension-manager.ts @@ -97,15 +97,6 @@ export class ExtensionManager { } } - getNpmPackageTarballUrl(packageName: string) { - try { - const command = [this.npmPath, "view", packageName, "dist.tarball", "--silent"]; - return child_process.execSync(command.join(" "), { encoding: "utf8" }).trim(); - } catch (err) { - return null; - } - } - protected installPackages(): Promise { return new Promise((resolve, reject) => { const child = child_process.fork(this.npmPath, ["install", "--silent", "--no-audit", "--only=prod", "--prefer-offline", "--no-package-lock"], { diff --git a/src/main/menu.ts b/src/main/menu.ts index e7add90e77..06bd9095cb 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -1,7 +1,7 @@ import { app, BrowserWindow, dialog, ipcMain, IpcMainEvent, Menu, MenuItem, MenuItemConstructorOptions, webContents, shell } from "electron"; import { autorun } from "mobx"; import { WindowManager } from "./window-manager"; -import { appName, isMac, isWindows, isTestEnv } from "../common/vars"; +import { appName, isMac, isWindows, isTestEnv, docsUrl, supportUrl } from "../common/vars"; import { addClusterURL } from "../renderer/components/+add-cluster/add-cluster.route"; import { preferencesURL } from "../renderer/components/+preferences/preferences.route"; import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route"; @@ -24,6 +24,7 @@ export function showAbout(browserWindow: BrowserWindow) { `${appName}: ${app.getVersion()}`, `Electron: ${process.versions.electron}`, `Chrome: ${process.versions.chrome}`, + `Node: ${process.versions.node}`, `Copyright 2020 Mirantis, Inc.`, ]; dialog.showMessageBoxSync(browserWindow, { @@ -215,13 +216,13 @@ export function buildMenu(windowManager: WindowManager) { { label: "Documentation", click: async () => { - shell.openExternal('https://docs.k8slens.dev/'); + shell.openExternal(docsUrl); }, }, { label: "Support", click: async () => { - shell.openExternal('https://docs.k8slens.dev/latest/support/'); + shell.openExternal(supportUrl); }, }, ...ignoreOnMac([ diff --git a/src/renderer/components/+extensions/extensions.scss b/src/renderer/components/+extensions/extensions.scss index 114b7220ab..8350b62b9c 100644 --- a/src/renderer/components/+extensions/extensions.scss +++ b/src/renderer/components/+extensions/extensions.scss @@ -22,16 +22,6 @@ > .flex.gaps { --flex-gap: #{$padding}; } - - .install-extension { - .Clipboard { - font-size: $font-size-small; - - &:hover { - color: $textColorSecondary; - } - } - } } .extensions-path { @@ -43,10 +33,24 @@ } } + .Clipboard { + display: inline; + vertical-align: baseline; + font-size: $font-size-small; + + &:hover { + color: $textColorSecondary; + } + } + .SearchInput { --spacing: #{$padding}; } + .SubTitle { + font-style: italic; + } + .WizardLayout { padding: 0; diff --git a/src/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index 72a48e7a91..87759e650e 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -12,6 +12,7 @@ import { Button } from "../button"; import { WizardLayout } from "../layout/wizard-layout"; import { DropFileInput, Input, InputValidators, SearchInput } from "../input"; import { Icon } from "../icon"; +import { SubTitle } from "../layout/sub-title"; import { PageLayout } from "../layout/page-layout"; import { Clipboard } from "../clipboard"; import logger from "../../../main/logger"; @@ -21,6 +22,7 @@ import { LensExtensionManifest, sanitizeExtensionName } from "../../../extension import { Notifications } from "../notifications"; import { downloadFile } from "../../../common/utils"; import { extractTar, readFileFromTar } from "../../../common/utils/tar"; +import { docsUrl } from "../../../common/vars"; interface InstallRequest { fileName: string; @@ -28,7 +30,11 @@ interface InstallRequest { data?: Buffer; } -interface InstallRequestValidated extends InstallRequest { +interface InstallRequestPreloaded extends InstallRequest { + data: Buffer; +} + +interface InstallRequestValidated extends InstallRequestPreloaded { manifest: LensExtensionManifest; tmpFile: string; // temp file for unpacking } @@ -54,6 +60,10 @@ export class Extensions extends React.Component { return extensionManager.localFolderPath; } + getExtensionPackageTemp(fileName = "") { + return path.join(os.tmpdir(), "lens-extensions", fileName); + } + getExtensionDestFolder(name: string) { return path.join(this.extensionsPath, sanitizeExtensionName(name)); } @@ -79,24 +89,16 @@ export class Extensions extends React.Component { } }; - installExtensions = () => { - if (this.downloadUrl) { - this.installFromNpmOrUrl(this.downloadUrl); - this.downloadUrl = ""; + addExtensions = () => { + const { downloadUrl } = this; + if (downloadUrl && InputValidators.isUrl.validate(downloadUrl)) { + this.installFromUrl(downloadUrl); } else { this.installFromSelectFileDialog(); } }; - installFromNpmOrUrl = async (url = this.downloadUrl) => { - if (!InputValidators.isUrl.validate(url)) { - const npmPackageName = url; - url = extensionManager.getNpmPackageTarballUrl(npmPackageName); - if (!url) { - Notifications.error(`Error: npm package "${npmPackageName}" not found!`); - return; - } - } + installFromUrl = async (url: string) => { try { const { promise: filePromise } = downloadFile({ url }); this.requestInstall([{ @@ -105,10 +107,7 @@ export class Extensions extends React.Component { }]); } catch (err) { Notifications.error( -
-

Installation from URL has failed: {String(err)}

-

URL: {url}

-
+

Installation via URL has failed: {String(err)}

); } }; @@ -123,89 +122,110 @@ export class Extensions extends React.Component { ); }; - async requestInstall(installRequests: InstallRequest[]) { - const pendingFiles: Promise[] = []; + async preloadExtensions(requests: InstallRequest[], { showError = true } = {}) { + const preloadedRequests = requests.filter(req => req.data); + await Promise.all( + requests + .filter(req => !req.data && req.filePath) + .map(req => { + return fse.readFile(req.filePath).then(data => { + req.data = data; + preloadedRequests.push(req); + }).catch(err => { + if (showError) { + Notifications.error(`Error while reading "${req.filePath}": ${String(err)}`); + } + }); + }) + ); + return preloadedRequests as InstallRequestPreloaded[]; + } - // read extensions with provided system path if any - installRequests.forEach(ext => { - if (ext.data) return; - const promise = fse.readFile(ext.filePath) - .then(data => ext.data = data) - .catch(err => { - Notifications.error(`Error while reading "${ext.filePath}": ${String(err)}`); - }); - pendingFiles.push(promise); + async validatePackage(filePath: string): Promise { + const packageJson: Buffer = await readFileFromTar(filePath, { + // tarball from npm contains single root folder "package/*" + fileMatcher: (path: string) => !!path.match(/(\w+\/)?package\.json$/), + notFoundMessage: "Invalid extension, package.json not found", }); - await Promise.all(pendingFiles); - installRequests = installRequests.filter(item => item.data); // remove items with reading errors + const manifest: LensExtensionManifest = JSON.parse(packageJson.toString("utf8")); + if (!manifest.lens && !manifest.renderer) { + throw `package.json must specify "main" and/or "renderer" fields`; + } + return manifest; + } - // prepare temp folder - const tempFolder = path.join(os.tmpdir(), "lens-extensions"); - await fse.ensureDir(tempFolder); + async createTempFilesAndValidate(requests: InstallRequestPreloaded[], { showErrors = true } = {}) { + const validatedRequests: InstallRequestValidated[] = []; - // copy files to temp, get extension info from package.json and do basic validation - const validatedInstalls: Promise[] = installRequests.map(async installReq => { - const { fileName, data } = installReq; - const tempFile = path.join(tempFolder, fileName); - await fse.writeFileSync(tempFile, data); // copy to temp - try { - const packageJson: Buffer = await readFileFromTar(tempFile, { - // tarball from npm contains single root folder "package/*" - fileMatcher: (path: string) => !!path.match(/(\w+\/)?package\.json$/), - notFoundMessage: "Extension's manifest file (package.json) not found", - }); - const manifest: LensExtensionManifest = JSON.parse(packageJson.toString("utf8")); - if (!manifest.lens && !manifest.renderer) { - throw `package.json must specify "main" and/or "renderer" fields`; + // copy files to temp + await fse.ensureDir(this.getExtensionPackageTemp()); + requests.forEach(req => { + const tempFile = this.getExtensionPackageTemp(req.fileName); + fse.writeFileSync(tempFile, req.data); + }); + + // validate packages + await Promise.all( + requests.map(async req => { + const tempFile = this.getExtensionPackageTemp(req.fileName); + try { + const manifest = await this.validatePackage(tempFile); + validatedRequests.push({ + ...req, + manifest: manifest, + tmpFile: tempFile, + }); + } catch (err) { + fse.unlink(tempFile).catch(() => null); // remove invalid temp package + if (showErrors) { + Notifications.error( +
+

Installing {req.fileName} has failed, skipping.

+

Reason: {String(err)}

+
+ ); + } } - return { - ...installReq, - manifest: manifest, - tmpFile: tempFile, - }; - } catch (err) { - fse.unlink(tempFile).catch(() => null); // remove invalid temp file - Notifications.error( -
-

Installing {fileName} has failed, skipping.

-

Reason: {String(err)}

+ }) + ); + return validatedRequests; + } + + async requestInstall(requests: InstallRequest[]) { + const preloadedRequests = await this.preloadExtensions(requests); + const validatedRequests = await this.createTempFilesAndValidate(preloadedRequests); + + validatedRequests.forEach(install => { + const { name, version, description } = install.manifest; + const extensionFolder = this.getExtensionDestFolder(name); + const folderExists = fse.existsSync(extensionFolder); + if (!folderExists) { + // auto-install extension if not yet exists + this.unpackExtension(install); + } else { + // otherwise confirmation required (re-install / update) + const removeNotification = Notifications.info( +
+
+

Install extension {name}@{version}?

+

Description: {description}

+
shell.openPath(extensionFolder)}> + Warning: {extensionFolder} will be removed before installation. +
+
+
); } }); - - // final step, provide UI with extension info for reviewing and confirming installation - const extensions = await Promise.all(validatedInstalls); - extensions.forEach(install => { - if (!install) { - return; // skip validating errors if any - } - const { fileName, manifest } = install; - const { name, version, description } = manifest; - const extensionFolder = this.getExtensionDestFolder(name); - const folderExists = fse.existsSync(extensionFolder); - const removeNotification = Notifications.info( -
-
-

Install extension {name}@{version}?

-

Description: {description}

- {folderExists && ( -
shell.openPath(extensionFolder)}> - Warning: {extensionFolder} will be removed before installation. -
- )} -
-
- ); - }); } async unpackExtension({ fileName, tmpFile, manifest: { name, version } }: InstallRequestValidated) { - logger.info(`Unpacking extension ${name} from ${fileName}`); + const extName = `${name}@${version}`; + logger.info(`Unpacking extension ${extName}`, { fileName, tmpFile }); const unpackingTempFolder = path.join(path.dirname(tmpFile), path.basename(tmpFile) + "-unpacked"); const extensionFolder = this.getExtensionDestFolder(name); try { @@ -218,18 +238,18 @@ export class Extensions extends React.Component { const unpackedFiles = await fse.readdir(unpackingTempFolder); let unpackedRootFolder = unpackingTempFolder; if (unpackedFiles.length === 1) { - // handle case when extension.tgz packed with top root folder, + // check if %extension.tgz was packed with single top folder, // e.g. "npm pack %ext_name" downloads file with "package" root folder within tarball unpackedRootFolder = path.join(unpackingTempFolder, unpackedFiles[0]); } await fse.ensureDir(extensionFolder); await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true }); Notifications.ok( -

Extension {name}/{version} successfully installed!

+

Extension {extName} successfully installed!

); } catch (err) { Notifications.error( -

Installing extension {name} has failed: {err}

+

Installing extension {extName} has failed: {err}

); } finally { // clean up @@ -247,34 +267,38 @@ export class Extensions extends React.Component { features of Lens are built as extensions and use the same Extension API.
-

Extensions loaded from:

+
shell.openPath(this.extensionsPath)}> {this.extensionsPath}
- - Install extensions from tarball ({this.supportedFormats.join(", ")}): - + this.downloadUrl = v} - onSubmit={this.installExtensions} + onSubmit={this.addExtensions} />
diff --git a/src/renderer/components/input/input.scss b/src/renderer/components/input/input.scss index d0b6b6bc4f..b4c3e21703 100644 --- a/src/renderer/components/input/input.scss +++ b/src/renderer/components/input/input.scss @@ -88,12 +88,11 @@ //- Themes &.theme { - &.invalid { - box-shadow: 0 0 0 2px $colorError; - border-radius: $radius; - } - &.round-black { + &.invalid label { + border-color: $colorSoftError !important; + } + label { background: $mainBackground; border: 1px solid $borderFaintColor; @@ -115,6 +114,6 @@ .Tooltip.InputTooltipError { --bgc: #{$colorError}; - --border: 1px solid $colorSoftError; + --border: none; --color: white; }