From 6f6bf30bf75e44e590f17a4e9e0f1ecadfe1032d Mon Sep 17 00:00:00 2001 From: Vitaliy Gulyy Date: Tue, 23 Feb 2021 17:57:25 +0200 Subject: [PATCH] Clone Git projects with SSH URI (#995) * Improve the UX when cloning Git projects with SSH uri Signed-off-by: Vitaliy Gulyy --- .../src/browser/che-github-main.ts | 12 +- plugins/ssh-plugin/src/commands.ts | 46 ++ .../ssh-plugin/src/node/ssh-key-manager.ts | 121 ----- plugins/ssh-plugin/src/ssh-plugin-backend.ts | 481 ++++++++++++------ plugins/welcome-plugin/package.json | 3 - plugins/workspace-plugin/package.json | 5 +- plugins/workspace-plugin/src/git.ts | 41 ++ plugins/workspace-plugin/src/ssh.ts | 31 ++ .../workspace-plugin/src/theia-commands.ts | 113 +++- .../src/workspace-projects-manager.ts | 140 ++--- 10 files changed, 587 insertions(+), 406 deletions(-) create mode 100644 plugins/ssh-plugin/src/commands.ts delete mode 100644 plugins/ssh-plugin/src/node/ssh-key-manager.ts create mode 100644 plugins/workspace-plugin/src/ssh.ts diff --git a/extensions/eclipse-che-theia-plugin-ext/src/browser/che-github-main.ts b/extensions/eclipse-che-theia-plugin-ext/src/browser/che-github-main.ts index 1d8e1693b3..7a70d9b6eb 100644 --- a/extensions/eclipse-che-theia-plugin-ext/src/browser/che-github-main.ts +++ b/extensions/eclipse-che-theia-plugin-ext/src/browser/che-github-main.ts @@ -25,7 +25,16 @@ export class CheGithubMainImpl implements CheGithubMain { } async $uploadPublicSshKey(publicKey: string): Promise { - await this.fetchToken(); + try { + await this.fetchToken(); + await this.uploadKey(publicKey); + } catch (error) { + console.error(error.message); + throw error; + } + } + + async uploadKey(publicKey: string): Promise { await this.axiosInstance.post( 'https://api.github.com/user/keys', { @@ -76,6 +85,7 @@ export class CheGithubMainImpl implements CheGithubMain { await this.oAuthUtils.authenticate(oAuthProvider, ['repo', 'user', 'write:public_key']); this.token = await this.oAuthUtils.getToken(oAuthProvider); }; + if (await this.oAuthUtils.isAuthenticated(oAuthProvider)) { try { // Validate the GitHub token. diff --git a/plugins/ssh-plugin/src/commands.ts b/plugins/ssh-plugin/src/commands.ts new file mode 100644 index 0000000000..0f549a8ff2 --- /dev/null +++ b/plugins/ssh-plugin/src/commands.ts @@ -0,0 +1,46 @@ +/********************************************************************** + * Copyright (c) 2019-2021 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ***********************************************************************/ + +import * as theia from '@theia/plugin'; + +export const SSH_GENERATE_FOR_HOST: theia.CommandDescription = { + id: 'ssh:generate_for_host', + label: 'SSH: Generate Key For Particular Host...', +}; + +export const SSH_GENERATE: theia.CommandDescription = { + id: 'ssh:generate', + label: 'SSH: Generate Key...', +}; + +export const SSH_CREATE: theia.CommandDescription = { + id: 'ssh:create', + label: 'SSH: Create Key...', +}; + +export const SSH_DELETE: theia.CommandDescription = { + id: 'ssh:delete', + label: 'SSH: Delete Key...', +}; + +export const SSH_VIEW: theia.CommandDescription = { + id: 'ssh:view', + label: 'SSH: View Public Key...', +}; + +export const SSH_UPLOAD: theia.CommandDescription = { + id: 'ssh:upload', + label: 'SSH: Upload Private Key...', +}; + +export const SSH_ADD_TO_GITHUB: theia.CommandDescription = { + id: 'ssh:add_key_to_github', + label: 'SSH: Add Existing Key To GitHub...', +}; diff --git a/plugins/ssh-plugin/src/node/ssh-key-manager.ts b/plugins/ssh-plugin/src/node/ssh-key-manager.ts deleted file mode 100644 index 3ab8ef6b60..0000000000 --- a/plugins/ssh-plugin/src/node/ssh-key-manager.ts +++ /dev/null @@ -1,121 +0,0 @@ -/********************************************************************** - * Copyright (c) 2019-2020 Red Hat, Inc. - * - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - ***********************************************************************/ - -import * as che from '@eclipse-che/plugin'; - -import { che as cheApi } from '@eclipse-che/api'; - -/** - * Simple SSH key pairs manager that performs basic operations like create, - * get, delete, etc. There is no restriction on the way the keys are obtained - - * remotely (via REST or JSON-RPC, ) or locally (e.g. dynamically generated - * and/or in-memory stored), so the implementation of the interface defines - * the mechanism that is used. - */ -export interface SshKeyManager { - /** - * Generate an SSH key pair for specified service and name - * - * @param {string} service the name of the service that is associated with - * the SSH key pair - * @param {string} name the identifier of the key pair - * - * @returns {Promise} - */ - generate(service: string, name: string): Promise; - - /** - * Create a specified SSH key pair - * - * @param {SshKeyPair} sshKeyPair the SSH key pair that is to be created - * - * @returns {Promise} - */ - create(sshKeyPair: cheApi.ssh.SshPair): Promise; - - /** - * Get all SSH key pairs associated with specified service - * - * @param {string} service the name of the service that is associated with - * the SSH key pair - * - * @returns {Promise} - */ - getAll(service: string): Promise; - - /** - * Get an SSH key pair associated with specified service and name - * - * @param {string} service the name of the service that is associated with - * the SSH key pair - * @param {string} name the identifier of the key pair - * - * @returns {Promise} - */ - get(service: string, name: string): Promise; - - /** - * Delete an SSH key pair with a specified service and name - * - * @param {string} service the name of the service that is associated with - * the SSH key pair - * @param {string} name the identifier of the key pair - * - * @returns {Promise} - */ - delete(service: string, name: string): Promise; -} - -export interface CheService { - name: string; - displayName: string; - description: string; -} - -/** - * A remote SSH key paris manager that uses {@link SshKeyServiceClient} for - * all SHH key related operations. - */ -export class RemoteSshKeyManager implements SshKeyManager { - /** - * @inheritDoc - */ - generate(service: string, name: string): Promise { - return che.ssh.generate(service, name); - } - - /** - * @inheritDoc - */ - create(sshKeyPair: cheApi.ssh.SshPair): Promise { - return che.ssh.create(sshKeyPair); - } - - /** - * @inheritDoc - */ - getAll(service: string): Promise { - return che.ssh.getAll(service); - } - - /** - * @inheritDoc - */ - get(service: string, name: string): Promise { - return che.ssh.get(service, name); - } - - /** - * @inheritDoc - */ - delete(service: string, name: string): Promise { - return che.ssh.deleteKey(service, name); - } -} diff --git a/plugins/ssh-plugin/src/ssh-plugin-backend.ts b/plugins/ssh-plugin/src/ssh-plugin-backend.ts index 1f934937be..d6f1767219 100644 --- a/plugins/ssh-plugin/src/ssh-plugin-backend.ts +++ b/plugins/ssh-plugin/src/ssh-plugin-backend.ts @@ -12,7 +12,15 @@ import * as che from '@eclipse-che/plugin'; import * as os from 'os'; import * as theia from '@theia/plugin'; -import { RemoteSshKeyManager, SshKeyManager } from './node/ssh-key-manager'; +import { + SSH_ADD_TO_GITHUB, + SSH_CREATE, + SSH_DELETE, + SSH_GENERATE, + SSH_GENERATE_FOR_HOST, + SSH_UPLOAD, + SSH_VIEW, +} from './commands'; import { access, appendFile, @@ -31,16 +39,38 @@ import { R_OK } from 'constants'; import { che as cheApi } from '@eclipse-che/api'; import { spawn } from 'child_process'; -export async function start(): Promise { - const sshKeyManager = new RemoteSshKeyManager(); - let keys: cheApi.ssh.SshPair[] = []; +export interface PluginModel { + configureSSH(gitHubActions: boolean): Promise; + addKeyToGitHub(): Promise; +} + +const MESSAGE_NEED_RESTART_WORKSPACE = + 'Che Git plugin can leverage the generated keys now. To make them available in all workspace containers please restart your workspace.'; + +const MESSAGE_ENTER_KEY_NAME_OR_LEAVE_EMPTY = + 'Please provide a hostname (e.g. github.com) or leave empty to setup default name'; + +const MESSAGE_CANNOT_GENETARE_SSH_KEY = 'Unable to generate SSH key.'; + +const MESSAGE_NO_SSH_KEYS = 'There are no SSH keys.'; + +const MESSAGE_GET_KEYS_FAILED = 'Failure to fetch SSH keys.'; + +const MESSAGE_PERMISSION_DENIED_PUBLICKEY = 'Failure to clone git project. Permission denied (publickey).'; + +const CONTINUE = 'Continue'; + +const PUBLIC_KEY_SCHEME = 'publickey'; + +export async function start(): Promise { + let keys: cheApi.ssh.SshPair[]; try { - keys = await getKeys(sshKeyManager); + keys = await che.ssh.getAll('vcs'); } catch (e) { - if (e.message !== 'No SSH key pair has been defined.') { - console.error(e.message); - } + console.error(e.message); + keys = []; } + const keyPath = (keyName: string | undefined) => (keyName ? '/etc/ssh/private/' + keyName : ''); const passphrase = (privateKey: string | undefined) => privateKey ? privateKey.substring(privateKey.indexOf('\npassphrase: ') + 13, privateKey.length - 1) : ''; @@ -76,14 +106,14 @@ export async function start(): Promise { if ((await che.oAuth.isRegistered('github')) && out.indexOf('git@github.com') > -1) { switch (command) { case 'clone': { - if (await askToGenerateIfEmptyAndUploadKeyToGithub(keys, true)) { + if (await addGitHubKey({ confirmMessage: MESSAGE_PERMISSION_DENIED_PUBLICKEY })) { await git.clone(url, path.substring(0, path.lastIndexOf('/'))); theia.window.showInformationMessage(`Project ${url} successfully cloned to ${path}`); } break; } case 'push': { - if (await askToGenerateIfEmptyAndUploadKeyToGithub(keys, false)) { + if (await addGitHubKey({ confirmMessage: MESSAGE_PERMISSION_DENIED_PUBLICKEY })) { theia.window.showInformationMessage( 'The public SSH key has been uploaded to Github, please try to push again.' ); @@ -104,95 +134,48 @@ export async function start(): Promise { const showWarningMessage = (showGenerate: boolean, gitProviderName?: string) => theia.window.showWarningMessage(`Permission denied, please ${ - showGenerate ? 'generate (F1 => ' + GENERATE.label + ') and ' : '' + showGenerate ? 'generate (F1 => ' + SSH_GENERATE.label + ') and ' : '' } upload your public SSH key to ${ gitProviderName ? gitProviderName : 'the Git provider' - } and try again. To get the public key press F1 => ${VIEW.label}`); + } and try again. To get the public key press F1 => ${SSH_VIEW.label}`); theia.plugins.onDidChange(onChange); - const askToGenerateIfEmptyAndUploadKeyToGithub = async ( - keysParam: cheApi.ssh.SshPair[], - tryAgain: boolean - ): Promise => { - let key = keysParam.find( - k => !!k.publicKey && !!k.name && (k.name.startsWith('github.com') || k.name.startsWith('default-')) - ); - const message = `Permission denied, would you like to ${ - !key ? 'generate and ' : '' - }upload the public SSH key to GitHub${tryAgain ? ' and try again' : ''}?`; - const action = await theia.window.showWarningMessage(message, key ? 'Upload' : 'Generate and upload'); - if (action) { - if (!key) { - key = await sshKeyManager.generate('vcs', 'github.com'); - await updateConfig('github.com'); - await writeKey('github.com', key.privateKey!); - } - if (key && key.publicKey) { - await che.github.uploadPublicSshKey(key.publicKey); - return true; - } - return false; - } else { - return false; - } - }; - - const GENERATE_FOR_HOST: theia.CommandDescription = { - id: 'ssh:generate_for_host', - label: 'SSH: generate key pair for particular host...', - }; - const GENERATE: theia.CommandDescription = { - id: 'ssh:generate', - label: 'SSH: generate key pair...', - }; - const CREATE: theia.CommandDescription = { - id: 'ssh:create', - label: 'SSH: create key pair...', - }; - const DELETE: theia.CommandDescription = { - id: 'ssh:delete', - label: 'SSH: delete key pair...', - }; - const VIEW: theia.CommandDescription = { - id: 'ssh:view', - label: 'SSH: view public key...', - }; - const UPLOAD: theia.CommandDescription = { - id: 'ssh:upload', - label: 'SSH: upload private key...', - }; - - theia.commands.registerCommand(GENERATE_FOR_HOST, () => { - generateKeyPairForHost(sshKeyManager); + theia.commands.registerCommand(SSH_GENERATE_FOR_HOST, () => { + generateKeyPairForHost(); }); - theia.commands.registerCommand(GENERATE, () => { - generateKeyPair(sshKeyManager); + theia.commands.registerCommand(SSH_GENERATE, () => { + generateKeyPair(); }); - theia.commands.registerCommand(CREATE, () => { - createKeyPair(sshKeyManager); + theia.commands.registerCommand(SSH_CREATE, () => { + createKeyPair(); }); - theia.commands.registerCommand(DELETE, () => { - deleteKeyPair(sshKeyManager); + theia.commands.registerCommand(SSH_DELETE, () => { + deleteKeyPair(); }); - theia.commands.registerCommand(VIEW, () => { - viewPublicKey(sshKeyManager); + theia.commands.registerCommand(SSH_VIEW, () => { + viewPublicKey(); }); - theia.commands.registerCommand(UPLOAD, () => { - uploadPrivateKey(sshKeyManager); + theia.commands.registerCommand(SSH_UPLOAD, () => { + uploadPrivateKey(); + }); + theia.commands.registerCommand(SSH_ADD_TO_GITHUB, () => { + addGitHubKey(); }); -} -const RESTART_WARNING_MESSAGE = - 'Che Git plugin can leverage the generated keys now. To make them available in all workspace containers please restart your workspace.'; -const ENTER_KEY_NAME_OR_LEAVE_EMPTY_MESSAGE = - 'Please provide a hostname (e.g. github.com) or leave empty to setup default name'; + theia.workspace.registerTextDocumentContentProvider(PUBLIC_KEY_SCHEME, new PublicKeyContentProvider()); -const hostNamePattern = new RegExp('^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$'); + return { + configureSSH: async (gitHubActions: boolean) => showCommandPalette(gitHubActions), + addKeyToGitHub: async () => addGitHubKey({ gitCloneFlow: true }), + }; +} + +async function getHostName(message?: string): Promise { + const hostNamePattern = new RegExp('^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$'); -const getHostName = async (message?: string) => - await theia.window.showInputBox({ + return theia.window.showInputBox({ placeHolder: message ? message : 'Please provide a hostname e.g. github.com', validateInput: (text: string) => { if (!hostNamePattern.test(text)) { @@ -200,10 +183,13 @@ const getHostName = async (message?: string) => } }, }); +} -const getKeyFilePath = (name: string) => resolve(os.homedir(), '.ssh', name.replace(new RegExp('\\.'), '_')); +function getKeyFilePath(name: string): string { + return resolve(os.homedir(), '.ssh', name.replace(new RegExp('\\.'), '_')); +} -const updateConfig = async (hostName: string) => { +async function updateConfig(hostName: string): Promise { const configFile = resolve(os.homedir(), '.ssh', 'config'); await ensureFile(configFile); await chmod(configFile, '644'); @@ -217,41 +203,179 @@ const updateConfig = async (hostName: string) => { } else { await appendFile(configFile, keyConfig); } -}; +} + +async function showCommandPalette(gitHubActions: boolean): Promise { + const items: theia.QuickPickItem[] = [ + { label: SSH_GENERATE_FOR_HOST.label! }, + { label: SSH_GENERATE.label! }, + { label: SSH_VIEW.label! }, + { label: SSH_CREATE.label! }, + { label: SSH_DELETE.label! }, + { label: SSH_UPLOAD.label! }, + ]; + + if (gitHubActions) { + items.push({ label: SSH_ADD_TO_GITHUB.label!, showBorder: true }); + } + + const command = await theia.window.showQuickPick(items, {}); + + if (command) { + if (command.label === SSH_GENERATE_FOR_HOST.label) { + await generateKeyPairForHost(); + return true; + } else if (command.label === SSH_GENERATE.label) { + await generateKeyPair({ gitCloneFlow: true }); + return true; + } else if (command.label === SSH_VIEW.label) { + await viewPublicKey({ gitCloneFlow: true }); + return true; + } else if (command.label === SSH_CREATE.label) { + await createKeyPair(); + return true; + } else if (command.label === SSH_DELETE.label) { + await deleteKeyPair({ gitCloneFlow: true }); + return true; + } else if (command.label === SSH_UPLOAD.label) { + await uploadPrivateKey(); + return true; + } else if (command.label === SSH_ADD_TO_GITHUB.label) { + await addGitHubKey({ gitCloneFlow: true }); + return true; + } + } -const writeKey = async (name: string, key: string) => { + return false; +} + +/** + * Generates new key for GitHub. + */ +async function generateGitHubKey(): Promise { + const key = await che.ssh.generate('vcs', 'github.com'); + await updateConfig('github.com'); + await writeKey('github.com', key.privateKey!); + return key; +} + +/** + * Adds an existing public key to GitHub. + */ +async function addGitHubKey(config?: { gitCloneFlow?: boolean; confirmMessage?: string }): Promise { + const actions = config && config.gitCloneFlow ? [CONTINUE] : []; + + if (config && config.confirmMessage) { + const confirm = await theia.window.showWarningMessage(config.confirmMessage, 'Add Key To GitHub'); + if (confirm === undefined) { + return false; + } + } + + // get list of keys + let keys: cheApi.ssh.SshPair[]; + try { + keys = await che.ssh.getAll('vcs'); + } catch (e) { + await theia.window.showErrorMessage(MESSAGE_GET_KEYS_FAILED, ...actions); + return false; + } + + if (keys.length === 0) { + const GENERATE = 'Generate'; + const CANCEL = 'Cancel'; + const action = await theia.window.showWarningMessage( + `${MESSAGE_NO_SSH_KEYS} Do you want to generate new one?`, + GENERATE, + CANCEL + ); + if (action === GENERATE) { + try { + keys.push(await generateGitHubKey()); + } catch (e) { + await theia.window.showErrorMessage(MESSAGE_CANNOT_GENETARE_SSH_KEY); + return false; + } + } else { + return false; + } + } + + // filter keys, leave only with names and that have public keys + keys = keys.filter(key => key.name && key.publicKey); + + let key: cheApi.ssh.SshPair | undefined; + if (keys.length === 1) { + // only one key has been found + // use it + key = keys[0]; + } else { + // pick key from the list + const keyName = await theia.window.showQuickPick( + keys.map(k => ({ label: k.name! })), + {} + ); + + if (!keyName) { + // user closed the popup + return false; + } + + key = keys.find(k => k.name && k.name === keyName.label); + } + + try { + if (key && key.publicKey) { + await che.github.uploadPublicSshKey(key.publicKey); + return true; + } else { + await theia.window.showErrorMessage('Unable to find public key.', ...actions); + } + } catch (error) { + console.error(error.message); + await theia.window.showErrorMessage('Failure to add public key to GitHub.', ...actions); + } + + return false; +} + +async function writeKey(name: string, key: string): Promise { const keyFile = getKeyFilePath(name); await appendFile(keyFile, key); await chmod(keyFile, '600'); -}; +} -const showWarning = async (message: string) => { - await theia.window.showWarningMessage(message); -}; +async function generateKeyPair(config?: { gitCloneFlow?: boolean }): Promise { + const actions = config && config.gitCloneFlow ? [CONTINUE] : []; -const generateKeyPair = async (sshkeyManager: SshKeyManager) => { const keyName = `default-${Date.now()}`; - const key = await sshkeyManager.generate('vcs', keyName); - await updateConfig(keyName); - await writeKey(keyName, key.privateKey!); - const viewAction = 'View'; - const action = await theia.window.showInformationMessage( - 'Key pair successfully generated, do you want to view the public key?', - viewAction - ); - if (action === viewAction && key.privateKey) { - const document = await theia.workspace.openTextDocument({ content: key.publicKey })!; - await theia.window.showTextDocument(document!); + try { + const key = await che.ssh.generate('vcs', keyName); + await updateConfig(keyName); + await writeKey(keyName, key.privateKey!); + const VIEW = 'View'; + const viewActions: string[] = config && config.gitCloneFlow ? [VIEW, CONTINUE] : [VIEW]; + const action = await theia.window.showInformationMessage( + 'Key pair successfully generated, do you want to view the public key?', + ...viewActions + ); + if (action === VIEW && key.privateKey) { + const document = await theia.workspace.openTextDocument({ content: key.publicKey })!; + await theia.window.showTextDocument(document!); + } + + await theia.window.showWarningMessage(MESSAGE_NEED_RESTART_WORKSPACE, ...actions); + } catch (e) { + await theia.window.showErrorMessage('Failure to generate SSH key.', ...actions); } - showWarning(RESTART_WARNING_MESSAGE); -}; +} -const generateKeyPairForHost = async (sshkeyManager: SshKeyManager) => { +async function generateKeyPairForHost(): Promise { const hostName = await getHostName(); if (!hostName) { return; } - const key = await sshkeyManager.generate('vcs', hostName); + const key = await che.ssh.generate('vcs', hostName); await updateConfig(hostName); await writeKey(hostName, key.privateKey!); const viewAction = 'View'; @@ -263,11 +387,12 @@ const generateKeyPairForHost = async (sshkeyManager: SshKeyManager) => { const document = await theia.workspace.openTextDocument({ content: key.publicKey }); await theia.window.showTextDocument(document!); } - showWarning(RESTART_WARNING_MESSAGE); -}; -const createKeyPair = async (sshkeyManager: SshKeyManager) => { - let hostName = await getHostName(ENTER_KEY_NAME_OR_LEAVE_EMPTY_MESSAGE); + await theia.window.showWarningMessage(MESSAGE_NEED_RESTART_WORKSPACE); +} + +async function createKeyPair(): Promise { + let hostName = await getHostName(MESSAGE_ENTER_KEY_NAME_OR_LEAVE_EMPTY); if (!hostName) { hostName = `default-${Date.now()}`; } @@ -275,27 +400,32 @@ const createKeyPair = async (sshkeyManager: SshKeyManager) => { const privateKey = await theia.window.showInputBox({ placeHolder: 'Enter private key' }); try { - await sshkeyManager.create({ name: hostName, service: 'vcs', publicKey: publicKey, privateKey }); + await che.ssh.create({ name: hostName, service: 'vcs', publicKey: publicKey, privateKey }); await updateConfig(hostName); await writeKey(hostName, privateKey!); await theia.window.showInformationMessage(`Key pair for ${hostName} successfully created`); - showWarning(RESTART_WARNING_MESSAGE); + await theia.window.showWarningMessage(MESSAGE_NEED_RESTART_WORKSPACE); } catch (error) { - theia.window.showErrorMessage(error); + await theia.window.showErrorMessage(error); } -}; +} -const uploadPrivateKey = async (sshkeyManager: SshKeyManager) => { - let hostName = await getHostName(ENTER_KEY_NAME_OR_LEAVE_EMPTY_MESSAGE); +async function uploadPrivateKey(): Promise { + let hostName = await getHostName(MESSAGE_ENTER_KEY_NAME_OR_LEAVE_EMPTY); if (!hostName) { hostName = `default-${Date.now()}`; } const tempDir = await mkdtemp(join(os.tmpdir(), 'private-key-')); - const uploadedFilePaths = await theia.window.showUploadDialog({ defaultUri: theia.Uri.file(tempDir) }); + let uploadedFilePaths: theia.Uri[] | undefined; + try { + uploadedFilePaths = await theia.window.showUploadDialog({ defaultUri: theia.Uri.file(tempDir) }); + } catch (error) { + console.error(error.message); + } - if (!uploadedFilePaths || uploadPrivateKey.length === 0) { - theia.window.showErrorMessage('No private key has been uploaded'); + if (!uploadedFilePaths) { + await theia.window.showErrorMessage('No private key has been uploaded'); return; } @@ -306,7 +436,7 @@ const uploadPrivateKey = async (sshkeyManager: SshKeyManager) => { const privateKeyContent = (await readFile(privateKeyPath.path)).toString(); try { - await sshkeyManager.create({ name: hostName, service: 'vcs', privateKey: privateKeyContent }); + await che.ssh.create({ name: hostName, service: 'vcs', privateKey: privateKeyContent }); await updateConfig(hostName); await writeKey(hostName, privateKeyContent); const keyPath = getKeyFilePath(hostName); @@ -316,53 +446,58 @@ const uploadPrivateKey = async (sshkeyManager: SshKeyManager) => { if (passphrase) { await registerKey(keyPath, passphrase); } else { - theia.window.showErrorMessage('Passphrase for key was not entered'); + await theia.window.showErrorMessage('Passphrase for key was not entered'); } } - theia.window.showInformationMessage(`Key pair for ${hostName} successfully uploaded`); - showWarning(RESTART_WARNING_MESSAGE); + + await theia.window.showInformationMessage(`Key pair for ${hostName} successfully uploaded`); + await theia.window.showWarningMessage(MESSAGE_NEED_RESTART_WORKSPACE); } catch (error) { theia.window.showErrorMessage(error); } await unlink(privateKeyPath.path); await remove(tempDir); -}; +} -const getKeys = async (sshKeyManager: SshKeyManager): Promise => { - const keys: cheApi.ssh.SshPair[] = await sshKeyManager.getAll('vcs'); - if (!keys || keys.length < 1) { - throw new Error('No SSH key pair has been defined.'); - } - return keys; -}; +async function deleteKeyPair(config?: { gitCloneFlow?: boolean }): Promise { + const actions = config && config.gitCloneFlow ? [CONTINUE] : []; -const deleteKeyPair = async (sshkeyManager: SshKeyManager) => { let keys: cheApi.ssh.SshPair[]; try { - keys = await getKeys(sshkeyManager); - } catch (error) { - showWarning('Delete SSH key operation is interrupted: ' + error.message); + keys = await che.ssh.getAll('vcs'); + } catch (e) { + await theia.window.showErrorMessage(MESSAGE_GET_KEYS_FAILED, ...actions); return; } - const keyResp = await theia.window.showQuickPick( - keys.map(key => ({ label: key.name ? key.name : '' })), + + if (keys.length === 0) { + await theia.window.showWarningMessage(MESSAGE_NO_SSH_KEYS, ...actions); + return; + } + + const key = await theia.window.showQuickPick( + keys.map(k => ({ label: k.name ? k.name : '' })), {} ); - const keyName = keyResp ? keyResp.label : ''; + + if (!key) { + return; + } try { - await sshkeyManager.delete('vcs', keyName); - const keyFile = getKeyFilePath(keyName); + await che.ssh.deleteKey('vcs', key.label); + const keyFile = getKeyFilePath(key.label); if (await pathExists(keyFile)) { await unlink(keyFile); - await updateConfig(keyName); + await updateConfig(key.label); } - theia.window.showInformationMessage(`Key ${keyName} successfully deleted`); + + theia.window.showInformationMessage(`Key ${key.label} successfully deleted`, ...actions); } catch (error) { - theia.window.showErrorMessage(error); + theia.window.showErrorMessage(error, ...actions); } -}; +} async function registerKey(keyPath: string, passphrase: string): Promise { try { @@ -419,26 +554,56 @@ function startSshAgent(): Promise { }); } -const viewPublicKey = async (sshkeyManager: SshKeyManager) => { +async function viewPublicKey(config?: { gitCloneFlow?: boolean }): Promise { + const actions = config && config.gitCloneFlow ? [CONTINUE] : []; + let keys: cheApi.ssh.SshPair[]; try { - keys = await getKeys(sshkeyManager); - } catch (error) { - showWarning('View public SSH key operation is interrupted: ' + error.message); + keys = await che.ssh.getAll('vcs'); + } catch (e) { + await theia.window.showErrorMessage(MESSAGE_GET_KEYS_FAILED, ...actions); return; } - const keyResp = await theia.window.showQuickPick( - keys.map(key => ({ label: key.name ? key.name : '' })), + + if (keys.length === 0) { + await theia.window.showWarningMessage(MESSAGE_NO_SSH_KEYS, ...actions); + return; + } + + const key = await theia.window.showQuickPick( + keys.map(k => ({ label: k.name ? k.name : '' })), {} ); - const keyName = keyResp ? keyResp.label : ''; + + if (!key) { + return; + } + try { - const key = await sshkeyManager.get('vcs', keyName); - const document = await theia.workspace.openTextDocument({ content: key.publicKey }); - theia.window.showTextDocument(document!); + const uri = theia.Uri.parse(`${PUBLIC_KEY_SCHEME}:ssh@${key.label}`); + const document = await theia.workspace.openTextDocument(uri); + if (document) { + await theia.window.showTextDocument(document, { preview: true }); + return; + } } catch (error) { - theia.window.showErrorMessage(error); + await theia.window.showErrorMessage(`Unable to open SSH key ${key.label}`, ...actions); + console.error(error.message); + } + + await theia.window.showErrorMessage(`Failure to open ${key.label}`, ...actions); +} + +class PublicKeyContentProvider implements theia.TextDocumentContentProvider { + async provideTextDocumentContent(uri: theia.Uri, token: theia.CancellationToken): Promise { + let keyName = uri.path; + if (keyName.startsWith('ssh@')) { + keyName = keyName.substring('ssh@'.length); + } + + const key = await che.ssh.get('vcs', keyName); + return key.publicKey; } -}; +} export function stop(): void {} diff --git a/plugins/welcome-plugin/package.json b/plugins/welcome-plugin/package.json index bd2c8636ef..a1646c059b 100644 --- a/plugins/welcome-plugin/package.json +++ b/plugins/welcome-plugin/package.json @@ -35,9 +35,6 @@ "@theia/plugin-packager": "latest", "@eclipse-che/plugin": "0.0.1" }, - "extensionDependencies": [ - "Eclipse Che.@eclipse-che/workspace-plugin" - ], "scripts": { "prepare": "yarn run clean && yarn run build", "clean": "rimraf lib", diff --git a/plugins/workspace-plugin/package.json b/plugins/workspace-plugin/package.json index b82f49ad70..d9a1020f93 100644 --- a/plugins/workspace-plugin/package.json +++ b/plugins/workspace-plugin/package.json @@ -18,8 +18,11 @@ "@theia/plugin": "next", "@theia/plugin-packager": "latest" }, + "extensionDependencies": [ + "Eclipse Che.@eclipse-che/theia-ssh-plugin" + ], "scripts": { - "prepare": "yarn clean && yarn build && yarn lint:fix && yarn test", + "prepare": "yarn clean && yarn lint:fix && yarn build && yarn test", "clean": "rimraf lib", "format": "if-env SKIP_FORMAT=true && echo 'skip format check' || prettier --check '{src,tests}/**/*.ts' package.json", "format:fix": "prettier --write '{src,tests}/**/*.ts' package.json", diff --git a/plugins/workspace-plugin/src/git.ts b/plugins/workspace-plugin/src/git.ts index a9d1843348..cc33c9e7f2 100644 --- a/plugins/workspace-plugin/src/git.ts +++ b/plugins/workspace-plugin/src/git.ts @@ -100,3 +100,44 @@ export function getGitRootFolder(uri: string): string { export async function execGit(directory: string, ...args: string[]): Promise { return execute('git', args, { cwd: directory }); } + +export function isSecureGitURI(uri: string): boolean { + return uri.startsWith('git@'); +} + +export function isSecureGitHubURI(uri: string): boolean { + return uri.startsWith('git@github.com'); +} + +export function getHost(uri: string): string { + if (uri.startsWith('git@')) { + return uri.substring(0, uri.indexOf(':')); + } else { + return uri; + } +} + +export async function testSecureLogin(uri: string): Promise { + const host = getHost(uri); + const args: string[] = ['-T', host]; + + try { + const result = await execute('ssh', args); + return result; + } catch (error) { + const searchString = "You've successfully authenticated"; + if (error.message.indexOf(searchString) > 0) { + return error.message; + } else { + throw error; + } + } +} + +export function getErrorReason(message: string): string | undefined { + if (message.indexOf('Permission denied (publickey)') >= 0) { + return 'A valid SSH key may be required'; + } + + return undefined; +} diff --git a/plugins/workspace-plugin/src/ssh.ts b/plugins/workspace-plugin/src/ssh.ts new file mode 100644 index 0000000000..75a114fb43 --- /dev/null +++ b/plugins/workspace-plugin/src/ssh.ts @@ -0,0 +1,31 @@ +/********************************************************************** + * Copyright (c) 2019-2021 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ***********************************************************************/ + +import * as theia from '@theia/plugin'; + +const SSH_PLUGIN_ID = 'Eclipse Che.@eclipse-che/theia-ssh-plugin'; + +export async function addKeyToGitHub(): Promise { + const sshPlugin = theia.plugins.getPlugin(SSH_PLUGIN_ID); + if (sshPlugin && sshPlugin.exports) { + await sshPlugin.exports.addKeyToGitHub(); + } else { + await theia.window.showErrorMessage('Unable to find SSH Plugin'); + } +} + +export async function configure(gitHubActions: boolean): Promise { + const sshPlugin = theia.plugins.getPlugin(SSH_PLUGIN_ID); + if (sshPlugin && sshPlugin.exports) { + await sshPlugin.exports.configureSSH(gitHubActions); + } else { + await theia.window.showErrorMessage('Unable to find SSH Plugin'); + } +} diff --git a/plugins/workspace-plugin/src/theia-commands.ts b/plugins/workspace-plugin/src/theia-commands.ts index 36c010eb06..20d40daa7b 100644 --- a/plugins/workspace-plugin/src/theia-commands.ts +++ b/plugins/workspace-plugin/src/theia-commands.ts @@ -13,6 +13,7 @@ import * as fs from 'fs-extra'; import * as git from './git'; import * as os from 'os'; import * as path from 'path'; +import * as ssh from './ssh'; import * as theia from '@theia/plugin'; import { TaskScope } from '@eclipse-che/plugin'; @@ -43,7 +44,7 @@ function isDevfileProjectConfig( } export interface TheiaImportCommand { - execute(): PromiseLike; + execute(): Promise; } export function buildProjectImportCommand( @@ -68,6 +69,8 @@ export function buildProjectImportCommand( } } +let output: theia.OutputChannel; + export class TheiaGitCloneCommand implements TheiaImportCommand { private projectName: string | undefined; private locationURI: string; @@ -116,28 +119,106 @@ export class TheiaGitCloneCommand implements TheiaImportCommand { this.projectsRoot = projectsRoot; } - execute(): PromiseLike { - let cloneFunc: ( - progress: theia.Progress<{ message?: string; increment?: number }>, - token: theia.CancellationToken - ) => Promise; - if (this.sparseCheckoutDir) { - // Sparse checkout - cloneFunc = this.gitSparseCheckout; - } else { - // Regular clone - cloneFunc = this.gitClone; - } - + clone(): PromiseLike { return theia.window.withProgress( { location: theia.ProgressLocation.Notification, title: `Cloning ${this.locationURI} ...`, }, - (progress, token) => cloneFunc.call(this, progress, token) + (progress, token) => { + if (this.sparseCheckoutDir) { + return this.gitSparseCheckout(progress, token); + } else { + return this.gitClone(progress, token); + } + } ); } + async execute(): Promise { + if (!git.isSecureGitURI(this.locationURI)) { + // clone using regular URI + return this.clone(); + } + + // clone using SSH URI + let latestError: string | undefined; + while (true) { + // test secure login + try { + await git.testSecureLogin(this.locationURI); + // exit the loop when successfull login + break; + } catch (error) { + // let out: theia.OutputChannel = theia.window.createOutputChannel('GIT clone'); + if (!output) { + output = theia.window.createOutputChannel('GIT'); + } + + output.show(true); + output.appendLine(error.message); + + latestError = git.getErrorReason(error.message); + } + + // unable to login + // Give the user possible actions + // - retry the login + // - show SSH options + + const RETRY = 'Retry'; + const ADD_KEY_TO_GITHUB = 'Add Key To GitHub'; + const CONFIGURE_SSH = 'Configure SSH'; + + let message = `Failure to clone git project ${this.locationURI}`; + if (latestError) { + message += ` ${latestError}`; + } + + const isSecureGitHubURI = git.isSecureGitHubURI(this.locationURI); + const buttons = isSecureGitHubURI ? [RETRY, ADD_KEY_TO_GITHUB, CONFIGURE_SSH] : [RETRY, CONFIGURE_SSH]; + const action = await theia.window.showWarningMessage(message, ...buttons); + if (action === RETRY) { + // Retry Secure login + // Do nothing, just continue the loop + continue; + } else if (action === ADD_KEY_TO_GITHUB) { + await ssh.addKeyToGitHub(); + continue; + } else if (action === CONFIGURE_SSH) { + await ssh.configure(isSecureGitHubURI); + continue; + } else { + // It seems user closed the popup. + // Ask the user to retry cloning the project. + const SKIP = 'Skip'; + const TRY_AGAIN = 'Try Again'; + const tryAgain = await theia.window.showWarningMessage( + `Cloning of ${this.locationURI} will be skipped`, + SKIP, + TRY_AGAIN + ); + if (tryAgain === TRY_AGAIN) { + // continue the loop to try again + continue; + } + // skip + return; + } + + // pause will be removed after debugging this method + await this.pause(100); + } + + return this.clone(); + } + + private async pause(miliseconds: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, miliseconds); + }); + } + // Clones git repository private async gitClone( progress: theia.Progress<{ message?: string; increment?: number }>, @@ -235,7 +316,7 @@ export class TheiaImportZipCommand implements TheiaImportCommand { } } - execute(): PromiseLike { + async execute(): Promise { const importZip = async ( progress: theia.Progress<{ message?: string; increment?: number }>, token: theia.CancellationToken diff --git a/plugins/workspace-plugin/src/workspace-projects-manager.ts b/plugins/workspace-plugin/src/workspace-projects-manager.ts index 3b0156b288..1f8d3322d9 100644 --- a/plugins/workspace-plugin/src/workspace-projects-manager.ts +++ b/plugins/workspace-plugin/src/workspace-projects-manager.ts @@ -25,38 +25,46 @@ export const onDidCloneSources = onDidCloneSourcesEmitter.event; export function handleWorkspaceProjects(pluginContext: theia.PluginContext, projectsRoot: string): void { che.workspace.getCurrentWorkspace().then((workspace: cheApi.workspace.Workspace) => { - if (workspace.devfile) { - new DevfileProjectsManager(pluginContext, projectsRoot).run(); - } else { - new WorkspaceConfigProjectsManager(pluginContext, projectsRoot).run(); - } + new WorkspaceProjectsManager(pluginContext, projectsRoot).run(); }); } -abstract class WorkspaceProjectsManager { +export class WorkspaceProjectsManager { watchers: theia.FileSystemWatcher[] = []; constructor(protected pluginContext: theia.PluginContext, protected projectsRoot: string) {} - abstract async selectProjectToCloneCommands(workspace: cheApi.workspace.Workspace): Promise; - abstract async updateOrCreateProject(workspace: cheApi.workspace.Workspace, projectFolderURI: string): Promise; - abstract deleteProject(workspace: cheApi.workspace.Workspace, projectFolderURI: string): void; - - async run(workspace?: cheApi.workspace.Workspace): Promise { + async run(): Promise { if (!theia.workspace.name) { // no workspace opened, so nothing to clone / watch return; } - if (!workspace) { - workspace = await che.workspace.getCurrentWorkspace(); - } - const cloneCommandList = await this.selectProjectToCloneCommands(workspace); + const workspace = await che.workspace.getCurrentWorkspace(); + const cloneCommandList = await this.buildCloneCommands(workspace); await this.executeCloneCommands(cloneCommandList); await this.startSyncWorkspaceProjects(); } + async buildCloneCommands(workspace: cheApi.workspace.Workspace): Promise { + const instance = this; + + const projects = workspace.devfile!.projects; + if (!projects) { + return []; + } + + return projects + .filter(project => { + const projectPath = project.clonePath + ? path.join(instance.projectsRoot, project.clonePath) + : path.join(instance.projectsRoot, project.name!); + return !fs.existsSync(projectPath); + }) + .map(project => buildProjectImportCommand(project, instance.projectsRoot)!); + } + private async executeCloneCommands(cloneCommandList: TheiaImportCommand[]): Promise { if (cloneCommandList.length === 0) { return; @@ -99,44 +107,6 @@ abstract class WorkspaceProjectsManager { await che.workspace.update(currentWorkspace.id, currentWorkspace); } - async deleteProjectInWorkspace(projectFolderURI: string | undefined): Promise { - if (!projectFolderURI) { - return; - } - const currentWorkspace = await che.workspace.getCurrentWorkspace(); - if (!currentWorkspace.id) { - console.error('Unexpected error: current workspace id is not defined'); - return; - } - - this.deleteProject(currentWorkspace, projectFolderURI); - - await che.workspace.update(currentWorkspace.id, currentWorkspace); - } -} - -/** - * Make synchronization between projects defined in Che workspace devfile and theia projects. - */ -export class DevfileProjectsManager extends WorkspaceProjectsManager { - async selectProjectToCloneCommands(workspace: cheApi.workspace.Workspace): Promise { - const instance = this; - - const projects = workspace.devfile!.projects; - if (!projects) { - return []; - } - - return projects - .filter(project => { - const projectPath = project.clonePath - ? path.join(instance.projectsRoot, project.clonePath) - : path.join(instance.projectsRoot, project.name!); - return !fs.existsSync(projectPath); - }) - .map(project => buildProjectImportCommand(project, instance.projectsRoot)!); - } - async updateOrCreateProject(workspace: cheApi.workspace.Workspace, projectFolderURI: string): Promise { const projectUpstreamBranch: git.GitUpstreamBranch | undefined = await git.getUpstreamBranch(projectFolderURI); if (!projectUpstreamBranch || !projectUpstreamBranch.remoteURL) { @@ -152,67 +122,25 @@ export class DevfileProjectsManager extends WorkspaceProjectsManager { ); } - deleteProject(workspace: cheApi.workspace.Workspace, projectFolderURI: string): void { - projectsHelper.deleteProjectFromDevfile( - workspace.devfile!.projects!, - fileUri.convertToCheProjectPath(projectFolderURI, this.projectsRoot) - ); - } -} - -/** - * Make synchronization between projects defined in Che workspace config and theia projects. - */ -export class WorkspaceConfigProjectsManager extends WorkspaceProjectsManager { - async selectProjectToCloneCommands(workspace: cheApi.workspace.Workspace): Promise { - const instance = this; - - const projects = workspace.config!.projects; - if (!projects) { - return []; - } - - return projects - .filter(project => !fs.existsSync(instance.projectsRoot + project.path)) - .map(project => buildProjectImportCommand(project, instance.projectsRoot)!); - } - - async updateOrCreateProject(workspace: cheApi.workspace.Workspace, projectFolderURI: string): Promise { - const projectUpstreamBranch: git.GitUpstreamBranch | undefined = await git.getUpstreamBranch(projectFolderURI); - if (!projectUpstreamBranch || !projectUpstreamBranch.remoteURL) { - console.error(`Could not detect git project branch for ${projectFolderURI}`); + async deleteProjectInWorkspace(projectFolderURI: string | undefined): Promise { + if (!projectFolderURI) { return; } - - if (!workspace.config) { - workspace.config = {}; + const currentWorkspace = await che.workspace.getCurrentWorkspace(); + if (!currentWorkspace.id) { + console.error('Unexpected error: current workspace id is not defined'); + return; } - if (!workspace.config.projects) { - workspace.config.projects = []; - } + this.deleteProject(currentWorkspace, projectFolderURI); - projectsHelper.updateOrCreateGitProjectInWorkspaceConfig( - workspace.config.projects, - '/' + fileUri.convertToCheProjectPath(projectFolderURI, this.projectsRoot), - projectUpstreamBranch.remoteURL, - projectUpstreamBranch.branch - ); + await che.workspace.update(currentWorkspace.id, currentWorkspace); } deleteProject(workspace: cheApi.workspace.Workspace, projectFolderURI: string): void { - if (!workspace.config) { - workspace.config = {}; - } - - if (!workspace.config.projects) { - workspace.config.projects = []; - return; - } - - projectsHelper.deleteProjectFromWorkspaceConfig( - workspace.config.projects, - '/' + fileUri.convertToCheProjectPath(projectFolderURI, this.projectsRoot) + projectsHelper.deleteProjectFromDevfile( + workspace.devfile!.projects!, + fileUri.convertToCheProjectPath(projectFolderURI, this.projectsRoot) ); } }