Skip to content

Commit

Permalink
Introduce '--install-plugin' cli command. (#13421)
Browse files Browse the repository at this point in the history
Fixes #13406

Inspired by the VS Code --install-extension command. Users can give the
option multiple times with either a plugin id of the form
"publisher.name[@Version]" or with a file path designating a *.vsix
file.

contributed on behalf of STMicroelectronics

Signed-off-by: Thomas Mäder <t.s.maeder@gmail.com>
  • Loading branch information
tsmaeder authored Feb 28, 2024
1 parent d22e3e2 commit 627dad4
Show file tree
Hide file tree
Showing 12 changed files with 140 additions and 49 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

## v1.47.0 not yet released

- [component] add here
- [plugin] Add command to install plugins from the command line [#13406](https://github.com/eclipse-theia/theia/issues/13406) - contributed on behalf of STMicroelectronics

<a name="breaking_changes_not_yet_released">[Breaking Changes:](#breaking_changes_not_yet_released)</a>
- [monaco] Upgrade Monaco dependency to 1.83.1 [#13217](https://github.com/eclipse-theia/theia/pull/13217)- contributed on behalf of STMicroelectronics\
Expand Down
66 changes: 48 additions & 18 deletions packages/core/src/common/promise-util.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as assert from 'assert/strict';
import { firstTrue, waitForEvent } from './promise-util';
import { Deferred, firstTrue, waitForEvent } from './promise-util';
import { Emitter } from './event';
import { CancellationError } from './cancellation';

Expand All @@ -35,37 +35,67 @@ describe('promise-util', () => {
});
});

type ExecutionHandler<T> = (resolve: (value: T) => void, reject: (error: unknown) => void) => void;

describe('firstTrue', () => {
function createSequentialPromises<T>(...executionHandlers: ExecutionHandler<T>[]): Promise<T>[] {
const deferreds: Deferred<T>[] = [];
let i = 0;
for (let k = 0; k < executionHandlers.length; k++) {
deferreds.push(new Deferred<T>());
}

const resolveNext = () => {
if (i < executionHandlers.length) {
executionHandlers[i](value => deferreds[i].resolve(value), error => deferreds[i].reject(error));
i++;
}
if (i < executionHandlers.length) {
setTimeout(resolveNext, 1);
}
};

setTimeout(resolveNext, 1);
return deferreds.map(deferred => deferred.promise);
}

it('should resolve to false when the promises arg is empty', async () => {
const actual = await firstTrue();
assert.strictEqual(actual, false);
});

it('should resolve to true when the first promise resolves to true', async () => {
const signals: string[] = [];
const createPromise = (signal: string, timeout: number, result: boolean) =>
new Promise<boolean>(resolve => setTimeout(() => {

function createHandler(signal: string, result?: boolean): ExecutionHandler<boolean> {
return (resolve: (value: boolean) => void, reject: (error: unknown) => void) => {
signals.push(signal);
resolve(result);
}, timeout));
const actual = await firstTrue(
createPromise('a', 10, false),
createPromise('b', 20, false),
createPromise('c', 30, true),
createPromise('d', 40, false),
createPromise('e', 50, true)
);
if (typeof result !== 'undefined') {
resolve(result);
} else {
reject(undefined);
}
};
}

const actual = await firstTrue(...createSequentialPromises(
createHandler('a', false),
createHandler('b', false),
createHandler('c', true),
createHandler('d', false),
createHandler('e', true)
));
assert.strictEqual(actual, true);
assert.deepStrictEqual(signals, ['a', 'b', 'c']);
});

it('should reject when one of the promises rejects', async () => {
await assert.rejects(firstTrue(
new Promise<boolean>(resolve => setTimeout(() => resolve(false), 10)),
new Promise<boolean>(resolve => setTimeout(() => resolve(false), 20)),
new Promise<boolean>((_, reject) => setTimeout(() => reject(new Error('my test error')), 30)),
new Promise<boolean>(resolve => setTimeout(() => resolve(true), 40)),
), /Error: my test error/);
await assert.rejects(firstTrue(...createSequentialPromises<boolean>(
(resolve, _) => resolve(false),
resolve => resolve(false),
(_, reject) => reject(new Error('my test error')),
resolve => resolve(true),
)), /Error: my test error/);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ export class PluginVscodeCommandsContribution implements CommandContribution {
commands.registerCommand({ id: VscodeCommands.INSTALL_FROM_VSIX.id }, {
execute: async (vsixUriOrExtensionId: TheiaURI | UriComponents | string) => {
if (typeof vsixUriOrExtensionId === 'string') {
await this.pluginServer.deploy(VSCodeExtensionUri.toVsxExtensionUriString(vsixUriOrExtensionId));
await this.pluginServer.deploy(VSCodeExtensionUri.fromId(vsixUriOrExtensionId).toString());
} else {
const uriPath = isUriComponents(vsixUriOrExtensionId) ? URI.revive(vsixUriOrExtensionId).fsPath : await this.fileService.fsPath(vsixUriOrExtensionId);
await this.pluginServer.deploy(`local-file:${uriPath}`);
Expand Down
32 changes: 15 additions & 17 deletions packages/plugin-ext-vscode/src/common/plugin-vscode-uri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,26 +21,24 @@ import URI from '@theia/core/lib/common/uri';
* In practice, this means that it will be resolved and deployed by the Open-VSX system.
*/
export namespace VSCodeExtensionUri {
export const VSCODE_PREFIX = 'vscode:extension/';
/**
* Should be used to prefix a plugin's ID to ensure that it is identified as a VSX Extension.
* @returns `vscode:extension/${id}`
*/
export function toVsxExtensionUriString(id: string): string {
return `${VSCODE_PREFIX}${id}`;
}
export function toUri(name: string, namespace: string): URI;
export function toUri(id: string): URI;
export function toUri(idOrName: string, namespace?: string): URI {
if (typeof namespace === 'string') {
return new URI(toVsxExtensionUriString(`${namespace}.${idOrName}`));
export const SCHEME = 'vscode-extension';

export function fromId(id: string, version?: string): URI {
if (typeof version === 'string') {
return new URI().withScheme(VSCodeExtensionUri.SCHEME).withAuthority(id).withPath(`/${version}`);
} else {
return new URI(toVsxExtensionUriString(idOrName));
return new URI().withScheme(VSCodeExtensionUri.SCHEME).withAuthority(id);
}
}
export function toId(uri: URI): string | undefined {
if (uri.scheme === 'vscode' && uri.path.dir.toString() === 'extension') {
return uri.path.base;

export function fromVersionedId(versionedId: string): URI {
const versionAndId = versionedId.split('@');
return fromId(versionAndId[0], versionAndId[1]);
}

export function toId(uri: URI): { id: string, version?: string } | undefined {
if (uri.scheme === VSCodeExtensionUri.SCHEME) {
return { id: uri.authority, version: uri.path.isRoot ? undefined : uri.path.base };
}
return undefined;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-ext-vscode/src/node/scanner-vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export class VsCodePluginScanner extends TheiaPluginScanner implements PluginSca
// Iterate over the list of dependencies present, and add them to the collection.
dependency.forEach((dep: string) => {
const dependencyId = dep.toLowerCase();
dependencies.set(dependencyId, VSCodeExtensionUri.toVsxExtensionUriString(dependencyId));
dependencies.set(dependencyId, VSCodeExtensionUri.fromId(dependencyId).toString());
});
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export class VSXExtensionEditorManager extends WidgetOpenHandler<VSXExtensionEdi
if (!id) {
throw new Error('Invalid URI: ' + uri.toString());
}
return { id };
return id;
}

}
2 changes: 1 addition & 1 deletion packages/vsx-registry/src/browser/vsx-extension.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export class VSXExtension implements VSXExtensionData, TreeElement {
}

get uri(): URI {
return VSCodeExtensionUri.toUri(this.id);
return VSCodeExtensionUri.fromId(this.id);
}

get id(): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export class VSXLanguageQuickPickService extends LanguageQuickPickService {
localizationContribution.localizedLanguageName ?? localizationContribution.languageName ?? localizationContribution.languageId),
});
try {
const extensionUri = VSCodeExtensionUri.toUri(extension.extension.name, extension.extension.namespace).toString();
const extensionUri = VSCodeExtensionUri.fromId(extension.extension.name, extension.extension.namespace).toString();
await this.pluginServer.deploy(extensionUri);
} finally {
progress.cancel();
Expand Down
46 changes: 46 additions & 0 deletions packages/vsx-registry/src/node/vsx-cli-deployer-participant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// *****************************************************************************
// Copyright (C) 2024 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-only WITH Classpath-exception-2.0
// *****************************************************************************

import { inject, injectable } from '@theia/core/shared/inversify';
import { PluginDeployerParticipant, PluginDeployerStartContext } from '@theia/plugin-ext';
import { VsxCli } from './vsx-cli';
import { VSXExtensionUri } from '../common';
import * as fs from 'fs';
import { FileUri } from '@theia/core/lib/node';
import * as path from 'path';

@injectable()
export class VsxCliDeployerParticipant implements PluginDeployerParticipant {

@inject(VsxCli)
protected readonly vsxCli: VsxCli;

async onWillStart(context: PluginDeployerStartContext): Promise<void> {
const pluginUris = this.vsxCli.pluginsToInstall.map(async id => {
try {
const resolvedPath = path.resolve(id);
const stat = await fs.promises.stat(resolvedPath);
if (stat.isFile()) {
return FileUri.create(resolvedPath).withScheme('local-file').toString();
}
} catch (e) {
// expected if file does not exist
}
return VSXExtensionUri.fromVersionedId(id).toString();
});
context.userEntries.push(...await Promise.all(pluginUris));
}
}
13 changes: 13 additions & 0 deletions packages/vsx-registry/src/node/vsx-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,28 @@ import * as fs from 'fs';
export class VsxCli implements CliContribution {

ovsxRouterConfig: OVSXRouterConfig | undefined;
pluginsToInstall: string[] = [];

configure(conf: Argv<{}>): void {
conf.option('ovsx-router-config', { description: 'JSON configuration file for the OVSX router client', type: 'string' });
conf.option('install-plugin', {
alias: 'install-extension',
nargs: 1,
desc: 'Installs or updates a plugin. Argument is a path to the *.vsix file or a plugin id of the form "publisher.name[@version]"'
});
}

async setArguments(args: Record<string, unknown>): Promise<void> {
const { 'ovsx-router-config': ovsxRouterConfig } = args;
if (typeof ovsxRouterConfig === 'string') {
this.ovsxRouterConfig = JSON.parse(await fs.promises.readFile(ovsxRouterConfig, 'utf8'));
}
let pluginsToInstall = args.installPlugin;
if (typeof pluginsToInstall === 'string') {
pluginsToInstall = [pluginsToInstall];
}
if (Array.isArray(pluginsToInstall)) {
this.pluginsToInstall = pluginsToInstall;
}
}
}
15 changes: 8 additions & 7 deletions packages/vsx-registry/src/node/vsx-extension-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,14 @@ export class VSXExtensionResolver implements PluginDeployerResolver {
}
let extension: VSXExtensionRaw | undefined;
const client = await this.clientProvider();
if (options) {
console.log(`[${id}]: trying to resolve version ${options.version}...`);
const { extensions } = await client.query({ extensionId: id, extensionVersion: options.version, includeAllVersions: true });
const version = options?.version || id.version;
if (version) {
console.log(`[${id}]: trying to resolve version ${version}...`);
const { extensions } = await client.query({ extensionId: id.id, extensionVersion: version, includeAllVersions: true });
extension = extensions[0];
} else {
console.log(`[${id}]: trying to resolve latest version...`);
const { extensions } = await client.query({ extensionId: id, includeAllVersions: true });
const { extensions } = await client.query({ extensionId: id.id, includeAllVersions: true });
extension = this.vsxApiFilter.getLatestCompatibleExtension(extensions);
}
if (!extension) {
Expand All @@ -66,12 +67,12 @@ export class VSXExtensionResolver implements PluginDeployerResolver {
if (extension.error) {
throw new Error(extension.error);
}
const resolvedId = id + '-' + extension.version;
const resolvedId = id.id + '-' + extension.version;
const downloadUrl = extension.files.download;
console.log(`[${id}]: resolved to '${resolvedId}'`);
console.log(`[${id.id}]: resolved to '${resolvedId}'`);

if (!options?.ignoreOtherVersions) {
const existingVersion = this.hasSameOrNewerVersion(id, extension);
const existingVersion = this.hasSameOrNewerVersion(id.id, extension);
if (existingVersion) {
console.log(`[${id}]: is already installed with the same or newer version '${existingVersion}'`);
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@
import { ConnectionHandler, JsonRpcConnectionHandler } from '@theia/core';
import { CliContribution } from '@theia/core/lib/node';
import { ContainerModule } from '@theia/core/shared/inversify';
import { PluginDeployerResolver } from '@theia/plugin-ext/lib/common/plugin-protocol';
import { PluginDeployerParticipant, PluginDeployerResolver } from '@theia/plugin-ext/lib/common/plugin-protocol';
import { VSXEnvironment, VSX_ENVIRONMENT_PATH } from '../common/vsx-environment';
import { VsxCli } from './vsx-cli';
import { VSXEnvironmentImpl } from './vsx-environment-impl';
import { VSXExtensionResolver } from './vsx-extension-resolver';
import { VsxCliDeployerParticipant } from './vsx-cli-deployer-participant';

export default new ContainerModule(bind => {
bind(VSXEnvironment).to(VSXEnvironmentImpl).inSingletonScope();
Expand All @@ -32,4 +33,6 @@ export default new ContainerModule(bind => {
.inSingletonScope();
bind(VSXExtensionResolver).toSelf().inSingletonScope();
bind(PluginDeployerResolver).toService(VSXExtensionResolver);
bind(VsxCliDeployerParticipant).toSelf().inSingletonScope();
bind(PluginDeployerParticipant).toService(VsxCliDeployerParticipant);
});

0 comments on commit 627dad4

Please sign in to comment.