Skip to content

Commit 527e523

Browse files
feat(extensibility): Add hint how to get access to a command from extension
In case user executes any command that is registered in extension, but this extension is not installed, they'll receive error: `Unknown command ...`. Instead of showing this, it is better to tell them how to get access to this command. In order to achieve this, implement a new logic in `extensibilityService` to find commands registered in all extensions. CLI searches npm (via npms API) for packages that have the keyword `nativescript:extension`. After that, CLI gets the package.json of the latest version of each dependency (via registry.npmjs.org) and searches for nativescript key in it. In case it has nativescript key, CLI reads the commands value inside it. The value should be string array containg all commands registered in extension's bootstrap. When user writes down a command that is not registered in CLI, we check if there's such command and in case it is found, CLI will instruc the user how to install the respective extension. Same will happen in case user tries to access the help content of a command that is registered in extension.
1 parent 00e7a30 commit 527e523

10 files changed

+404
-6
lines changed

lib/declarations.d.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,20 @@ interface INodePackageManager {
3232
* @return {Promise<string>} The output of the uninstallation.
3333
*/
3434
search(filter: string[], config: IDictionary<string | boolean>): Promise<string>;
35+
36+
/**
37+
* Searches for npm packages in npms by keyword.
38+
* @param {string} keyword The keyword based on which the search action will be executed.
39+
* @returns {INpmsResult} The information about found npm packages.
40+
*/
41+
searchNpms(keyword: string): Promise<INpmsResult>;
42+
43+
/**
44+
* Gets information for a specified package from registry.npmjs.org.
45+
* @param {string} packageName The name of the package.
46+
* @returns {any} The full data from registry.npmjs.org for this package.
47+
*/
48+
getRegistryPackageData(packageName: string): Promise<any>;
3549
}
3650

