From 0c747fbd6aecba0fe8a10066d5392a44e8b7e5a7 Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Mon, 5 Nov 2018 17:45:51 +0100 Subject: [PATCH] GH-3397: Implemented the HTTP-based authentication for Git in Electron. If a Git operation (fetch, pull, merge, ...) requires authentication, the Theia backend will ask the frontend for the username and password. User credentials are neither stored nor reused. In electron, if a Git operation fails due to authentication, we report it back to the user. This PR does not change the behavior of the browser-based application. Closes: #3397 Signed-off-by: Akos Kitta --- .../browser/quick-open/quick-open-service.ts | 7 + packages/git/package.json | 12 +- .../git/src/browser/git-view-contribution.ts | 2 + .../src/browser/prompt/git-prompt-module.ts | 29 +++ packages/git/src/common/git-prompt.ts | 174 +++++++++++++++ .../prompt/electron-git-prompt-module.ts | 26 +++ .../prompt/git-quick-open-prompt.ts | 72 +++++++ .../electron-browser/prompt/p-queue.spec.ts | 39 ++++ .../electron-node/askpass/askpass-empty.sh | 4 + .../src/electron-node/askpass/askpass-main.ts | 76 +++++++ .../git/src/electron-node/askpass/askpass.sh | 4 + .../git/src/electron-node/askpass/askpass.ts | 202 ++++++++++++++++++ .../env/electron-git-env-module.ts | 26 +++ .../env/electron-git-env-provider.ts | 47 ++++ packages/git/src/node/dugite-git-prompt.ts | 39 ++++ packages/git/src/node/dugite-git.ts | 115 +++++----- packages/git/src/node/env/git-env-module.ts | 23 ++ packages/git/src/node/env/git-env-provider.ts | 56 +++++ packages/git/src/node/git-backend-module.ts | 17 ++ packages/git/src/node/test/binding-helper.ts | 5 +- .../src/browser/monaco-quick-open-service.ts | 6 +- yarn.lock | 17 +- 22 files changed, 936 insertions(+), 62 deletions(-) create mode 100644 packages/git/src/browser/prompt/git-prompt-module.ts create mode 100644 packages/git/src/common/git-prompt.ts create mode 100644 packages/git/src/electron-browser/prompt/electron-git-prompt-module.ts create mode 100644 packages/git/src/electron-browser/prompt/git-quick-open-prompt.ts create mode 100644 packages/git/src/electron-browser/prompt/p-queue.spec.ts create mode 100755 packages/git/src/electron-node/askpass/askpass-empty.sh create mode 100644 packages/git/src/electron-node/askpass/askpass-main.ts create mode 100755 packages/git/src/electron-node/askpass/askpass.sh create mode 100644 packages/git/src/electron-node/askpass/askpass.ts create mode 100644 packages/git/src/electron-node/env/electron-git-env-module.ts create mode 100644 packages/git/src/electron-node/env/electron-git-env-provider.ts create mode 100644 packages/git/src/node/dugite-git-prompt.ts create mode 100644 packages/git/src/node/env/git-env-module.ts create mode 100644 packages/git/src/node/env/git-env-provider.ts diff --git a/packages/core/src/browser/quick-open/quick-open-service.ts b/packages/core/src/browser/quick-open/quick-open-service.ts index b29b442850793..1e82dabdcde98 100644 --- a/packages/core/src/browser/quick-open/quick-open-service.ts +++ b/packages/core/src/browser/quick-open/quick-open-service.ts @@ -39,6 +39,12 @@ export namespace QuickOpenOptions { */ readonly showItemsWithoutHighlight: boolean; + /** + * `true` if the quick open widget provides a way for the user to securely enter a password. + * Otherwise, `false`. + */ + readonly password: boolean; + selectIndex(lookfor: string): number; onClose(canceled: boolean): void; @@ -57,6 +63,7 @@ export namespace QuickOpenOptions { skipPrefix: 0, showItemsWithoutHighlight: false, + password: false, onClose: () => { /* no-op*/ }, diff --git a/packages/git/package.json b/packages/git/package.json index f70f183d27a9d..a874ce21f6e1c 100644 --- a/packages/git/package.json +++ b/packages/git/package.json @@ -11,12 +11,14 @@ "@theia/workspace": "^0.3.17", "@types/diff": "^3.2.2", "@types/fs-extra": "^4.0.2", + "@types/p-queue": "^2.3.1", "diff": "^3.4.0", - "dugite-extra": "0.1.7", + "dugite-extra": "0.1.9", "find-git-repositories": "^0.1.0", "fs-extra": "^4.0.2", "moment": "^2.21.0", "octicons": "^7.1.0", + "p-queue": "^2.4.2", "ts-md5": "^1.2.2" }, "publishConfig": { @@ -26,6 +28,14 @@ { "frontend": "lib/browser/git-frontend-module", "backend": "lib/node/git-backend-module" + }, + { + "backend": "lib/node/env/git-env-module", + "backendElectron": "lib/electron-node/env/electron-git-env-module" + }, + { + "frontend": "lib/browser/prompt/git-prompt-module", + "frontendElectron": "lib/electron-browser/prompt/electron-git-prompt-module" } ], "keywords": [ diff --git a/packages/git/src/browser/git-view-contribution.ts b/packages/git/src/browser/git-view-contribution.ts index 0916f51b3214a..dbcfe5fb2cbb1 100644 --- a/packages/git/src/browser/git-view-contribution.ts +++ b/packages/git/src/browser/git-view-contribution.ts @@ -28,6 +28,7 @@ import { GitRepositoryTracker } from './git-repository-tracker'; import { GitQuickOpenService } from './git-quick-open-service'; import { GitSyncService } from './git-sync-service'; import { WorkspaceService } from '@theia/workspace/lib/browser'; +import { GitPrompt } from '../common/git-prompt'; export const GIT_WIDGET_FACTORY_ID = 'git'; @@ -104,6 +105,7 @@ export class GitViewContribution extends AbstractViewContribution @inject(GitRepositoryTracker) protected readonly repositoryTracker: GitRepositoryTracker; @inject(GitSyncService) protected readonly syncService: GitSyncService; @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; + @inject(GitPrompt) protected readonly prompt: GitPrompt; constructor() { super({ diff --git a/packages/git/src/browser/prompt/git-prompt-module.ts b/packages/git/src/browser/prompt/git-prompt-module.ts new file mode 100644 index 0000000000000..5d6916622498d --- /dev/null +++ b/packages/git/src/browser/prompt/git-prompt-module.ts @@ -0,0 +1,29 @@ +/******************************************************************************** + * Copyright (C) 2018 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { ContainerModule, interfaces } from 'inversify'; +import { WebSocketConnectionProvider } from '@theia/core/lib/browser/messaging/ws-connection-provider'; +import { GitPrompt, GitPromptServer, GitPromptServerProxy, GitPromptServerImpl } from '../../common/git-prompt'; + +export default new ContainerModule(bind => { + bind(GitPrompt).toSelf(); + bindPromptServer(bind); +}); + +export function bindPromptServer(bind: interfaces.Bind): void { + bind(GitPromptServer).to(GitPromptServerImpl).inSingletonScope(); + bind(GitPromptServerProxy).toDynamicValue(context => WebSocketConnectionProvider.createProxy(context.container, GitPrompt.WS_PATH)).inSingletonScope(); +} diff --git a/packages/git/src/common/git-prompt.ts b/packages/git/src/common/git-prompt.ts new file mode 100644 index 0000000000000..9f145a45d1572 --- /dev/null +++ b/packages/git/src/common/git-prompt.ts @@ -0,0 +1,174 @@ +/******************************************************************************** + * Copyright (C) 2018 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { inject, injectable, postConstruct } from 'inversify'; +import { JsonRpcProxy } from '@theia/core/lib/common/messaging/proxy-factory'; +import { JsonRpcServer } from '@theia/core/lib/common/messaging/proxy-factory'; +import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; + +export const GitPromptServer = Symbol('GitPromptServer'); +export interface GitPromptServer extends JsonRpcServer { +} + +export const GitPromptServerProxy = Symbol('GitPromptServerProxy'); +export interface GitPromptServerProxy extends JsonRpcProxy { +} + +@injectable() +export class GitPrompt implements GitPromptClient, Disposable { + + @inject(GitPromptServer) + protected readonly server: GitPromptServer; + + protected readonly toDispose = new DisposableCollection(); + + @postConstruct() + protected init(): void { + this.server.setClient(this); + } + + dispose(): void { + this.toDispose.dispose(); + } + + async ask(question: GitPrompt.Question): Promise { + return GitPrompt.Failure.create('Interactive Git prompt is not supported in the browser.'); + } + +} + +export namespace GitPrompt { + + /** + * Unique WS endpoint path for the Git prompt service. + */ + export const WS_PATH = 'services/git-prompt'; + + export interface Question { + readonly text: string; + readonly details?: string; + readonly password?: boolean; + } + + export interface Answer { + readonly type: Answer.Type; + } + + export interface Success { + readonly type: Answer.Type.SUCCESS; + readonly result: string | boolean; + } + + export namespace Success { + + export function is(answer: Answer): answer is Success { + return answer.type === Answer.Type.SUCCESS + && 'result' in answer + && ((typeof (answer as Success).result) === 'string' || (typeof (answer as Success).result) === 'boolean'); + } + + export function create(result: string | boolean): Success { + return { + type: Answer.Type.SUCCESS, + result + }; + } + + } + + export interface Cancel extends Answer { + readonly type: Answer.Type.CANCEL; + } + + export namespace Cancel { + + export function is(answer: Answer): answer is Cancel { + return answer.type === Answer.Type.CANCEL; + } + + export function create(): Cancel { + return { + type: Answer.Type.CANCEL + }; + } + + } + + export interface Failure extends Answer { + readonly type: Answer.Type.FAILURE; + readonly error: string | Error; + } + + export namespace Failure { + + export function is(answer: Answer): answer is Failure { + return answer.type === Answer.Type.FAILURE + && 'error' in answer + && ((typeof (answer as Failure).error) === 'string' || (answer as Failure).error instanceof Error); + } + + export function create(error: string | Error): Failure { + return { + type: Answer.Type.FAILURE, + error + }; + } + + } + + export namespace Answer { + + export enum Type { + + SUCCESS, + CANCEL, + FAILURE + + } + + } + +} + +export const GitPromptClient = Symbol('GitPromptClient'); +export interface GitPromptClient { + + ask(question: GitPrompt.Question): Promise; + + // TODO: implement `confirm` with boolean return type. + // TODO: implement `select` with possible answers. + +} + +/** + * Note: This implementation is not reconnecting. + * Git prompting is not supported in the browser. In electron, there's no need to reconnect. + */ +@injectable() +export class GitPromptServerImpl implements GitPromptServer { + + @inject(GitPromptServerProxy) + protected readonly proxy: GitPromptServerProxy; + + setClient(client: GitPromptClient): void { + this.proxy.setClient(client); + } + + dispose(): void { + this.proxy.dispose(); + } + +} diff --git a/packages/git/src/electron-browser/prompt/electron-git-prompt-module.ts b/packages/git/src/electron-browser/prompt/electron-git-prompt-module.ts new file mode 100644 index 0000000000000..7097423cf7483 --- /dev/null +++ b/packages/git/src/electron-browser/prompt/electron-git-prompt-module.ts @@ -0,0 +1,26 @@ +/******************************************************************************** + * Copyright (C) 2018 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { ContainerModule } from 'inversify'; +import { GitPrompt } from '../../common/git-prompt'; +import { bindPromptServer } from '../../browser/prompt/git-prompt-module'; +import { GitQuickOpenPrompt } from './git-quick-open-prompt'; + +export default new ContainerModule(bind => { + bind(GitQuickOpenPrompt).toSelf().inSingletonScope(); + bind(GitPrompt).toService(GitQuickOpenPrompt); + bindPromptServer(bind); +}); diff --git a/packages/git/src/electron-browser/prompt/git-quick-open-prompt.ts b/packages/git/src/electron-browser/prompt/git-quick-open-prompt.ts new file mode 100644 index 0000000000000..57f9c5c635f0f --- /dev/null +++ b/packages/git/src/electron-browser/prompt/git-quick-open-prompt.ts @@ -0,0 +1,72 @@ +/******************************************************************************** + * Copyright (C) 2018 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { inject, injectable } from 'inversify'; +import * as PQueue from 'p-queue'; +import { QuickOpenItem, QuickOpenMode } from '@theia/core/lib/browser/quick-open/quick-open-model'; +import { QuickOpenService } from '@theia/core/lib/browser/quick-open/quick-open-service'; +import { GitPrompt } from '../../common/git-prompt'; + +@injectable() +export class GitQuickOpenPrompt extends GitPrompt { + + @inject(QuickOpenService) + protected readonly quickOpenService: QuickOpenService; + + protected readonly queue = new PQueue({ autoStart: true, concurrency: 1 }); + + async ask(question: GitPrompt.Question): Promise { + return this.queue.add(() => { + const { details, text, password } = question; + return new Promise(resolve => { + const model = { + onType(lookFor: string, acceptor: (items: QuickOpenItem[]) => void): void { + acceptor([ + new QuickOpenItem({ + label: details, + run: (mode: QuickOpenMode): boolean => { + if (mode !== QuickOpenMode.OPEN) { + return false; + } + resolve(GitPrompt.Success.create(lookFor)); + return true; + } + }) + ]); + } + }; + const options = { + onClose: (canceled: boolean): void => { + if (canceled) { + resolve(GitPrompt.Cancel.create()); + } + }, + placeholder: text, + password + }; + this.quickOpenService.open(model, options); + }); + }); + } + + dispose(): void { + if (!this.queue.isPaused) { + this.queue.pause(); + } + this.queue.clear(); + } + +} diff --git a/packages/git/src/electron-browser/prompt/p-queue.spec.ts b/packages/git/src/electron-browser/prompt/p-queue.spec.ts new file mode 100644 index 0000000000000..f1c521cecd3d2 --- /dev/null +++ b/packages/git/src/electron-browser/prompt/p-queue.spec.ts @@ -0,0 +1,39 @@ +/******************************************************************************** + * Copyright (C) 2018 YourCompany and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import * as PQueue from 'p-queue'; +import { expect } from 'chai'; + +describe('p-queue', () => { + + it('tasks are executed sequentially when \'concurrency\' is \'1\'', async () => { + const actual = [] as number[]; + const expected = [200, 10, 100, 50]; + const queue = new PQueue({ concurrency: 1 }); + const newTask = (value: number) => () => new Promise(resolve => { + setTimeout(() => { + actual.push(value); + resolve(value); + }, value); + }); + for (const value of expected) { + queue.add(newTask(value)); + } + await queue.onIdle(); + expect(expected).to.be.deep.equal(actual); + }); + +}); diff --git a/packages/git/src/electron-node/askpass/askpass-empty.sh b/packages/git/src/electron-node/askpass/askpass-empty.sh new file mode 100755 index 0000000000000..893bc8c768696 --- /dev/null +++ b/packages/git/src/electron-node/askpass/askpass-empty.sh @@ -0,0 +1,4 @@ +#!/bin/sh +# Based on: https://github.com/Microsoft/vscode/blob/b1d403f8665603d1db44d3dc013f7ebd06bc526e/extensions/git/src/askpass-empty.sh + +echo '' diff --git a/packages/git/src/electron-node/askpass/askpass-main.ts b/packages/git/src/electron-node/askpass/askpass-main.ts new file mode 100644 index 0000000000000..40415c429e32b --- /dev/null +++ b/packages/git/src/electron-node/askpass/askpass-main.ts @@ -0,0 +1,76 @@ +// tslint:disable:file-header +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// Based on: https://github.com/Microsoft/vscode/blob/dd3e2d94f81139f9d18ba15a24c16c6061880b93/extensions/git/src/askpass-main.ts. + +import * as url from 'url'; +import * as http from 'http'; + +// tslint:disable-next-line:no-any +function fatal(err: any): void { + console.error('Missing or invalid credentials.'); + console.error(err); + process.exit(1); +} + +// 1. Node.js executable path. In this particular case it is Electron. +// 2. The location of the corresponding JS file of the current (`__filename`) file. +// 3. `Username`/`Password`. +// 4. `for`. +// 5. The host. For example: `https://github.com`. +const expectedArgvCount = 5; + +function main(argv: string[]): void { + + if (argv.length !== expectedArgvCount) { + return fatal(`Wrong number of arguments. Expected ${expectedArgvCount}. Got ${argv.length} instead.`); + } + + if (!process.env['THEIA_GIT_ASKPASS_HANDLE']) { + return fatal("Missing 'THEIA_GIT_ASKPASS_HANDLE' handle."); + } + + const handle = process.env['THEIA_GIT_ASKPASS_HANDLE'] as string; + const { host, hostname, port, protocol } = url.parse(handle); + const gitRequest = argv[2]; + const gitHost = argv[4].substring(1, argv[4].length - 2); + + const opts: http.RequestOptions = { + host, + hostname, + port, + protocol, + path: '/', + method: 'POST' + }; + + const req = http.request(opts, res => { + if (res.statusCode !== 200) { + return fatal(`Bad status code: ${res.statusCode}.`); + } + + const chunks: string[] = []; + res.setEncoding('utf8'); + res.on('data', (d: string) => chunks.push(d)); + res.on('end', () => { + const raw = chunks.join(''); + + try { + const result = JSON.parse(raw); + process.stdout.write(result); + } catch (err) { + return fatal('Error parsing the response.'); + } + + setTimeout(() => process.exit(0), 0); + }); + }); + + req.on('error', err => fatal(err)); + req.write(JSON.stringify({ gitRequest, gitHost })); + req.end(); +} + +main(process.argv); diff --git a/packages/git/src/electron-node/askpass/askpass.sh b/packages/git/src/electron-node/askpass/askpass.sh new file mode 100755 index 0000000000000..92b8fd3ece998 --- /dev/null +++ b/packages/git/src/electron-node/askpass/askpass.sh @@ -0,0 +1,4 @@ +#!/bin/sh +# Based on: https://github.com/Microsoft/vscode/blob/77f0e95307675c3936c05d641f72b8b32dc8e274/extensions/git/src/askpass.sh + +"$THEIA_GIT_ASKPASS_NODE" "$THEIA_GIT_ASKPASS_MAIN" $* diff --git a/packages/git/src/electron-node/askpass/askpass.ts b/packages/git/src/electron-node/askpass/askpass.ts new file mode 100644 index 0000000000000..7b7715ed896fc --- /dev/null +++ b/packages/git/src/electron-node/askpass/askpass.ts @@ -0,0 +1,202 @@ +// tslint:disable:file-header +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// Based on: https://github.com/Microsoft/vscode/blob/dd3e2d94f81139f9d18ba15a24c16c6061880b93/extensions/git/src/askpass.ts + +import { injectable, postConstruct, inject } from 'inversify'; +import * as path from 'path'; +import * as http from 'http'; +import { ILogger } from '@theia/core/lib/common/logger'; +import { Disposable } from '@theia/core/lib/common/disposable'; +import { MaybePromise } from '@theia/core/lib/common/types'; +import { Deferred } from '@theia/core/lib/common/promise-util'; +import { GitPrompt } from '../../common/git-prompt'; +import { DugiteGitPromptServer } from '../../node/dugite-git-prompt'; + +/** + * Environment for the Git askpass helper. + */ +export interface AskpassEnvironment { + + /** + * The path to the external script to run by Git when authentication is required. + */ + readonly GIT_ASKPASS: string; + + /** + * Starts the process as a normal Node.js process. User `"1"` if you want to enable it. + */ + readonly ELECTRON_RUN_AS_NODE?: string; + + /** + * The path to the Node.js executable that will run the external `ASKPASS` script. + */ + readonly THEIA_GIT_ASKPASS_NODE?: string; + + /** + * The JS file to run. + */ + readonly THEIA_GIT_ASKPASS_MAIN?: string; + + /** + * The Git askpass handle path. In our case, this is the address of the HTTP server listening on the `Username` and `Password` requests. + */ + readonly THEIA_GIT_ASKPASS_HANDLE?: string; + +} + +export interface Address { + readonly port: number; + readonly family: string; + readonly address: string; +} + +@injectable() +export class Askpass implements Disposable { + + @inject(ILogger) + protected readonly logger: ILogger; + + @inject(DugiteGitPromptServer) + protected readonly promptServer: DugiteGitPromptServer; + + protected server: http.Server; + protected serverAddress: Address | undefined; + protected ready = new Deferred(); + + @postConstruct() + protected init(): void { + this.server = http.createServer((req, res) => this.onRequest(req, res)); + this.setup().then(serverAddress => { + if (serverAddress) { + this.serverAddress = serverAddress; + const { address, port } = this.serverAddress; + this.logger.info(`Git askpass helper is listening on http://${address}:${port}.`); + this.ready.resolve(true); + } else { + this.logger.warn("Couldn't start the HTTP server for the Git askpass helper."); + this.ready.resolve(false); + } + }).catch(() => { + this.ready.resolve(false); + }); + } + + protected async setup(): Promise
{ + try { + return new Promise
(resolve => { + this.server.on('error', err => this.logger.error(err)); + this.server.listen(0, this.hostname(), () => { + resolve(this.server.address()); + }); + }); + } catch (err) { + this.logger.error('Could not launch Git askpass helper.', err); + return undefined; + } + } + + protected onRequest(req: http.ServerRequest, res: http.ServerResponse): void { + const chunks: string[] = []; + req.setEncoding('utf8'); + req.on('data', (d: string) => chunks.push(d)); + req.on('end', () => { + const { gitRequest, gitHost } = JSON.parse(chunks.join('')); + this.prompt(gitHost, gitRequest).then(result => { + res.writeHead(200); + res.end(JSON.stringify(result)); + }, err => { + this.logger.error(err); + res.writeHead(500); + res.end(); + }); + }); + } + + protected async prompt(requestingHost: string, request: string): Promise { + try { + const answer = await this.promptServer.ask({ + password: /password/i.test(request), + text: request, + details: `Git: ${requestingHost} (Press 'Enter' to confirm or 'Escape' to cancel.)` + }); + if (GitPrompt.Success.is(answer) && typeof answer.result === 'string') { + return answer.result; + } else if (GitPrompt.Cancel.is(answer)) { + return ''; + } else if (GitPrompt.Failure.is(answer)) { + const { error } = answer; + throw error; + } + throw new Error('Unexpected answer.'); // Do not ever log the `answer`, it might contain the password. + } catch (e) { + this.logger.error(`An unexpected error occurred when requesting ${request} by ${requestingHost}.`, e); + return ''; + } + } + + async getEnv(): Promise { + const ok = await this.ready.promise; + if (!ok) { + return { + GIT_ASKPASS: path.join(__dirname, '..', '..', '..', 'src', 'electron-node', 'askpass', 'askpass-empty.sh') + }; + } + + const [ + ELECTRON_RUN_AS_NODE, + GIT_ASKPASS, + THEIA_GIT_ASKPASS_NODE, + THEIA_GIT_ASKPASS_MAIN, + THEIA_GIT_ASKPASS_HANDLE + ] = await Promise.all([ + this.ELECTRON_RUN_AS_NODE(), + this.GIT_ASKPASS(), + this.THEIA_GIT_ASKPASS_NODE(), + this.THEIA_GIT_ASKPASS_MAIN(), + this.THEIA_GIT_ASKPASS_HANDLE() + ]); + + return { + ELECTRON_RUN_AS_NODE, + GIT_ASKPASS, + THEIA_GIT_ASKPASS_NODE, + THEIA_GIT_ASKPASS_MAIN, + THEIA_GIT_ASKPASS_HANDLE + }; + } + + dispose(): void { + this.server.close(); + } + + protected hostname(): string { + return 'localhost'; + } + + protected GIT_ASKPASS(): MaybePromise { + return path.join(__dirname, '..', '..', '..', 'src', 'electron-node', 'askpass', 'askpass.sh'); + } + + protected ELECTRON_RUN_AS_NODE(): MaybePromise { + return '1'; + } + + protected THEIA_GIT_ASKPASS_NODE(): MaybePromise { + return process.execPath; + } + + protected THEIA_GIT_ASKPASS_MAIN(): MaybePromise { + return path.join(__dirname, 'askpass-main.js'); + } + + protected THEIA_GIT_ASKPASS_HANDLE(): MaybePromise { + if (this.serverAddress) { + return `http://${this.hostname()}:${this.serverAddress.port}`; + } + return undefined; + } + +} diff --git a/packages/git/src/electron-node/env/electron-git-env-module.ts b/packages/git/src/electron-node/env/electron-git-env-module.ts new file mode 100644 index 0000000000000..df5173d8e4913 --- /dev/null +++ b/packages/git/src/electron-node/env/electron-git-env-module.ts @@ -0,0 +1,26 @@ +/******************************************************************************** + * Copyright (C) 2018 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { ContainerModule } from 'inversify'; +import { GitEnvProvider } from '../../node/env/git-env-provider'; +import { Askpass } from '../askpass/askpass'; +import { ElectronGitEnvProvider } from './electron-git-env-provider'; + +export default new ContainerModule(bind => { + bind(ElectronGitEnvProvider).toSelf().inSingletonScope(); + bind(Askpass).toSelf(); + bind(GitEnvProvider).toService(ElectronGitEnvProvider); +}); diff --git a/packages/git/src/electron-node/env/electron-git-env-provider.ts b/packages/git/src/electron-node/env/electron-git-env-provider.ts new file mode 100644 index 0000000000000..9cb07b3e44a04 --- /dev/null +++ b/packages/git/src/electron-node/env/electron-git-env-provider.ts @@ -0,0 +1,47 @@ +/******************************************************************************** + * Copyright (C) 2018 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { inject, injectable, postConstruct } from 'inversify'; +import { DefaultGitEnvProvider } from '../../node/env/git-env-provider'; +import { Askpass } from '../askpass/askpass'; + +/** + * Git environment provider for Electron. + * + * This Git environment provider is customized for the Electron-based application. It sets the `GIT_ASKPASS` environment variable, to run + * a custom script for the authentication. + */ +@injectable() +export class ElectronGitEnvProvider extends DefaultGitEnvProvider { + + @inject(Askpass) + protected readonly askpass: Askpass; + protected _env: Object | undefined; + + @postConstruct() + protected init(): void { + super.init(); + this.toDispose.push(this.askpass); + } + + async getEnv(): Promise { + if (!this._env) { + this._env = this.askpass.getEnv(); + } + return this._env; + } + +} diff --git a/packages/git/src/node/dugite-git-prompt.ts b/packages/git/src/node/dugite-git-prompt.ts new file mode 100644 index 0000000000000..73cfac066916b --- /dev/null +++ b/packages/git/src/node/dugite-git-prompt.ts @@ -0,0 +1,39 @@ +/******************************************************************************** + * Copyright (C) 2018 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { injectable } from 'inversify'; +import { GitPromptServer, GitPromptClient, GitPrompt } from '../common/git-prompt'; + +@injectable() +export class DugiteGitPromptServer implements GitPromptServer, GitPromptClient { + + protected client: GitPromptClient | undefined; + + dispose(): void { + } + + setClient(client: GitPromptClient | undefined): void { + this.client = client; + } + + async ask(question: GitPrompt.Question): Promise { + if (this.client) { + return this.client.ask(question); + } + return GitPrompt.Failure.create('Not yet available.'); + } + +} diff --git a/packages/git/src/node/dugite-git.ts b/packages/git/src/node/dugite-git.ts index f607c25dc31ff..067797ad21d9a 100644 --- a/packages/git/src/node/dugite-git.ts +++ b/packages/git/src/node/dugite-git.ts @@ -16,7 +16,7 @@ import * as fs from 'fs-extra'; import * as Path from 'path'; -import { injectable, inject } from 'inversify'; +import { injectable, inject, postConstruct } from 'inversify'; import { git } from 'dugite-extra/lib/core/git'; import { push } from 'dugite-extra/lib/command/push'; import { pull } from 'dugite-extra/lib/command/pull'; @@ -35,6 +35,7 @@ import { IStatusResult, IAheadBehind, AppFileStatus, WorkingDirectoryStatus as D import { Branch as DugiteBranch } from 'dugite-extra/lib/model/branch'; import { Commit as DugiteCommit, CommitIdentity as DugiteCommitIdentity } from 'dugite-extra/lib/model/commit'; import { ILogger } from '@theia/core'; +import { Deferred } from '@theia/core/lib/common/promise-util'; import * as strings from '@theia/core/lib/common/strings'; import { Git, GitUtils, Repository, WorkingDirectoryStatus, GitFileChange, GitFileStatus, Branch, Commit, @@ -43,6 +44,7 @@ import { import { GitRepositoryManager } from './git-repository-manager'; import { GitLocator } from './git-locator/git-locator-protocol'; import { GitExecProvider } from './git-exec-provider'; +import { GitEnvProvider } from './env/git-env-provider'; /** * Parsing and converting raw Git output into Git model instances. @@ -303,6 +305,16 @@ export class DugiteGit implements Git { @inject(GitExecProvider) protected readonly execProvider: GitExecProvider; + @inject(GitEnvProvider) + protected readonly envProvider: GitEnvProvider; + + protected gitEnv: Deferred = new Deferred(); + + @postConstruct() + protected init(): void { + this.envProvider.getEnv().then(env => this.gitEnv.resolve(env)); + } + dispose(): void { this.locator.dispose(); this.execProvider.dispose(); @@ -310,8 +322,8 @@ export class DugiteGit implements Git { async clone(remoteUrl: string, options: Git.Options.Clone): Promise { const { localUri, branch } = options; - const exec = await this.execProvider.exec(); - await clone(remoteUrl, this.getFsPath(localUri), { branch }, { exec }); + const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]); + await clone(remoteUrl, this.getFsPath(localUri), { branch }, { exec, env }); return { localUri }; } @@ -342,16 +354,16 @@ export class DugiteGit implements Git { async status(repository: Repository): Promise { const repositoryPath = this.getFsPath(repository); - const exec = await this.execProvider.exec(); - const dugiteStatus = await getStatus(repositoryPath, true, this.limit, { exec }); + const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]); + const dugiteStatus = await getStatus(repositoryPath, true, this.limit, { exec, env }); return this.mapStatus(dugiteStatus, repository); } async add(repository: Repository, uri: string | string[]): Promise { const paths = (Array.isArray(uri) ? uri : [uri]).map(FileUri.fsPath); - const exec = await this.execProvider.exec(); + const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]); return this.manager.run(repository, () => - stage(this.getFsPath(repository), paths, { exec }) + stage(this.getFsPath(repository), paths, { exec, env }) ); } @@ -359,9 +371,9 @@ export class DugiteGit implements Git { const paths = (Array.isArray(uri) ? uri : [uri]).map(FileUri.fsPath); const treeish = options && options.treeish ? options.treeish : undefined; const where = options && options.reset ? options.reset : undefined; - const exec = await this.execProvider.exec(); + const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]); return this.manager.run(repository, () => - unstage(this.getFsPath(repository), paths, treeish, where, { exec }) + unstage(this.getFsPath(repository), paths, treeish, where, { exec, env }) ); } @@ -370,40 +382,40 @@ export class DugiteGit implements Git { async branch(repository: Repository, options: Git.Options.BranchCommand.Create | Git.Options.BranchCommand.Rename | Git.Options.BranchCommand.Delete): Promise; // tslint:disable-next-line:no-any async branch(repository: any, options: any): Promise { - const exec = await this.execProvider.exec(); + const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]); const repositoryPath = this.getFsPath(repository); if (GitUtils.isBranchList(options)) { if (options.type === 'current') { - const currentBranch = await listBranch(repositoryPath, options.type, { exec }); + const currentBranch = await listBranch(repositoryPath, options.type, { exec, env }); return currentBranch ? this.mapBranch(currentBranch) : undefined; } - const branches = await listBranch(repositoryPath, options.type, { exec }); + const branches = await listBranch(repositoryPath, options.type, { exec, env }); return Promise.all(branches.map(branch => this.mapBranch(branch))); } return this.manager.run(repository, () => { if (GitUtils.isBranchCreate(options)) { - return createBranch(repositoryPath, options.toCreate, { startPoint: options.startPoint }, { exec }); + return createBranch(repositoryPath, options.toCreate, { startPoint: options.startPoint }, { exec, env }); } if (GitUtils.isBranchRename(options)) { - return renameBranch(repositoryPath, options.newName, options.newName, { force: !!options.force }, { exec }); + return renameBranch(repositoryPath, options.newName, options.newName, { force: !!options.force }, { exec, env }); } if (GitUtils.isBranchDelete(options)) { - return deleteBranch(repositoryPath, options.toDelete, { force: !!options.force, remote: !!options.remote }, { exec }); + return deleteBranch(repositoryPath, options.toDelete, { force: !!options.force, remote: !!options.remote }, { exec, env }); } return this.fail(repository, `Unexpected git branch options: ${options}.`); }); } async checkout(repository: Repository, options: Git.Options.Checkout.CheckoutBranch | Git.Options.Checkout.WorkingTreeFile): Promise { - const exec = await this.execProvider.exec(); + const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]); return this.manager.run(repository, () => { const repositoryPath = this.getFsPath(repository); if (GitUtils.isBranchCheckout(options)) { - return checkoutBranch(repositoryPath, options.branch, { exec }); + return checkoutBranch(repositoryPath, options.branch, { exec, env }); } if (GitUtils.isWorkingTreeFileCheckout(options)) { const paths = (Array.isArray(options.paths) ? options.paths : [options.paths]).map(FileUri.fsPath); - return checkoutPaths(repositoryPath, paths, { exec }); + return checkoutPaths(repositoryPath, paths, { exec, env }); } return this.fail(repository, `Unexpected git checkout options: ${options}.`); }); @@ -412,9 +424,9 @@ export class DugiteGit implements Git { async commit(repository: Repository, message?: string, options?: Git.Options.Commit): Promise { const signOff = options && options.signOff; const amend = options && options.amend; - const exec = await this.execProvider.exec(); + const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]); return this.manager.run(repository, () => - createCommit(this.getFsPath(repository), message || '', signOff, amend, { exec }) + createCommit(this.getFsPath(repository), message || '', signOff, amend, { exec, env }) ); } @@ -422,9 +434,9 @@ export class DugiteGit implements Git { const repositoryPath = this.getFsPath(repository); const r = await this.getDefaultRemote(repositoryPath, options ? options.remote : undefined); if (r) { - const exec = await this.execProvider.exec(); + const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]); return this.manager.run(repository, () => - fetch(repositoryPath, r!, exec) + fetch(repositoryPath, r!, { exec, env }) ); } this.fail(repository, 'No remote repository specified. Please, specify either a URL or a remote name from which new revisions should be fetched.'); @@ -452,9 +464,9 @@ export class DugiteGit implements Git { args.push(branchName + (remoteBranch ? `:${remoteBranch}` : '')); await this.exec(repository, args); } else { - const exec = await this.execProvider.exec(); + const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]); return this.manager.run(repository, () => - push(repositoryPath, currentRemote!, branchName, remoteBranch, exec) + push(repositoryPath, currentRemote!, branchName, remoteBranch, { exec, env }) ); } } @@ -478,25 +490,25 @@ export class DugiteGit implements Git { } await this.exec(repository, args); } else { - const exec = await this.execProvider.exec(); - return this.manager.run(repository, () => pull(repositoryPath, currentRemote!, branch, exec)); + const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]); + return this.manager.run(repository, () => pull(repositoryPath, currentRemote!, branch, { exec, env })); } } async reset(repository: Repository, options: Git.Options.Reset): Promise { const repositoryPath = this.getFsPath(repository); const mode = this.getResetMode(options.mode); - const exec = await this.execProvider.exec(); + const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]); return this.manager.run(repository, () => - reset(repositoryPath, mode, options.mode ? options.mode : 'HEAD', { exec }) + reset(repositoryPath, mode, options.mode ? options.mode : 'HEAD', { exec, env }) ); } async merge(repository: Repository, options: Git.Options.Merge): Promise { const repositoryPath = this.getFsPath(repository); - const exec = await this.execProvider.exec(); + const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]); return this.manager.run(repository, () => - merge(repositoryPath, options.branch, { exec }) + merge(repositoryPath, options.branch, { exec, env }) ); } @@ -505,11 +517,11 @@ export class DugiteGit implements Git { const commitish = this.getCommitish(options); const repositoryPath = this.getFsPath(repository); const path = this.getFsPath(uri); - const exec = await this.execProvider.exec(); + const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]); if (encoding === 'binary') { - return (await getBlobContents(repositoryPath, commitish, path, { exec })).toString(); + return (await getBlobContents(repositoryPath, commitish, path, { exec, env })).toString(); } - return (await getTextContents(repositoryPath, commitish, path, { exec })).toString(); + return (await getTextContents(repositoryPath, commitish, path, { exec, env })).toString(); } async remote(repository: Repository): Promise { @@ -521,7 +533,7 @@ export class DugiteGit implements Git { const repositoryPath = this.getFsPath(repository); return this.manager.run(repository, async () => { const name = options && options.name ? options.name : ''; - const exec = await this.execProvider.exec(); + const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]); let opts = {}; if (options) { opts = { @@ -530,7 +542,8 @@ export class DugiteGit implements Git { } opts = { ...opts, - exec + exec, + env }; return git(args, repositoryPath, name, opts); }); @@ -579,8 +592,8 @@ export class DugiteGit implements Git { const args = ['blame', '--root', '--incremental']; const file = Path.relative(this.getFsPath(repository), this.getFsPath(uri)); const repositoryPath = this.getFsPath(repository); - const exec = await this.execProvider.exec(); - const status = await getStatus(repositoryPath, true, this.limit, { exec }); + const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]); + const status = await getStatus(repositoryPath, true, this.limit, { exec, env }); const isUncommitted = (change: DugiteFileChange) => change.status === AppFileStatus.New && change.path === file; const changes = status.workingDirectory.files; if (changes.some(isUncommitted)) { @@ -633,8 +646,8 @@ export class DugiteGit implements Git { // Maybe, we should use `--show-cdup` here instead of `--show-toplevel` because `show-toplevel` dereferences symlinks. private async resolveContainingPath(repositoryPath: string): Promise { // Do not log an error if we are not contained in a Git repository. Treat exit code 128 as a success too. - const exec = await this.execProvider.exec(); - const options = { successExitCodes: new Set([0, 128]), exec }; + const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]); + const options = { successExitCodes: new Set([0, 128]), exec, env }; const result = await git(['rev-parse', '--show-toplevel'], repositoryPath, 'rev-parse', options); const out = result.stdout; if (out && out.length !== 0) { @@ -649,8 +662,8 @@ export class DugiteGit implements Git { } private async getRemotes(repositoryPath: string): Promise { - const exec = await this.execProvider.exec(); - const result = await git(['remote'], repositoryPath, 'remote', { exec }); + const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]); + const result = await git(['remote'], repositoryPath, 'remote', { exec, env }); const out = result.stdout || ''; return out.trim().match(/\S+/g) || []; } @@ -667,8 +680,8 @@ export class DugiteGit implements Git { if (localBranch !== undefined) { return localBranch; } - const exec = await this.execProvider.exec(); - const branch = await listBranch(repositoryPath, 'current', { exec }); + const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]); + const branch = await listBranch(repositoryPath, 'current', { exec, env }); if (branch === undefined) { return this.fail(repositoryPath, 'No current branch.'); } @@ -721,10 +734,7 @@ export class DugiteGit implements Git { private async mapStatus(toMap: IStatusResult, repository: Repository): Promise { const repositoryPath = this.getFsPath(repository); - const aheadBehindPromise = this.mapAheadBehind(toMap.branchAheadBehind); - const changesPromise = this.mapFileChanges(toMap.workingDirectory, repositoryPath); - const aheadBehind = await aheadBehindPromise; - const changes = await changesPromise; + const [aheadBehind, changes] = await Promise.all([this.mapAheadBehind(toMap.branchAheadBehind), this.mapFileChanges(toMap.workingDirectory, repositoryPath)]); return { exists: toMap.exists, branch: toMap.currentBranch, @@ -745,12 +755,11 @@ export class DugiteGit implements Git { } private async mapFileChange(toMap: DugiteFileChange, repositoryPath: string): Promise { - const uriPromise = this.getUri(Path.join(repositoryPath, toMap.path)); - const statusPromise = this.mapFileStatus(toMap.status); - const oldUriPromise = toMap.oldPath ? this.getUri(Path.join(repositoryPath, toMap.oldPath)) : undefined; - const uri = await uriPromise; - const status = await statusPromise; - const oldUri = await oldUriPromise; + const [uri, status, oldUri] = await Promise.all([ + this.getUri(Path.join(repositoryPath, toMap.path)), + this.mapFileStatus(toMap.status), + toMap.oldPath ? this.getUri(Path.join(repositoryPath, toMap.oldPath)) : undefined + ]); return { uri, status, diff --git a/packages/git/src/node/env/git-env-module.ts b/packages/git/src/node/env/git-env-module.ts new file mode 100644 index 0000000000000..bbe9138dfec2e --- /dev/null +++ b/packages/git/src/node/env/git-env-module.ts @@ -0,0 +1,23 @@ +/******************************************************************************** + * Copyright (C) 2018 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { ContainerModule } from 'inversify'; +import { GitEnvProvider, DefaultGitEnvProvider } from './git-env-provider'; + +export default new ContainerModule(bind => { + bind(DefaultGitEnvProvider).toSelf().inSingletonScope(); + bind(GitEnvProvider).toService(DefaultGitEnvProvider); +}); diff --git a/packages/git/src/node/env/git-env-provider.ts b/packages/git/src/node/env/git-env-provider.ts new file mode 100644 index 0000000000000..d28ce2b558160 --- /dev/null +++ b/packages/git/src/node/env/git-env-provider.ts @@ -0,0 +1,56 @@ +/******************************************************************************** + * Copyright (C) 2018 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { injectable, postConstruct } from 'inversify'; +import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; + +/** + * Provides an additional environment object when executing every single Git command. + */ +export const GitEnvProvider = Symbol('GitEnvProvider'); +export interface GitEnvProvider extends Disposable { + + /** + * The additional environment object that will be set before executing every single Git command. + */ + getEnv(): Promise; + +} + +/** + * The default Git environment provider. Does nothing. + */ +@injectable() +export class DefaultGitEnvProvider implements GitEnvProvider { + + protected toDispose = new DisposableCollection(); + + @postConstruct() + protected init(): void { + // NOOP + } + + async getEnv(): Promise { + return {}; + } + + dispose(): void { + if (!this.toDispose.disposed) { + this.toDispose.dispose(); + } + } + +} diff --git a/packages/git/src/node/git-backend-module.ts b/packages/git/src/node/git-backend-module.ts index e38669b314d8c..884472de4f178 100644 --- a/packages/git/src/node/git-backend-module.ts +++ b/packages/git/src/node/git-backend-module.ts @@ -27,6 +27,8 @@ import { GitLocator } from './git-locator/git-locator-protocol'; import { GitLocatorClient } from './git-locator/git-locator-client'; import { GitLocatorImpl } from './git-locator/git-locator-impl'; import { GitExecProvider } from './git-exec-provider'; +import { GitPromptServer, GitPromptClient, GitPrompt } from '../common/git-prompt'; +import { DugiteGitPromptServer } from './dugite-git-prompt'; export interface GitBindingOptions { readonly bindManager: (binding: interfaces.BindingToSyntax<{}>) => interfaces.BindingWhenOnSyntax<{}>; @@ -74,6 +76,11 @@ export function bindRepositoryWatcher(bind: interfaces.Bind): void { bind(GitWatcherServer).toService(DugiteGitWatcherServer); } +export function bindPrompt(bind: interfaces.Bind): void { + bind(DugiteGitPromptServer).toSelf().inSingletonScope(); + bind(GitPromptServer).toDynamicValue(context => context.container.get(DugiteGitPromptServer)); +} + export default new ContainerModule(bind => { bindGit(bind); bind(ConnectionHandler).toDynamicValue(context => @@ -93,4 +100,14 @@ export default new ContainerModule(bind => { return server; }) ).inSingletonScope(); + + bindPrompt(bind); + bind(ConnectionHandler).toDynamicValue(context => + new JsonRpcConnectionHandler(GitPrompt.WS_PATH, client => { + const server = context.container.get(GitPromptServer); + server.setClient(client); + client.onDidCloseConnection(() => server.dispose()); + return server; + }) + ).inSingletonScope(); }); diff --git a/packages/git/src/node/test/binding-helper.ts b/packages/git/src/node/test/binding-helper.ts index 7fcbf18aa0c54..944915ac08ce0 100644 --- a/packages/git/src/node/test/binding-helper.ts +++ b/packages/git/src/node/test/binding-helper.ts @@ -20,11 +20,14 @@ import { DugiteGit } from '../dugite-git'; import { bindGit, GitBindingOptions } from '../git-backend-module'; import { bindLogger } from '@theia/core/lib/node/logger-backend-module'; import { NoSyncRepositoryManager } from '.././test/no-sync-repository-manager'; +import { GitEnvProvider, DefaultGitEnvProvider } from '../env/git-env-provider'; // tslint:disable-next-line:no-any -export function initializeBindings(): { container: Container, bind: any } { +export function initializeBindings(): { container: Container, bind: interfaces.Bind } { const container = new Container(); const bind = container.bind.bind(container); + bind(DefaultGitEnvProvider).toSelf().inRequestScope(); + bind(GitEnvProvider).toService(DefaultGitEnvProvider); bindLogger(bind); return { container, bind }; } diff --git a/packages/monaco/src/browser/monaco-quick-open-service.ts b/packages/monaco/src/browser/monaco-quick-open-service.ts index 9f249a2712ba5..d58d888a87876 100644 --- a/packages/monaco/src/browser/monaco-quick-open-service.ts +++ b/packages/monaco/src/browser/monaco-quick-open-service.ts @@ -168,6 +168,7 @@ export class MonacoQuickOpenService extends QuickOpenService { export class MonacoQuickOpenControllerOptsImpl implements MonacoQuickOpenControllerOpts { protected readonly options: QuickOpenOptions.Resolved; + readonly password?: boolean; constructor( protected readonly model: QuickOpenModel, @@ -175,16 +176,13 @@ export class MonacoQuickOpenControllerOptsImpl implements MonacoQuickOpenControl ) { this.model = model; this.options = QuickOpenOptions.resolve(options); + this.password = this.options.password; } get prefix(): string { return this.options.prefix; } - get password(): boolean { - return this.options.password; - } - get ignoreFocusOut(): boolean { return this.options.ignoreFocusOut; } diff --git a/yarn.lock b/yarn.lock index 65bc9f471a08f..eded5178a8a59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -325,6 +325,11 @@ version "1.0.0" resolved "https://registry.yarnpkg.com/@types/p-debounce/-/p-debounce-1.0.0.tgz#c7fab3d61f9bc6454337c4aef0dec069456d00ee" +"@types/p-queue@^2.3.1": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@types/p-queue/-/p-queue-2.3.1.tgz#2fb251e46e884e31c4bd1bf58f0e188972353ff4" + integrity sha512-JyO7uMAtkcMMULmsTQ4t/lCC8nxirTtweGG1xAFNNIAoC1RemmeIxq8PiKghuEy99XdbS6Lwx4zpbXUjfeSSAA== + "@types/prop-types@*": version "15.5.5" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.5.5.tgz#17038dd322c2325f5da650a94d5f9974943625e3" @@ -3330,9 +3335,10 @@ dtrace-provider@~0.8: dependencies: nan "^2.10.0" -dugite-extra@0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/dugite-extra/-/dugite-extra-0.1.7.tgz#606081e8ad17fa9487fd50fd828fac05b1734788" +dugite-extra@0.1.9: + version "0.1.9" + resolved "https://registry.yarnpkg.com/dugite-extra/-/dugite-extra-0.1.9.tgz#31f73c683804e3c059a5dba512e5159de18975df" + integrity sha512-ft2ROrM0jKTT+aAM14DYf1OuFfuuxxAIzy7AuDSDwTKv1op4ReKDrM9iDLn97pS04rHpY6pGx2HCOTF8xKNO5w== dependencies: byline "^5.0.0" dugite "1.67.0" @@ -7118,6 +7124,11 @@ p-map@^1.1.1, p-map@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b" +p-queue@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-2.4.2.tgz#03609826682b743be9a22dba25051bd46724fc34" + integrity sha512-n8/y+yDJwBjoLQe1GSJbbaYQLTI7QHNZI2+rpmCDbe++WLf9HC3gf6iqj5yfPAV71W4UF3ql5W1+UBPXoXTxng== + p-reduce@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-reduce/-/p-reduce-1.0.0.tgz#18c2b0dd936a4690a529f8231f58a0fdb6a47dfa"