Skip to content

feat: add create plugin command #3800

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Aug 22, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/man_pages/lib-management/plugin-create.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<% if (isJekyll) { %>---
title: tns plugin create
position: 1
---<% } %>
# tns plugin create

Usage | Synopsis
---|---
Create a new plugin | `$ tns plugin create <Plugin Repository Name> [--path <Directory>]`

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:

* `src` - source code of the plugin
* `demo` - simple NativeScript application used to test and show plugin features
* `publish` - shell scripts used to build and pack the plugin source code and publish it in [NPM](https://www.npmjs.com/)

The project is setup for easy commit in Github, which is why the command will ask you for your Github username.
<% 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).<% } %>

### Options

* `--path` - Specifies the directory where you want to create the project, if different from the current directory.
* `--username` - Specifies the Github username, which will be used to build the URLs in the plugin's package.json file.
* `--pluginName` - Used to set the default file and class names in the plugin source.

### Attributes

* `<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.
2 changes: 2 additions & 0 deletions docs/man_pages/lib-management/plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Lets you manage the plugins for your project.
* `find` - Finds NativeScript plugins in npm.
* `search` - Finds NativeScript plugins in npm.
* `build` - Builds the Android parts of a NativeScript plugin.
* `create` - Creates a project for building a new NativeScript plugin.

<% if(isHtml) { %>
### Related Commands
Expand All @@ -30,4 +31,5 @@ Command | Description
[plugin remove](plugin-remove.html) | Uninstalls the specified plugin and its dependencies.
[plugin update](plugin-update.html) | Updates the specified plugin(s) and its dependencies.
[plugin build](plugin-build.html) | Builds the Android project of a NativeScript plugin, and updates the `include.gradle`.
[plugin create](plugin-create.html) | Builds the Android project of a NativeScript plugin, and updates the `include.gradle`.
<% } %>
1 change: 1 addition & 0 deletions lib/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ $injector.requireCommand("plugin|install", "./commands/plugin/add-plugin");
$injector.requireCommand("plugin|remove", "./commands/plugin/remove-plugin");
$injector.requireCommand("plugin|update", "./commands/plugin/update-plugin");
$injector.requireCommand("plugin|build", "./commands/plugin/build-plugin");
$injector.requireCommand("plugin|create", "./commands/plugin/create-plugin");

$injector.require("doctorService", "./services/doctor-service");
$injector.require("xcprojService", "./services/xcproj-service");
Expand Down
114 changes: 114 additions & 0 deletions lib/commands/plugin/create-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import * as path from "path";
import { isInteractive } from "../../common/helpers";

export class CreatePluginCommand implements ICommand {
public allowedParameters: ICommandParameter[] = [];
public userMessage = "What is your GitHub username?\n(will be used to update the Github URLs in the plugin's package.json)";
public nameMessage = "What will be the name of your plugin?\n(use lowercase characters and dashes only)";
constructor(private $options: IOptions,
private $errors: IErrors,
private $terminalSpinnerService: ITerminalSpinnerService,
private $logger: ILogger,
private $pacoteService: IPacoteService,
private $fs: IFileSystem,
private $childProcess: IChildProcess,
private $prompter: IPrompter,
private $npm: INodePackageManager) { }

public async execute(args: string[]): Promise<void> {
const pluginRepoName = args[0];
const pathToProject = this.$options.path;
const selectedPath = path.resolve(pathToProject || ".");
const projectDir = path.join(selectedPath, pluginRepoName);

this.$logger.printMarkdown("Downloading the latest version of NativeScript Plugin Seed...");
await this.downloadPackage(projectDir);

this.$logger.printMarkdown("Executing initial plugin configuration script...");
await this.setupSeed(projectDir, pluginRepoName);

this.$logger.printMarkdown("Solution for `%s` was successfully created.", pluginRepoName);
}

public async canExecute(args: string[]): Promise<boolean> {
if (!args[0]) {
this.$errors.fail("You must specify the plugin repository name.");
}

return true;
}

private async setupSeed(projectDir: string, pluginRepoName: string): Promise<void> {
const config = this.$options;
const spinner = this.$terminalSpinnerService.createSpinner();
const cwd = path.join(projectDir, "src");
try {
spinner.start();
const npmOptions: any = { silent: true };
await this.$npm.install(cwd, cwd, npmOptions);
} finally {
spinner.stop();
}

const gitHubUsername = await this.getGitHubUsername(config.username);
const pluginNameSource = await this.getPluginNameSource(config.pluginName, pluginRepoName);

if (!isInteractive() && (!config.username || !config.pluginName)) {
this.$logger.printMarkdown("Using default values for Github user and/or plugin name since your shell is not interactive.");
}

// run postclone script manually and kill it if it takes more than 10 sec
const pathToPostCloneScript = path.join("scripts", "postclone");
const params = [pathToPostCloneScript, `gitHubUsername=${gitHubUsername}`, `pluginName=${pluginNameSource}`, "initGit=y"];
const outputScript = (await this.$childProcess.spawnFromEvent(process.execPath, params, "close", { cwd, timeout: 10000 }));
if (outputScript && outputScript.stdout) {
this.$logger.printMarkdown(outputScript.stdout);
}
}

private async downloadPackage(projectDir: string): Promise<void> {
this.$fs.createDirectory(projectDir);

if (this.$fs.exists(projectDir) && !this.$fs.isEmptyDir(projectDir)) {
this.$errors.fail("Path already exists and is not empty %s", projectDir);
}

const spinner = this.$terminalSpinnerService.createSpinner();
const packageToInstall = "https://github.com/NativeScript/nativescript-plugin-seed/archive/master.tar.gz";
try {
spinner.start();
await this.$pacoteService.extractPackage(packageToInstall, projectDir);
} catch (err) {
this.$fs.deleteDirectory(projectDir);
throw err;
} finally {
spinner.stop();
}
}

private async getGitHubUsername(gitHubUsername: string) {
if (!gitHubUsername) {
gitHubUsername = "NativeScriptDeveloper";
if (isInteractive()) {
gitHubUsername = await this.$prompter.getString(this.userMessage, { allowEmpty: false, defaultAction: () => { return gitHubUsername; } });
}
}

return gitHubUsername;
}

private async getPluginNameSource(pluginNameSource: string, pluginRepoName: string) {
if (!pluginNameSource) {
// remove nativescript- prefix for naming plugin files
const prefix = 'nativescript-';
pluginNameSource = pluginRepoName.toLowerCase().startsWith(prefix) ? pluginRepoName.slice(prefix.length, pluginRepoName.length) : pluginRepoName;
if (isInteractive()) {
pluginNameSource = await this.$prompter.getString(this.nameMessage, { allowEmpty: false, defaultAction: () => { return pluginNameSource; } });
}
}

return pluginNameSource;
}
}

$injector.registerCommand(["plugin|create"], CreatePluginCommand);
7 changes: 6 additions & 1 deletion lib/declarations.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,12 @@ interface IPort {
port: Number;
}

interface IOptions extends ICommonOptions, IBundleString, IPlatformTemplate, IHasEmulatorOption, IClean, IProvision, ITeamIdentifier, IAndroidReleaseOptions, INpmInstallConfigurationOptions, IPort, IEnvOptions {
interface IPluginSeedOptions {
username: string;
pluginName: string;
}

interface IOptions extends ICommonOptions, IBundleString, IPlatformTemplate, IHasEmulatorOption, IClean, IProvision, ITeamIdentifier, IAndroidReleaseOptions, INpmInstallConfigurationOptions, IPort, IEnvOptions, IPluginSeedOptions {
all: boolean;
client: boolean;
compileSdk: number;
Expand Down
4 changes: 3 additions & 1 deletion lib/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ export class Options extends commonOptionsLibPath.OptionsBase {
inspector: { type: OptionType.Boolean },
clean: { type: OptionType.Boolean },
watch: { type: OptionType.Boolean, default: true },
background: { type: OptionType.String }
background: { type: OptionType.String },
username: { type: OptionType.String },
pluginName: { type: OptionType.String }
},
$errors, $staticConfig, $settingsService);

Expand Down
116 changes: 116 additions & 0 deletions test/plugin-create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { Yok } from "../lib/common/yok";
import * as stubs from "./stubs";
import { CreatePluginCommand } from "../lib/commands/plugin/create-plugin";
import { assert } from "chai";
import helpers = require("../lib/common/helpers");

const originalIsInteractive = helpers.isInteractive;
const dummyArgs = ["dummyProjectName"];
const dummyUser = "devUsername";
const dummyName = "devPlugin";

function createTestInjector() {
const testInjector = new Yok();

testInjector.register("injector", testInjector);
testInjector.register("errors", stubs.ErrorsStub);
testInjector.register("logger", stubs.LoggerStub);
testInjector.register("childProcess", stubs.ChildProcessStub);
testInjector.register("prompter", new stubs.PrompterStub());
testInjector.register("fs", stubs.FileSystemStub);
testInjector.register("npm", stubs.NpmInstallationManagerStub);
testInjector.register("options", {
username: undefined,
pluginName: undefined
});

testInjector.register("terminalSpinnerService", {
createSpinner: () => ({
start: (): void => undefined,
stop: (): void => undefined,
message: (): void => undefined
})
});

testInjector.register("pacoteService", {
manifest: () => Promise.resolve(),
extractPackage: () => Promise.resolve()
});

testInjector.register("createCommand", CreatePluginCommand);

return testInjector;
}

describe("Plugin create command tests", () => {
let testInjector: IInjector;
let options: IOptions;
let createPluginCommand: CreatePluginCommand;

beforeEach(() => {
helpers.isInteractive = () => true;
testInjector = createTestInjector();
options = testInjector.resolve("$options");
createPluginCommand = testInjector.resolve("$createCommand");
});

afterEach(() => {
helpers.isInteractive = originalIsInteractive;
});

describe("#CreatePluginCommand", () => {
it("should fail when project name is not set.", async () => {
assert.isRejected(createPluginCommand.canExecute([]));
});

it("should pass when only project name is set in non-interactive shell.", async () => {
helpers.isInteractive = () => false;
await createPluginCommand.execute(dummyArgs);
});

it("should pass when only project name is set with prompts in interactive shell.", async () => {
const prompter = testInjector.resolve("$prompter");
const strings: IDictionary<string> = {};
strings[createPluginCommand.userMessage] = dummyUser;
strings[createPluginCommand.nameMessage] = dummyName;

prompter.expect({
strings: strings
});
await createPluginCommand.execute(dummyArgs);
prompter.assert();
});

it("should pass with project name and username set with one prompt in interactive shell.", async () => {
options.username = dummyUser;
const prompter = testInjector.resolve("$prompter");
const strings: IDictionary<string> = {};
strings[createPluginCommand.nameMessage] = dummyName;

prompter.expect({
strings: strings
});
await createPluginCommand.execute(dummyArgs);
prompter.assert();
});

it("should pass with project name and pluginName set with one prompt in interactive shell.", async () => {
options.pluginName = dummyName;
const prompter = testInjector.resolve("$prompter");
const strings: IDictionary<string> = {};
strings[createPluginCommand.userMessage] = dummyUser;

prompter.expect({
strings: strings
});
await createPluginCommand.execute(dummyArgs);
prompter.assert();
});

it("should pass with project name, username and pluginName set with no prompt in interactive shell.", async () => {
options.username = dummyUser;
options.pluginName = dummyName;
await createPluginCommand.execute(dummyArgs);
});
});
});
8 changes: 8 additions & 0 deletions test/stubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,10 +629,18 @@ export class AndroidToolsInfoStub implements IAndroidToolsInfo {

export class ChildProcessStub {
public spawnCount = 0;
public execCount = 0;
public spawnFromEventCount = 0;
public lastCommand = "";
public lastCommandArgs: string[] = [];

public async exec(command: string, options?: any, execOptions?: any): Promise<any> {
this.execCount++;
this.lastCommand = command;
this.lastCommandArgs = command ? command.split(" ") : [];
return null;
}

public spawn(command: string, args?: string[], options?: any): any {
this.spawnCount++;
this.lastCommand = command;
Expand Down