3751
interface INpmInstallationManager {
@@ -306,6 +320,57 @@ interface IDependencyData {
306320
dependencies?: string[];
307321
}
308322

323+
interface INpmsResult {
324+
total: number;
325+
results: INpmsSingleResultData[];
326+
}
327+
328+
interface INpmsSingleResultData {
329+
package: INpmsPackageData;
330+
flags: INpmsFlags;
331+
score: INpmsScore;
332+
searchScore: number;
333+
}
334+
335+
interface INpmsPackageData {
336+
name: string;
337+
// unscoped in case package is not in a scope
338+
// scope name in case part of a scope "angular" for example for @angular/core
339+
scope: string;
340+
version: string;
341+
description: string;
342+
keywords: string[];
343+
date: string;
344+
links: { npm: string };
345+
author: { name: string };
346+
publisher: INpmsUser;
347+
maintainers: INpmsUser[];
348+
}
349+
350+
interface IUsername {
351+
username: string;
352+
}
353+
354+
interface INpmsUser extends IUsername {
355+
email: string;
356+
}
357+
358+
interface INpmsFlags {
359+
unstable: boolean;
360+
insecure: number;
361+
// Describes the reason for deprecation.
362+
deprecated: string;
363+
}
364+
365+
interface INpmsScore {
366+
final: number;
367+
detail: {
368+
quality: number;
369+
popularity: number;
370+
maintenance: number;
371+
}
372+
}
373+
309374
interface IStaticConfig extends Config.IStaticConfig { }
310375

311376
interface IConfiguration extends Config.IConfig {

lib/node-package-manager.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ export class NodePackageManager implements INodePackageManager {
1010
private $hostInfo: IHostInfo,
1111
private $errors: IErrors,
1212
private $childProcess: IChildProcess,
13-
private $logger: ILogger) { }
13+
private $logger: ILogger,
14+
private $httpClient: Server.IHttpClient) { }
1415

1516
@exported("npm")
1617
public async install(packageName: string, pathToSave: string, config: INodePackageManagerInstallOptions): Promise<INpmInstallResultInfo> {
@@ -110,6 +111,23 @@ export class NodePackageManager implements INodePackageManager {
110111
return JSON.parse(viewResult);
111112
}
112113

114+
public async searchNpms(keyword: string): Promise<INpmsResult> {
115+
// TODO: Fix the generation of url - in case it contains @ or / , the call may fail.
116+
const httpRequestResult = await this.$httpClient.httpRequest(`https://api.npms.io/v2/search?q=keywords:${keyword}`);
117+
const result: INpmsResult = JSON.parse(httpRequestResult.body);
118+
return result;
119+
}
120+
121+
public async getRegistryPackageData(packageName: string): Promise<any> {
122+
const url = `https://registry.npmjs.org/${packageName}`;
123+
this.$logger.trace(`Trying to get data from npm registry for package ${packageName}, url is: ${url}`);
124+
const responseData = (await this.$httpClient.httpRequest(url)).body;
125+
this.$logger.trace(`Successfully received data from npm registry for package ${packageName}. Response data is: ${responseData}`);
126+
const jsonData = JSON.parse(responseData);
127+
this.$logger.trace(`Successfully parsed data from npm registry for package ${packageName}.`);
128+
return jsonData;
129+
}
130+
113131
private getNpmExecutableName(): string {
114132
let npmExecutableName = "npm";
115133

lib/services/extensibility-service.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as path from "path";
22
import { cache, exported } from "../common/decorators";
33
import * as constants from "../constants";
4+
import { createRegExp, regExpEscape } from "../common/helpers";
45

56
export class ExtensibilityService implements IExtensibilityService {
67
private get pathToExtensions(): string {
@@ -107,6 +108,63 @@ export class ExtensibilityService implements IExtensibilityService {
107108
}
108109
}
109110

111+
public async getExtensionNameWhereCommandIsRegistered(inputOpts: IGetExtensionCommandInfoParams): Promise<IExtensionCommandInfo> {
112+
let allExtensions: INpmsSingleResultData[] = [];
113+
114+
try {
115+
const npmsResult = await this.$npm.searchNpms("nativescript:extension");
116+
allExtensions = npmsResult.results || [];
117+
} catch (err) {
118+
this.$logger.trace(`Unable to find extensions via npms. Error is: ${err}`);
119+
return null;
120+
}
121+
122+
const defaultCommandRegExp = new RegExp(`${regExpEscape(inputOpts.defaultCommandDelimiter)}.*`);
123+
const commandDelimiterRegExp = createRegExp(inputOpts.commandDelimiter, "g");
124+
125+
for (const extensionData of allExtensions) {
126+
const extensionName = extensionData.package.name;
127+
128+
try {
129+
// now get full package.json for the latest version of the package
130+
const registryData = await this.$npm.getRegistryPackageData(extensionName);
131+
const latestPackageData = registryData.versions[registryData["dist-tags"].latest];
132+
const commands: string[] = latestPackageData && latestPackageData.nativescript && latestPackageData.nativescript.commands;
133+
if (commands && commands.length) {
134+
// For each default command we need to add its short syntax in the array of commands.
135+
// For example in case there's a default command called devices list, the commands array will contain devices|*list.
136+
// However, in case the user executes just tns devices, CLI will still execute the tns devices list command.
137+
// So we need to add the devices command as well.
138+
_.filter(commands, command => command.indexOf(inputOpts.defaultCommandDelimiter) !== -1)
139+
.forEach(defaultCommand => {
140+
commands.push(defaultCommand.replace(defaultCommandRegExp, ""));
141+
});
142+
143+
const copyOfFullArgs = _.clone(inputOpts.inputStrings);
144+
while (copyOfFullArgs.length) {
145+
const currentCommand = copyOfFullArgs.join(inputOpts.commandDelimiter).toLowerCase();
146+
147+
if (_.some(commands, c => c.toLowerCase() === currentCommand)) {
148+
const beautifiedCommandName = currentCommand.replace(commandDelimiterRegExp, " ");
149+
return {
150+
extensionName,
151+
registeredCommandName: currentCommand,
152+
installationMessage: `The command ${beautifiedCommandName} is registered in extension ${extensionName}. You can install it by executing 'tns extension install ${extensionName}'`
153+
};
154+
}
155+
156+
copyOfFullArgs.splice(-1, 1);
157+
}
158+
}
159+
} catch (err) {
160+
// We do not want to stop the whole process in case we are unable to find data for one of the extensions.
161+
this.$logger.trace(`Unable to get data for ${extensionName}. Error is: ${err}`);
162+
}
163+
}
164+
165+
return null;
166+
}
167+
110168
private getPathToExtension(extensionName: string): string {
111169
return path.join(this.pathToExtensions, constants.NODE_MODULES_FOLDER_NAME, extensionName);
112170
}

test/ios-project-service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ function createTestInjector(projectPath: string, projectName: string): IInjector
117117
testInjector.register("npm", NodePackageManager);
118118
testInjector.register("xCConfigService", XCConfigService);
119119
testInjector.register("settingsService", SettingsService);
120+
testInjector.register("httpClient", {});
120121

121122
return testInjector;
122123
}

test/npm-support.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ function createTestInjector(): IInjector {
9696
message: (): void => undefined
9797
})
9898
});
99+
testInjector.register("httpClient", {});
99100

100101
return testInjector;
101102
}

test/platform-commands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ function createTestInjector() {
160160
message: (): void => undefined
161161
})
162162
});
163+
testInjector.register("extensibilityService", {});
163164

164165
return testInjector;
165166
}

test/plugins-service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,8 @@ function createTestInjector() {
110110
message: (): void => undefined
111111
})
112112
});
113-
113+
testInjector.register("httpClient", {});
114+
testInjector.register("extensibilityService", {});
114115
return testInjector;
115116
}
116117

test/project-service.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import * as ProjectHelperLib from "../lib/common/project-helper";
99
import { StaticConfig } from "../lib/config";
1010
import * as NpmLib from "../lib/node-package-manager";
1111
import { NpmInstallationManager } from "../lib/npm-installation-manager";
12-
import * as HttpClientLib from "../lib/common/http-client";
1312
import { FileSystem } from "../lib/common/file-system";
1413
import * as path from "path";
1514
import temp = require("temp");
@@ -145,7 +144,7 @@ class ProjectIntegrationTest {
145144

146145
this.testInjector.register("npmInstallationManager", NpmInstallationManager);
147146
this.testInjector.register("npm", NpmLib.NodePackageManager);
148-
this.testInjector.register("httpClient", HttpClientLib.HttpClient);
147+
this.testInjector.register("httpClient", {});
149148

150149
this.testInjector.register("options", Options);
151150
this.testInjector.register("hostInfo", HostInfo);

0 commit comments

Comments
 (0)