Skip to content

Commit d74399e

Browse files
authored
Merge pull request #3800 from NativeScript/lini/create-plugin-command
feat: add create plugin command
2 parents e4a4044 + 7a207c7 commit d74399e

File tree

8 files changed

+278
-2
lines changed

8 files changed

+278
-2
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<% if (isJekyll) { %>---
2+
title: tns plugin create
3+
position: 1
4+
---<% } %>
5+
# tns plugin create
6+
7+
Usage | Synopsis
8+
---|---
9+
Create a new plugin | `$ tns plugin create <Plugin Repository Name> [--path <Directory>]`
10+
11+
Creates a new project for NativeScript plugin development. The project uses the [NativeScript Plugin Seed](https://github.com/NativeScript/nativescript-plugin-seed) as a base and contains the following directories:
12+
13+
* `src` - source code of the plugin
14+
* `demo` - simple NativeScript application used to test and show plugin features
15+
* `publish` - shell scripts used to build and pack the plugin source code and publish it in [NPM](https://www.npmjs.com/)
16+
17+
The project is setup for easy commit in Github, which is why the command will ask you for your Github username.
18+
<% if(isHtml) { %>Before starting to code your first plugin, you can visit the NativeScript documentation page for [building plugins](https://docs.nativescript.org/plugins/building-plugins#step-2-set-up-a-development-workflow) or the [plugin seed repository](https://github.com/NativeScript/nativescript-plugin-seed/blob/master/README.md).<% } %>
19+
20+
### Options
21+
22+
* `--path` - Specifies the directory where you want to create the project, if different from the current directory.
23+
* `--username` - Specifies the Github username, which will be used to build the URLs in the plugin's package.json file.
24+
* `--pluginName` - Used to set the default file and class names in the plugin source.
25+
26+
### Attributes
27+
28+
* `<Plugin Repository Name>` is the name of repository where your plugin will reside. A directory with the same name will be created. For example: `nativescript-awesome-list`. If a directory with the name already exists and is not empty, the plugin create command will fail.

docs/man_pages/lib-management/plugin.md

+2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Lets you manage the plugins for your project.
2020
* `find` - Finds NativeScript plugins in npm.
2121
* `search` - Finds NativeScript plugins in npm.
2222
* `build` - Builds the Android parts of a NativeScript plugin.
23+
* `create` - Creates a project for building a new NativeScript plugin.
2324

2425
<% if(isHtml) { %>
2526
### Related Commands
@@ -30,4 +31,5 @@ Command | Description
3031
[plugin remove](plugin-remove.html) | Uninstalls the specified plugin and its dependencies.
3132
[plugin update](plugin-update.html) | Updates the specified plugin(s) and its dependencies.
3233
[plugin build](plugin-build.html) | Builds the Android project of a NativeScript plugin, and updates the `include.gradle`.
34+
[plugin create](plugin-create.html) | Builds the Android project of a NativeScript plugin, and updates the `include.gradle`.
3335
<% } %>

lib/bootstrap.ts

+1
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ $injector.requireCommand("plugin|install", "./commands/plugin/add-plugin");
9696
$injector.requireCommand("plugin|remove", "./commands/plugin/remove-plugin");
9797
$injector.requireCommand("plugin|update", "./commands/plugin/update-plugin");
9898
$injector.requireCommand("plugin|build", "./commands/plugin/build-plugin");
99+
$injector.requireCommand("plugin|create", "./commands/plugin/create-plugin");
99100

100101
$injector.require("doctorService", "./services/doctor-service");
101102
$injector.require("xcprojService", "./services/xcproj-service");

lib/commands/plugin/create-plugin.ts

+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import * as path from "path";
2+
import { isInteractive } from "../../common/helpers";
3+
4+
export class CreatePluginCommand implements ICommand {
5+
public allowedParameters: ICommandParameter[] = [];
6+
public userMessage = "What is your GitHub username?\n(will be used to update the Github URLs in the plugin's package.json)";
7+
public nameMessage = "What will be the name of your plugin?\n(use lowercase characters and dashes only)";
8+
constructor(private $options: IOptions,
9+
private $errors: IErrors,
10+
private $terminalSpinnerService: ITerminalSpinnerService,
11+
private $logger: ILogger,
12+
private $pacoteService: IPacoteService,
13+
private $fs: IFileSystem,
14+
private $childProcess: IChildProcess,
15+
private $prompter: IPrompter,
16+
private $npm: INodePackageManager) { }
17+
18+
public async execute(args: string[]): Promise<void> {
19+
const pluginRepoName = args[0];
20+
const pathToProject = this.$options.path;
21+
const selectedPath = path.resolve(pathToProject || ".");
22+
const projectDir = path.join(selectedPath, pluginRepoName);
23+
24+
this.$logger.printMarkdown("Downloading the latest version of NativeScript Plugin Seed...");
25+
await this.downloadPackage(projectDir);
26+
27+
this.$logger.printMarkdown("Executing initial plugin configuration script...");
28+
await this.setupSeed(projectDir, pluginRepoName);
29+
30+
this.$logger.printMarkdown("Solution for `%s` was successfully created.", pluginRepoName);
31+
}
32+
33+
public async canExecute(args: string[]): Promise<boolean> {
34+
if (!args[0]) {
35+
this.$errors.fail("You must specify the plugin repository name.");
36+
}
37+
38+
return true;
39+
}
40+
41+
private async setupSeed(projectDir: string, pluginRepoName: string): Promise<void> {
42+
const config = this.$options;
43+
const spinner = this.$terminalSpinnerService.createSpinner();
44+
const cwd = path.join(projectDir, "src");
45+
try {
46+
spinner.start();
47+
const npmOptions: any = { silent: true };
48+
await this.$npm.install(cwd, cwd, npmOptions);
49+
} finally {
50+
spinner.stop();
51+
}
52+
53+
const gitHubUsername = await this.getGitHubUsername(config.username);
54+
const pluginNameSource = await this.getPluginNameSource(config.pluginName, pluginRepoName);
55+
56+
if (!isInteractive() && (!config.username || !config.pluginName)) {
57+
this.$logger.printMarkdown("Using default values for Github user and/or plugin name since your shell is not interactive.");
58+
}
59+
60+
// run postclone script manually and kill it if it takes more than 10 sec
61+
const pathToPostCloneScript = path.join("scripts", "postclone");
62+
const params = [pathToPostCloneScript, `gitHubUsername=${gitHubUsername}`, `pluginName=${pluginNameSource}`, "initGit=y"];
63+
const outputScript = (await this.$childProcess.spawnFromEvent(process.execPath, params, "close", { cwd, timeout: 10000 }));
64+
if (outputScript && outputScript.stdout) {
65+
this.$logger.printMarkdown(outputScript.stdout);
66+
}
67+
}
68+
69+
private async downloadPackage(projectDir: string): Promise<void> {
70+
this.$fs.createDirectory(projectDir);
71+
72+
if (this.$fs.exists(projectDir) && !this.$fs.isEmptyDir(projectDir)) {
73+
this.$errors.fail("Path already exists and is not empty %s", projectDir);
74+
}
75+
76+
const spinner = this.$terminalSpinnerService.createSpinner();
77+
const packageToInstall = "https://github.com/NativeScript/nativescript-plugin-seed/archive/master.tar.gz";
78+
try {
79+
spinner.start();
80+
await this.$pacoteService.extractPackage(packageToInstall, projectDir);
81+
} catch (err) {
82+
this.$fs.deleteDirectory(projectDir);
83+
throw err;
84+
} finally {
85+
spinner.stop();
86+
}
87+
}
88+
89+
private async getGitHubUsername(gitHubUsername: string) {
90+
if (!gitHubUsername) {
91+
gitHubUsername = "NativeScriptDeveloper";
92+
if (isInteractive()) {
93+
gitHubUsername = await this.$prompter.getString(this.userMessage, { allowEmpty: false, defaultAction: () => { return gitHubUsername; } });
94+
}
95+
}
96+
97+
return gitHubUsername;
98+
}
99+
100+
private async getPluginNameSource(pluginNameSource: string, pluginRepoName: string) {
101+
if (!pluginNameSource) {
102+
// remove nativescript- prefix for naming plugin files
103+
const prefix = 'nativescript-';
104+
pluginNameSource = pluginRepoName.toLowerCase().startsWith(prefix) ? pluginRepoName.slice(prefix.length, pluginRepoName.length) : pluginRepoName;
105+
if (isInteractive()) {
106+
pluginNameSource = await this.$prompter.getString(this.nameMessage, { allowEmpty: false, defaultAction: () => { return pluginNameSource; } });
107+
}
108+
}
109+
110+
return pluginNameSource;
111+
}
112+
}
113+
114+
$injector.registerCommand(["plugin|create"], CreatePluginCommand);

lib/declarations.d.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,12 @@ interface IPort {
446446
port: Number;
447447
}
448448

449-
interface IOptions extends ICommonOptions, IBundleString, IPlatformTemplate, IHasEmulatorOption, IClean, IProvision, ITeamIdentifier, IAndroidReleaseOptions, INpmInstallConfigurationOptions, IPort, IEnvOptions {
449+
interface IPluginSeedOptions {
450+
username: string;
451+
pluginName: string;
452+
}
453+
454+
interface IOptions extends ICommonOptions, IBundleString, IPlatformTemplate, IHasEmulatorOption, IClean, IProvision, ITeamIdentifier, IAndroidReleaseOptions, INpmInstallConfigurationOptions, IPort, IEnvOptions, IPluginSeedOptions {
450455
all: boolean;
451456
client: boolean;
452457
compileSdk: number;

lib/options.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ export class Options extends commonOptionsLibPath.OptionsBase {
3939
inspector: { type: OptionType.Boolean },
4040
clean: { type: OptionType.Boolean },
4141
watch: { type: OptionType.Boolean, default: true },
42-
background: { type: OptionType.String }
42+
background: { type: OptionType.String },
43+
username: { type: OptionType.String },
44+
pluginName: { type: OptionType.String }
4345
},
4446
$errors, $staticConfig, $settingsService);
4547

test/plugin-create.ts

+116
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { Yok } from "../lib/common/yok";
2+
import * as stubs from "./stubs";
3+
import { CreatePluginCommand } from "../lib/commands/plugin/create-plugin";
4+
import { assert } from "chai";
5+
import helpers = require("../lib/common/helpers");
6+
7+
const originalIsInteractive = helpers.isInteractive;
8+
const dummyArgs = ["dummyProjectName"];
9+
const dummyUser = "devUsername";
10+
const dummyName = "devPlugin";
11+
12+
function createTestInjector() {
13+
const testInjector = new Yok();
14+
15+
testInjector.register("injector", testInjector);
16+
testInjector.register("errors", stubs.ErrorsStub);
17+
testInjector.register("logger", stubs.LoggerStub);
18+
testInjector.register("childProcess", stubs.ChildProcessStub);
19+
testInjector.register("prompter", new stubs.PrompterStub());
20+
testInjector.register("fs", stubs.FileSystemStub);
21+
testInjector.register("npm", stubs.NpmInstallationManagerStub);
22+
testInjector.register("options", {
23+
username: undefined,
24+
pluginName: undefined
25+
});
26+
27+
testInjector.register("terminalSpinnerService", {
28+
createSpinner: () => ({
29+
start: (): void => undefined,
30+
stop: (): void => undefined,
31+
message: (): void => undefined
32+
})
33+
});
34+
35+
testInjector.register("pacoteService", {
36+
manifest: () => Promise.resolve(),
37+
extractPackage: () => Promise.resolve()
38+
});
39+
40+
testInjector.register("createCommand", CreatePluginCommand);
41+
42+
return testInjector;
43+
}
44+
45+
describe("Plugin create command tests", () => {
46+
let testInjector: IInjector;
47+
let options: IOptions;
48+
let createPluginCommand: CreatePluginCommand;
49+
50+
beforeEach(() => {
51+
helpers.isInteractive = () => true;
52+
testInjector = createTestInjector();
53+
options = testInjector.resolve("$options");
54+
createPluginCommand = testInjector.resolve("$createCommand");
55+
});
56+
57+
afterEach(() => {
58+
helpers.isInteractive = originalIsInteractive;
59+
});
60+
61+
describe("#CreatePluginCommand", () => {
62+
it("should fail when project name is not set.", async () => {
63+
assert.isRejected(createPluginCommand.canExecute([]));
64+
});
65+
66+
it("should pass when only project name is set in non-interactive shell.", async () => {
67+
helpers.isInteractive = () => false;
68+
await createPluginCommand.execute(dummyArgs);
69+
});
70+
71+
it("should pass when only project name is set with prompts in interactive shell.", async () => {
72+
const prompter = testInjector.resolve("$prompter");
73+
const strings: IDictionary<string> = {};
74+
strings[createPluginCommand.userMessage] = dummyUser;
75+
strings[createPluginCommand.nameMessage] = dummyName;
76+
77+
prompter.expect({
78+
strings: strings
79+
});
80+
await createPluginCommand.execute(dummyArgs);
81+
prompter.assert();
82+
});
83+
84+
it("should pass with project name and username set with one prompt in interactive shell.", async () => {
85+
options.username = dummyUser;
86+
const prompter = testInjector.resolve("$prompter");
87+
const strings: IDictionary<string> = {};
88+
strings[createPluginCommand.nameMessage] = dummyName;
89+
90+
prompter.expect({
91+
strings: strings
92+
});
93+
await createPluginCommand.execute(dummyArgs);
94+
prompter.assert();
95+
});
96+
97+
it("should pass with project name and pluginName set with one prompt in interactive shell.", async () => {
98+
options.pluginName = dummyName;
99+
const prompter = testInjector.resolve("$prompter");
100+
const strings: IDictionary<string> = {};
101+
strings[createPluginCommand.userMessage] = dummyUser;
102+
103+
prompter.expect({
104+
strings: strings
105+
});
106+
await createPluginCommand.execute(dummyArgs);
107+
prompter.assert();
108+
});
109+
110+
it("should pass with project name, username and pluginName set with no prompt in interactive shell.", async () => {
111+
options.username = dummyUser;
112+
options.pluginName = dummyName;
113+
await createPluginCommand.execute(dummyArgs);
114+
});
115+
});
116+
});

test/stubs.ts

+8
Original file line numberDiff line numberDiff line change
@@ -629,10 +629,18 @@ export class AndroidToolsInfoStub implements IAndroidToolsInfo {
629629

630630
export class ChildProcessStub {
631631
public spawnCount = 0;
632+
public execCount = 0;
632633
public spawnFromEventCount = 0;
633634
public lastCommand = "";
634635
public lastCommandArgs: string[] = [];
635636

637+
public async exec(command: string, options?: any, execOptions?: any): Promise<any> {
638+
this.execCount++;
639+
this.lastCommand = command;
640+
this.lastCommandArgs = command ? command.split(" ") : [];
641+
return null;
642+
}
643+
636644
public spawn(command: string, args?: string[], options?: any): any {
637645
this.spawnCount++;
638646
this.lastCommand = command;

0 commit comments

Comments
 (0)