Browse Source

Merge pull request #46 from ckeditor/t/43

T/43 The dev-plugin-install & -create tasks should manage to dedupe CKE5 dependencies.
Piotrek Koszuliński 10 years ago
parent
commit
ac532e0d11

+ 7 - 10
dev/tasks/dev.js

@@ -7,9 +7,9 @@
 
 const initTask = require( './utils/dev-init' );
 const pluginCreateTask = require( './utils/dev-plugin-create' );
-const pluginInstallTask = require( './utils/dev-plugin-install' );
 const pluginUpdateTask = require( './utils/dev-update' );
 const pluginStatusTask = require( './utils/dev-status' );
+const installTask = require( './utils/dev-install' );
 const relinkTask = require( './utils/dev-relink' );
 const boilerplateUpdateTask = require( './utils/dev-boilerplate-update' );
 const ckeditor5Path = process.cwd();
@@ -19,7 +19,7 @@ module.exports = ( grunt ) => {
 	const workspaceRoot = grunt.config.data.workspaceRoot;
 
 	grunt.registerTask( 'dev-init', function() {
-		initTask( ckeditor5Path, packageJSON, workspaceRoot, grunt.log.writeln, grunt.log.error );
+		initTask( installTask, ckeditor5Path, packageJSON, workspaceRoot, grunt.log.writeln );
 	} );
 
 	grunt.registerTask( 'dev-plugin-create', function() {
@@ -29,15 +29,8 @@ module.exports = ( grunt ) => {
 			.catch( ( error )  => done( error ) );
 	} );
 
-	grunt.registerTask( 'dev-plugin-install', function() {
-		const done = this.async();
-		pluginInstallTask( ckeditor5Path, workspaceRoot, grunt.log.writeln )
-			.then( done )
-			.catch( ( error )  => done( error ) );
-	} );
-
 	grunt.registerTask( 'dev-update', function() {
-		pluginUpdateTask( ckeditor5Path, packageJSON, workspaceRoot, grunt.log.writeln, grunt.log.error );
+		pluginUpdateTask( ckeditor5Path, packageJSON, workspaceRoot, grunt.log.writeln, grunt.option( 'npm-update' ) );
 	} );
 
 	grunt.registerTask( 'dev-status', function() {
@@ -51,5 +44,9 @@ module.exports = ( grunt ) => {
 	grunt.registerTask( 'dev-relink', function() {
 		relinkTask( ckeditor5Path, packageJSON, workspaceRoot, grunt.log.writeln, grunt.log.error );
 	} );
+
+	grunt.registerTask( 'dev-install', function( ) {
+		installTask( ckeditor5Path, workspaceRoot, grunt.option( 'plugin' ), grunt.log.writeln );
+	} );
 };
 

+ 13 - 9
dev/tasks/utils/dev-boilerplate-update.js

@@ -29,18 +29,22 @@ module.exports = ( ckeditor5Path, packageJSON, workspaceRoot, writeln, writeErro
 	if ( dependencies ) {
 		const directories = tools.getCKE5Directories( workspaceAbsolutePath );
 
-		for ( let dependency in dependencies ) {
-			const repositoryAbsolutePath = path.join( workspaceAbsolutePath, dependency );
+		if ( directories.length ) {
+			for ( let dependency in dependencies ) {
+				const repositoryAbsolutePath = path.join( workspaceAbsolutePath, dependency );
 
-			// Check if repository's directory already exists.
-			if ( directories.indexOf( dependency ) > -1 ) {
-				try {
-					writeln( `Updating boilerplate in ${ dependency }...` );
-					git.updateBoilerplate( repositoryAbsolutePath );
-				} catch ( error ) {
-					writeError( error );
+				// Check if repository's directory already exists.
+				if ( directories.indexOf( dependency ) > -1 ) {
+					try {
+						writeln( `Updating boilerplate in ${ dependency }...` );
+						git.updateBoilerplate( repositoryAbsolutePath );
+					} catch ( error ) {
+						writeError( error );
+					}
 				}
 			}
+		} else {
+			writeln( 'No CKEditor5 plugins in development mode.' );
 		}
 	} else {
 		writeln( 'No CKEditor5 dependencies found in package.json file.' );

+ 6 - 44
dev/tasks/utils/dev-init.js

@@ -6,66 +6,28 @@
 'use strict';
 
 const tools = require( './tools' );
-const git = require( './git' );
-const path = require( 'path' );
 
 /**
  * 1. Get CKEditor5 dependencies from package.json file.
- * 2. Check if any of the repositories are already present in the workspace.
- * 		2.1. If repository is present in the workspace, check it out to desired branch if one is provided.
- * 		2.2. If repository is not present in the workspace, clone it and checkout to desired branch if one is provided.
- * 3. Pull changes from remote branch.
- * 4. Link each new repository to node_modules. (do not use npm link, use standard linking instead)
- * 5. Run `npm install` in each repository.
- * 6. Install Git hooks in each repository.
+ * 2. Run install task on each dependency.
  *
+ * @param {Function} installTask Install task to use on each dependency.
  * @param {String} ckeditor5Path Path to main CKEditor5 repository.
  * @param {Object} packageJSON Parsed package.json file from CKEditor5 repository.
  * @param {String} workspaceRoot Relative path to workspace root.
  * @param {Function} writeln Function for log output.
- * @param {Function} writeError Function of error output
  */
-module.exports = ( ckeditor5Path, packageJSON, workspaceRoot, writeln, writeError ) => {
-	const workspaceAbsolutePath = path.join( ckeditor5Path, workspaceRoot );
-
+module.exports = ( installTask, ckeditor5Path, packageJSON, workspaceRoot, writeln ) => {
 	// Get all CKEditor dependencies from package.json.
 	const dependencies = tools.getCKEditorDependencies( packageJSON.dependencies );
 
 	if ( dependencies ) {
-		const directories = tools.getCKE5Directories( workspaceAbsolutePath );
-
 		for ( let dependency in dependencies ) {
 			const repositoryURL = dependencies[ dependency ];
-			const urlInfo = git.parseRepositoryUrl( repositoryURL );
-			const repositoryAbsolutePath = path.join( workspaceAbsolutePath, dependency );
-
-			// Check if repository's directory already exists.
-			try {
-				if ( directories.indexOf( dependency ) === -1 ) {
-					writeln( `Clonning ${ repositoryURL }...` );
-					git.cloneRepository( urlInfo, workspaceAbsolutePath );
-				}
-
-				// Check out proper branch.
-				writeln( `Checking out ${ repositoryURL } to ${ urlInfo.branch }...` );
-				git.checkout( repositoryAbsolutePath, urlInfo.branch );
-
-				writeln( `Pulling changes to ${ repositoryURL } ${ urlInfo.branch }...` );
-				git.pull( repositoryAbsolutePath, urlInfo.branch );
-
-				writeln( `Linking ${ repositoryURL }...` );
-				tools.linkDirectories( repositoryAbsolutePath, path.join( ckeditor5Path, 'node_modules' , dependency ) );
-
-				writeln( `Running npm install in ${ repositoryURL }.` );
-				tools.npmInstall( repositoryAbsolutePath );
-
-				writeln( `Installing Git hooks in ${ repositoryURL }.` );
-				tools.installGitHooks( repositoryAbsolutePath );
-			} catch ( error ) {
-				writeError( error );
-			}
+			writeln( `\x1b[1m\x1b[36m${ dependency }\x1b[0m` );
+			installTask( ckeditor5Path, workspaceRoot, repositoryURL, writeln );
 		}
 	} else {
-		writeln( 'No CKEditor5 dependencies found in package.json file.' );
+		writeln( 'No CKEditor5 dependencies (ckeditor5-) found in package.json file.' );
 	}
 };

+ 114 - 0
dev/tasks/utils/dev-install.js

@@ -0,0 +1,114 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+const git = require( './git' );
+const tools = require( './tools' );
+const path = require( 'path' );
+
+/**
+ * This tasks install specified module in development mode. It can be executed by typing:
+ * 		grunt dev-install --plugin <git_hub_url|npm_name|path_on_disk>
+ *
+ *
+ * It performs following steps:
+ * 1. If GitHub URL is provided - clones the repository.
+ * 2. If NPM module name is provided - gets GitHub URL from NPM and clones the repository.
+ * 3. If path on disk is provided - it is used directly.
+ * 4. Runs `npm install` in plugin repository.
+ * 5. If plugin exists in `ckeditor5/node_modules/` - runs `npm uninstall plugin_name`.
+ * 6. Links plugin directory into `ckeditor5/node_modules/`.
+ * 7. Adds dependency to `ckeditor5/package.json`.
+ * 8. Installs Git hooks.
+ *
+ * @param {String} ckeditor5Path Absolute path to `ckeditor5` repository.
+ * @param {String} workspaceRoot Relative path to workspace root directory.
+ * @param {String} name Name of the NPM module or GitHub URL.
+ * @param {Function} writeln Function used to report progress to the console.
+ */
+module.exports = ( ckeditor5Path, workspaceRoot, name, writeln ) => {
+	const workspaceAbsolutePath = path.join( ckeditor5Path, workspaceRoot );
+	let repositoryPath;
+	let dependency;
+	let urlInfo;
+
+	// First check if name is local path to repository.
+	repositoryPath = path.isAbsolute( name ) ? name : path.resolve( name );
+
+	if ( tools.isDirectory( repositoryPath ) ) {
+		const packageName = tools.readPackageName( repositoryPath );
+
+		if ( packageName ) {
+			writeln( `Plugin located at ${ repositoryPath }.` );
+			urlInfo = {
+				name: packageName
+			};
+
+			dependency = repositoryPath;
+		}
+	}
+
+	// Check if name is repository URL.
+	if ( !urlInfo ) {
+		urlInfo = git.parseRepositoryUrl( name );
+		dependency = name;
+	}
+
+	// Check if name is NPM package.
+	if ( !urlInfo ) {
+		writeln( `Not a GitHub URL. Trying to get GitHub URL from NPM package...` );
+		const url = tools.getGitUrlFromNpm( name );
+
+		if ( url ) {
+			urlInfo = git.parseRepositoryUrl( url );
+			dependency  = url;
+		}
+	}
+
+	if ( urlInfo ) {
+		repositoryPath = path.join( workspaceAbsolutePath, urlInfo.name );
+
+		if ( tools.isDirectory( repositoryPath ) ) {
+			writeln( `Directory ${ repositoryPath } already exists.` );
+		} else {
+			writeln( `Cloning ${ urlInfo.name } into ${ repositoryPath }...` );
+			git.cloneRepository( urlInfo, workspaceAbsolutePath );
+		}
+
+		// Checkout to specified branch if one is provided.
+		if ( urlInfo.branch ) {
+			writeln( `Checking ${ urlInfo.name } to ${ urlInfo.branch }...` );
+			git.checkout( repositoryPath, urlInfo.branch );
+		}
+
+		// Run `npm install` in new repository.
+		writeln( `Running "npm install" in ${ urlInfo.name }...` );
+		tools.npmInstall( repositoryPath );
+
+		const linkPath = path.join( ckeditor5Path, 'node_modules', urlInfo.name );
+
+		if ( tools.isDirectory( linkPath ) ) {
+			writeln( `Uninstalling ${ urlInfo.name } from CKEditor5 node_modules...` );
+			tools.npmUninstall( ckeditor5Path, urlInfo.name );
+		}
+
+		writeln( `Linking ${ linkPath } to ${ repositoryPath }...` );
+		tools.linkDirectories( repositoryPath, linkPath );
+
+		writeln( `Adding ${ urlInfo.name } dependency to CKEditor5 package.json...` );
+		tools.updateJSONFile( path.join( ckeditor5Path, 'package.json' ), ( json ) => {
+			json.dependencies = json.dependencies || {};
+			json.dependencies[ urlInfo.name ] = dependency;
+
+			return json;
+		} );
+
+		writeln( `Installing Git hooks in ${ urlInfo.name }...` );
+		tools.installGitHooks( repositoryPath );
+	} else {
+		throw new Error( 'Please provide valid GitHub URL, NPM module name or path.' );
+	}
+};

+ 0 - 70
dev/tasks/utils/dev-plugin-install.js

@@ -1,70 +0,0 @@
-/**
- * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-'use strict';
-
-const inquiries = require( './inquiries' );
-const git = require( './git' );
-const tools = require( './tools' );
-const path = require( 'path' );
-
-/**
- * 1. Ask for plugin name.
- * 2. Ask for GitHub URL.
- * 3. Clone repository from provided GitHub URL.
- * 4. Checkout repository to provided branch (or master if no branch is provided).
- * 5. Update package.json file in CKEditor5 repository.
- * 6. Link new plugin.
- * 7. Call `npm install` in plugin repository.
- * 8. Install Git hooks in plugin repository.
- *
- * @param {String} ckeditor5Path Path to main CKEditor5 repository.
- * @param {String} workspaceRoot Relative path to workspace root.
- * @param {Function} writeln Function for log output.
- * @returns {Promise} Returns promise fulfilled after task is done.
- */
-module.exports = ( ckeditor5Path, workspaceRoot, writeln ) => {
-	const workspaceAbsolutePath = path.join( ckeditor5Path, workspaceRoot );
-	let pluginName;
-	let repositoryPath;
-	let gitHubUrl;
-
-	return inquiries.getPluginName()
-		.then( result => {
-			pluginName = result;
-			repositoryPath = path.join( workspaceAbsolutePath, pluginName );
-
-			return inquiries.getPluginGitHubUrl( pluginName );
-		} )
-		.then( result => {
-			gitHubUrl = result;
-			let urlInfo = git.parseRepositoryUrl( gitHubUrl );
-
-			writeln( `Clonning ${ gitHubUrl }...` );
-			git.cloneRepository( urlInfo, workspaceAbsolutePath );
-
-			writeln( `Checking out ${ gitHubUrl } to ${ urlInfo.branch }...` );
-			git.checkout( repositoryPath, urlInfo.branch );
-
-			writeln( `Updating package.json files...` );
-			tools.updateJSONFile( path.join( ckeditor5Path, 'package.json' ), ( json ) => {
-				if ( !json.dependencies ) {
-					json.dependencies = {};
-				}
-				json.dependencies[ pluginName ] = gitHubUrl;
-
-				return json;
-			} );
-
-			writeln( `Linking ${ pluginName } to node_modules...` );
-			tools.linkDirectories( repositoryPath, path.join( ckeditor5Path, 'node_modules', pluginName ) );
-
-			writeln( `Running npm install in ${ pluginName }.` );
-			tools.npmInstall( repositoryPath );
-
-			writeln( `Installing GIT hooks in ${ pluginName }.` );
-			tools.installGitHooks( repositoryPath );
-		} );
-};

+ 14 - 10
dev/tasks/utils/dev-relink.js

@@ -28,19 +28,23 @@ module.exports = ( ckeditor5Path, packageJSON, workspaceRoot, writeln, writeErro
 	if ( dependencies ) {
 		const directories = tools.getCKE5Directories( workspaceAbsolutePath );
 
-		for ( let dependency in dependencies ) {
-			const repositoryAbsolutePath = path.join( workspaceAbsolutePath, dependency );
-			const repositoryURL = dependencies[ dependency ];
+		if ( directories.length ) {
+			for ( let dependency in dependencies ) {
+				const repositoryAbsolutePath = path.join( workspaceAbsolutePath, dependency );
+				const repositoryURL = dependencies[ dependency ];
 
-			// Check if repository's directory exists.
-			if ( directories.indexOf( dependency ) > -1 ) {
-				try {
-					writeln( `Linking ${ repositoryURL }...` );
-					tools.linkDirectories( repositoryAbsolutePath, path.join( ckeditor5Path, 'node_modules' , dependency ) );
-				} catch ( error ) {
-					writeError( error );
+				// Check if repository's directory exists.
+				if ( directories.indexOf( dependency ) > -1 ) {
+					try {
+						writeln( `Linking ${ repositoryURL }...` );
+						tools.linkDirectories( repositoryAbsolutePath, path.join( ckeditor5Path, 'node_modules', dependency ) );
+					} catch ( error ) {
+						writeError( error );
+					}
 				}
 			}
+		} else {
+			writeln( 'No CKEditor5 plugins in development mode.' );
 		}
 	} else {
 		writeln( 'No CKEditor5 dependencies found in package.json file.' );

+ 14 - 10
dev/tasks/utils/dev-status.js

@@ -29,19 +29,23 @@ module.exports = ( ckeditor5Path, packageJSON, workspaceRoot, writeln, writeErro
 	if ( dependencies ) {
 		const directories = tools.getCKE5Directories( workspaceAbsolutePath );
 
-		for ( let dependency in dependencies ) {
-			const repositoryAbsolutePath = path.join( workspaceAbsolutePath, dependency );
-			let status;
+		if ( directories.length ) {
+			for ( let dependency in dependencies ) {
+				const repositoryAbsolutePath = path.join( workspaceAbsolutePath, dependency );
+				let status;
 
-			// Check if repository's directory already exists.
-			if ( directories.indexOf( dependency ) > -1 ) {
-				try {
-					status = git.getStatus( repositoryAbsolutePath );
-					writeln( `\x1b[1m\x1b[36m${ dependency }\x1b[0m\n${ status.trim() }` );
-				} catch ( error ) {
-					writeError( error );
+				// Check if repository's directory already exists.
+				if ( directories.indexOf( dependency ) > -1 ) {
+					try {
+						status = git.getStatus( repositoryAbsolutePath );
+						writeln( `\x1b[1m\x1b[36m${ dependency }\x1b[0m\n${ status.trim() }` );
+					} catch ( error ) {
+						writeError( error );
+					}
 				}
 			}
+		} else {
+			writeln( 'No CKEditor5 plugins in development mode.' );
 		}
 	} else {
 		writeln( 'No CKEditor5 dependencies found in package.json file.' );

+ 26 - 12
dev/tasks/utils/dev-update.js

@@ -18,9 +18,10 @@ const path = require( 'path' );
  * @param {Object} packageJSON Parsed package.json file from CKEditor5 repository.
  * @param {String} workspaceRoot Relative path to workspace root.
  * @param {Function} writeln Function for log output.
- * @param {Function} writeError Function of error output
+ * @param {Boolean} runNpmUpdate When set to true `npm update` will be executed inside each plugin repository
+ * and inside CKEditor 5 repository.
  */
-module.exports = ( ckeditor5Path, packageJSON, workspaceRoot, writeln, writeError ) => {
+module.exports = ( ckeditor5Path, packageJSON, workspaceRoot, writeln, runNpmUpdate ) => {
 	const workspaceAbsolutePath = path.join( ckeditor5Path, workspaceRoot );
 
 	// Get all CKEditor dependencies from package.json.
@@ -29,20 +30,33 @@ module.exports = ( ckeditor5Path, packageJSON, workspaceRoot, writeln, writeErro
 	if ( dependencies ) {
 		const directories = tools.getCKE5Directories( workspaceAbsolutePath );
 
-		for ( let dependency in dependencies ) {
-			const repositoryURL = dependencies[ dependency ];
-			const urlInfo = git.parseRepositoryUrl( repositoryURL );
-			const repositoryAbsolutePath = path.join( workspaceAbsolutePath, dependency );
+		if ( directories.length ) {
+			for ( let dependency in dependencies ) {
+				const repositoryURL = dependencies[ dependency ];
+				const urlInfo = git.parseRepositoryUrl( repositoryURL );
+				const repositoryAbsolutePath = path.join( workspaceAbsolutePath, dependency );
 
-			// Check if repository's directory already exists.
-			if ( directories.indexOf( dependency ) > -1 ) {
-				try {
-					writeln( `Updating ${ repositoryURL }...` );
+				// Check if repository's directory already exists.
+				if ( directories.indexOf( urlInfo.name ) > -1 ) {
+					writeln( `Checking out ${ urlInfo.name } to ${ urlInfo.branch }...` );
+					git.checkout( repositoryAbsolutePath, urlInfo.branch );
+
+					writeln( `Pulling changes to ${ urlInfo.name }...` );
 					git.pull( repositoryAbsolutePath, urlInfo.branch );
-				} catch ( error ) {
-					writeError( error );
+
+					if ( runNpmUpdate ) {
+						writeln( `Running "npm update" in ${ urlInfo.name }...` );
+						tools.npmUpdate( repositoryAbsolutePath );
+					}
 				}
 			}
+
+			if ( runNpmUpdate ) {
+				writeln( `Running "npm update" in CKEditor5 repository...` );
+				tools.npmUpdate( ckeditor5Path );
+			}
+		} else {
+			writeln( 'No CKEditor5 plugins in development mode.' );
 		}
 	} else {
 		writeln( 'No CKEditor5 dependencies found in package.json file.' );

+ 13 - 7
dev/tasks/utils/git.js

@@ -33,31 +33,37 @@ module.exports = {
 	 * @returns {Object} urlInfo
 	 * @returns {String} urlInfo.server
 	 * @returns {String} urlInfo.repository
+	 * @returns {String} urlInfo.user
+	 * @returns {String} urlInfo.name
 	 * @returns {String} urlInfo.branch
 	 */
 	parseRepositoryUrl( url ) {
-		const regexp = /^(git@github\.com:|https?:\/\/github\.com\/)?([^#]+)(?:#)?(.*)$/;
+		const regexp = /^((?:git@|(?:http[s]?|git):\/\/)github\.com(?:\/|:))?(([\w-]+)\/([\w-]+(?:\.git)?))(?:#([\w-\/]+))?$/;
 		const match = url.match( regexp );
 		let server;
 		let repository;
 		let branch;
+		let name;
+		let user;
 
 		if ( !match ) {
 			return null;
 		}
 
 		server = match[ 1 ] || 'https://github.com/';
-		repository = match[ 2 ] || '';
-		branch = match[ 3 ] || 'master';
+		repository = match[ 2 ];
+		user = match[ 3 ];
+		name = match[ 4 ];
+		branch = match[ 5 ] || 'master';
 
-		if ( !repository ) {
-			return null;
-		}
+		name = /\.git$/.test( name ) ? name.slice( 0, -4 ) : name;
 
 		return {
 			server: server,
 			repository: repository,
-			branch: branch
+			branch: branch,
+			user: user,
+			name: name
 		};
 	},
 

+ 1 - 1
dev/tasks/utils/inquiries.js

@@ -6,7 +6,7 @@
 'use strict';
 
 const inquirer = require( 'inquirer' );
-const DEFAULT_PLUGIN_NAME_PREFIX = 'ckeditor5-plugin-';
+const DEFAULT_PLUGIN_NAME_PREFIX = 'ckeditor5-';
 const DEFAULT_PLUGIN_VERSION = '0.0.1';
 const DEFAULT_GITHUB_URL_PREFIX = 'ckeditor/';
 

+ 79 - 0
dev/tasks/utils/tools.js

@@ -198,6 +198,7 @@ module.exports = {
 
 	/**
 	 * Returns true if path points to existing directory.
+	 *
 	 * @param {String} path
 	 * @returns {Boolean}
 	 */
@@ -212,6 +213,22 @@ module.exports = {
 	},
 
 	/**
+	 * Returns true if path points to existing file.
+	 *
+	 * @param {String} path
+	 * @returns {Boolean}
+	 */
+	isFile( path ) {
+		const fs = require( 'fs' );
+
+		try {
+			return fs.statSync( path ).isFile();
+		} catch ( e ) {}
+
+		return false;
+	},
+
+	/**
 	 * Returns all directories under specified path that match 'ckeditor5' pattern.
 	 *
 	 * @param {String} path
@@ -240,6 +257,26 @@ module.exports = {
 	},
 
 	/**
+	 * Returns name of the NPM module located under provided path.
+	 *
+	 * @param {String} modulePath Path to NPM module.
+     */
+	readPackageName( modulePath ) {
+		const fs = require( 'fs' );
+		const path = require( 'path' );
+		const packageJSONPath = path.join( modulePath, 'package.json' );
+
+		if ( !this.isFile( packageJSONPath ) ) {
+			return null;
+		}
+
+		const contents = fs.readFileSync( packageJSONPath, 'utf-8' );
+		const json = JSON.parse( contents );
+
+		return json.name || null;
+	},
+
+	/**
 	 * Calls `npm install` command in specified path.
 	 *
 	 * @param {String} path
@@ -249,6 +286,24 @@ module.exports = {
 	},
 
 	/**
+	 * Calls `npm uninstall <name>` command in specified path.
+	 *
+	 * @param {String} path
+	 */
+	npmUninstall( path, name ) {
+		this.shExec( `cd ${ path } && npm uninstall ${ name }` );
+	},
+
+	/**
+	 * Calls `npm update` command in specified path.
+	 *
+	 * @param {String} path
+	 */
+	npmUpdate( path ) {
+		this.shExec( `cd ${ path } && npm update` );
+	},
+
+	/**
 	 * Installs Git hooks in specified repository.
 	 *
 	 * @param {String} path
@@ -266,5 +321,29 @@ module.exports = {
 		const path = require( 'path' );
 		const templatesPath = path.resolve( TEMPLATE_PATH );
 		this.shExec( `cp ${ path.join( templatesPath, '*.md' ) } ${ destination }` );
+	},
+
+	/**
+	 * Executes 'npm view' command for provided module name and returns Git url if one is found. Returns null if
+	 * module cannot be found.
+	 *
+	 * @param {String} name Name of the module.
+	 * @returns {*}
+     */
+	getGitUrlFromNpm( name ) {
+		try {
+			const info = JSON.parse( this.shExec( `npm view ${ name } repository --json` ) );
+
+			if ( info && info.type == 'git' ) {
+				return info.url;
+			}
+		} catch ( error ) {
+			// Throw error only when different than E404.
+			if ( error.message.indexOf( 'npm ERR! code E404' ) == -1 ) {
+				throw error;
+			}
+		}
+
+		return null;
 	}
 };

+ 110 - 0
dev/tests/dev-boilerplate-update.js

@@ -0,0 +1,110 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global describe, it */
+
+'use strict';
+
+const sinon = require( 'sinon' );
+const tools = require( '../tasks/utils/tools' );
+const git = require( '../tasks/utils/git' );
+const path = require( 'path' );
+
+describe( 'dev-boilerplate-update', () => {
+	const task = require( '../tasks/utils/dev-boilerplate-update' );
+	const ckeditor5Path = 'path/to/ckeditor5';
+	const workspaceRoot = '..';
+	const workspaceAbsolutePath = path.join( ckeditor5Path, workspaceRoot );
+	const emptyFn = () => {};
+
+	it( 'should update boilerplate in dev repositories', () => {
+		const dirs = [ 'ckeditor5-core', 'ckeditor5-devtest' ];
+		const getDependenciesSpy = sinon.spy( tools, 'getCKEditorDependencies' );
+		const getDirectoriesStub = sinon.stub( tools, 'getCKE5Directories' ).returns( dirs );
+		const updateStub = sinon.stub( git, 'updateBoilerplate' );
+		const json = {
+			dependencies: {
+				'ckeditor5-core': 'ckeditor/ckeditor5-core',
+				'ckeditor5-devtest': 'ckeditor/ckeditor5-devtest#new-branch',
+				'other-plugin': '1.2.3'
+			}
+		};
+
+		task( ckeditor5Path, json, workspaceRoot, emptyFn, emptyFn );
+
+		getDependenciesSpy.restore();
+		getDirectoriesStub.restore();
+		updateStub.restore();
+
+		sinon.assert.calledTwice( updateStub );
+		sinon.assert.calledWithExactly( updateStub.firstCall, path.join( workspaceAbsolutePath, dirs[ 0 ] ) );
+		sinon.assert.calledWithExactly( updateStub.secondCall, path.join( workspaceAbsolutePath, dirs[ 1 ] ) );
+	} );
+
+	it( 'should not update boilerplate when no dependencies are found', () => {
+		const getDependenciesSpy = sinon.spy( tools, 'getCKEditorDependencies' );
+		const getDirectoriesStub = sinon.stub( tools, 'getCKE5Directories' );
+		const updateStub = sinon.stub( git, 'updateBoilerplate' );
+		const json = {
+			dependencies: {
+				'other-plugin': '1.2.3'
+			}
+		};
+
+		task( ckeditor5Path, json, workspaceRoot, emptyFn, emptyFn );
+
+		getDependenciesSpy.restore();
+		getDirectoriesStub.restore();
+		updateStub.restore();
+
+		sinon.assert.notCalled( updateStub );
+	} );
+
+	it( 'should not update boilerplate when no plugins in dev mode', () => {
+		const getDependenciesSpy = sinon.spy( tools, 'getCKEditorDependencies' );
+		const getDirectoriesStub = sinon.stub( tools, 'getCKE5Directories' ).returns( [] );
+		const updateStub = sinon.stub( git, 'updateBoilerplate' );
+		const json = {
+			dependencies: {
+				'ckeditor5-devtest': 'ckeditor/ckeditor5-devtest#new-branch',
+				'other-plugin': '1.2.3'
+			}
+		};
+
+		task( ckeditor5Path, json, workspaceRoot, emptyFn, emptyFn );
+
+		getDependenciesSpy.restore();
+		getDirectoriesStub.restore();
+		updateStub.restore();
+
+		sinon.assert.notCalled( updateStub );
+	} );
+
+	it( 'should write error message when updating boilerplate is unsuccessful', () => {
+		const dirs = [ 'ckeditor5-core' ];
+		const getDependenciesSpy = sinon.spy( tools, 'getCKEditorDependencies' );
+		const getDirectoriesStub = sinon.stub( tools, 'getCKE5Directories' ).returns( dirs );
+		const error = new Error( 'Error message.' );
+		const updateStub = sinon.stub( git, 'updateBoilerplate' ).throws( error );
+		const json = {
+			dependencies: {
+				'ckeditor5-core': 'ckeditor/ckeditor5-core',
+				'ckeditor5-devtest': 'ckeditor/ckeditor5-devtest#new-branch',
+				'other-plugin': '1.2.3'
+			}
+		};
+		const writeErrorSpy = sinon.spy();
+
+		task( ckeditor5Path, json, workspaceRoot, emptyFn, writeErrorSpy );
+
+		getDependenciesSpy.restore();
+		getDirectoriesStub.restore();
+		updateStub.restore();
+
+		sinon.assert.calledOnce( updateStub );
+		sinon.assert.calledOnce( writeErrorSpy );
+		sinon.assert.calledWithExactly( writeErrorSpy, error );
+	} );
+} );

+ 57 - 0
dev/tests/dev-init.js

@@ -0,0 +1,57 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global describe, it */
+
+'use strict';
+
+const sinon = require( 'sinon' );
+const tools = require( '../tasks/utils/tools' );
+
+describe( 'dev-init', () => {
+	const initTask = require( '../tasks/utils/dev-init' );
+	const ckeditor5Path = 'path/to/ckeditor5';
+	const workspaceRoot = '..';
+	const emptyFn = () => {};
+
+	it( 'should get all ckedtior5- dependencies and execute dev-install on them', () => {
+		const getDependenciesSpy = sinon.spy( tools, 'getCKEditorDependencies' );
+		const installSpy = sinon.spy();
+		const JSON = {
+			dependencies: {
+				'ckeditor5-core': 'ckeditor/ckeditor5-code',
+				'non-ckeditor-plugin': '^2.0.0',
+				'ckeditor5-plugin-devtest': 'ckeditor/ckeditor5-plugin-devtest'
+			}
+		};
+		const deps = JSON.dependencies;
+
+		initTask( installSpy, ckeditor5Path, JSON, workspaceRoot, emptyFn );
+
+		getDependenciesSpy.restore();
+
+		sinon.assert.calledOnce( getDependenciesSpy );
+		sinon.assert.calledWithExactly( getDependenciesSpy, deps );
+		sinon.assert.calledTwice( installSpy );
+		sinon.assert.calledWithExactly( installSpy.firstCall, ckeditor5Path, workspaceRoot, deps[ 'ckeditor5-core' ], emptyFn );
+		sinon.assert.calledWithExactly( installSpy.secondCall, ckeditor5Path, workspaceRoot, deps[ 'ckeditor5-plugin-devtest' ], emptyFn );
+	} );
+
+	it( 'should not call dev-install if no ckedtior5- dependencies', () => {
+		const getDependenciesSpy = sinon.spy( tools, 'getCKEditorDependencies' );
+		const installSpy = sinon.spy();
+		const JSON = {
+			dependencies: {}
+		};
+
+		initTask( installSpy, ckeditor5Path, JSON, workspaceRoot, emptyFn );
+
+		getDependenciesSpy.restore();
+
+		sinon.assert.calledOnce( getDependenciesSpy );
+		sinon.assert.calledWithExactly( getDependenciesSpy, JSON.dependencies );
+		sinon.assert.notCalled( installSpy );
+	} );
+} );

+ 179 - 0
dev/tests/dev-install.js

@@ -0,0 +1,179 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global describe, it, beforeEach, afterEach */
+
+'use strict';
+
+const chai = require( 'chai' );
+const sinon = require( 'sinon' );
+const git = require( '../tasks/utils/git' );
+const tools = require( '../tasks/utils/tools' );
+const installTask = require( '../tasks/utils/dev-install' );
+const expect = chai.expect;
+const path = require( 'path' );
+
+describe( 'dev-install', () => {
+	const moduleName = 'ckeditor5-core';
+	const repositoryUrl = 'git@github.com:ckeditor/ckeditor5-core';
+	const ckeditor5Path = '/path/to/ckeditor';
+	const workspacePath = '..';
+	const workspaceAbsolutePath = path.join( ckeditor5Path, workspacePath );
+	const spies = {};
+
+	beforeEach( () => {
+		spies.parseUrl = sinon.spy( git, 'parseRepositoryUrl' );
+		spies.isDirectory = sinon.stub( tools, 'isDirectory' );
+		spies.cloneRepository = sinon.stub( git, 'cloneRepository' );
+		spies.linkDirectories = sinon.stub( tools, 'linkDirectories' );
+		spies.updateJSON = sinon.stub( tools, 'updateJSONFile' );
+		spies.npmInstall = sinon.stub( tools, 'npmInstall' );
+		spies.checkout = sinon.stub( git, 'checkout' );
+		spies.getGitUrlFromNpm = sinon.stub( tools, 'getGitUrlFromNpm' );
+		spies.readPackageName = sinon.stub( tools, 'readPackageName' );
+		spies.npmUninstall = sinon.stub( tools, 'npmUninstall' );
+		spies.installGitHooks = sinon.stub( tools, 'installGitHooks' );
+	} );
+
+	afterEach( () => {
+		Object.keys( spies ).forEach( ( spy ) => spies[ spy ].restore() );
+	} );
+
+	it( 'should use GitHub URL', () => {
+		spies.isDirectory.onFirstCall().returns( false );
+		spies.isDirectory.onSecondCall().returns( false );
+		spies.isDirectory.onThirdCall().returns( true );
+
+		installTask( ckeditor5Path, workspacePath, repositoryUrl, () => {} );
+
+		sinon.assert.calledThrice( spies.isDirectory );
+		sinon.assert.calledOnce( spies.parseUrl );
+		sinon.assert.calledWithExactly( spies.parseUrl, repositoryUrl );
+
+		const urlInfo = spies.parseUrl.firstCall.returnValue;
+		const repositoryPath = path.join( workspaceAbsolutePath, urlInfo.name );
+
+		sinon.assert.calledWithExactly( spies.isDirectory.secondCall, repositoryPath );
+		sinon.assert.calledOnce( spies.cloneRepository );
+		sinon.assert.calledWithExactly( spies.cloneRepository, urlInfo, workspaceAbsolutePath );
+		sinon.assert.calledOnce( spies.checkout );
+		sinon.assert.calledWithExactly( spies.checkout, repositoryPath, urlInfo.branch );
+
+		sinon.assert.calledOnce( spies.npmInstall );
+		sinon.assert.calledWithExactly( spies.npmInstall, repositoryPath );
+
+		const linkPath = path.join( ckeditor5Path, 'node_modules', urlInfo.name );
+
+		sinon.assert.calledOnce( spies.npmUninstall );
+		sinon.assert.calledWithExactly( spies.npmUninstall, ckeditor5Path, urlInfo.name );
+
+		sinon.assert.calledOnce( spies.linkDirectories );
+		sinon.assert.calledWithExactly( spies.linkDirectories, repositoryPath, linkPath );
+
+		const packageJsonPath = path.join( ckeditor5Path, 'package.json' );
+
+		sinon.assert.calledOnce( spies.updateJSON );
+		expect( spies.updateJSON.firstCall.args[ 0 ] ).to.equal( packageJsonPath );
+		const updateFn = spies.updateJSON.firstCall.args[ 1 ];
+		const json = updateFn( {} );
+		expect( json.dependencies ).to.be.a( 'object' );
+		expect( json.dependencies[ urlInfo.name ] ).to.equal( repositoryUrl );
+
+		sinon.assert.calledOnce( spies.installGitHooks );
+		sinon.assert.calledWithExactly( spies.installGitHooks, repositoryPath );
+	} );
+
+	it( 'should use npm module name', () => {
+		spies.isDirectory.onFirstCall().returns( false );
+		spies.isDirectory.onSecondCall().returns( true );
+		spies.getGitUrlFromNpm.returns( repositoryUrl );
+
+		installTask( ckeditor5Path, workspacePath, moduleName, () => {} );
+
+		sinon.assert.calledThrice( spies.isDirectory );
+		sinon.assert.calledTwice( spies.parseUrl );
+		sinon.assert.calledWithExactly( spies.parseUrl.firstCall, moduleName );
+		expect( spies.parseUrl.firstCall.returnValue ).to.equal( null );
+		sinon.assert.calledOnce( spies.getGitUrlFromNpm );
+		sinon.assert.calledWithExactly( spies.getGitUrlFromNpm, moduleName );
+		sinon.assert.calledWithExactly( spies.parseUrl.secondCall, repositoryUrl );
+		const urlInfo = spies.parseUrl.secondCall.returnValue;
+		const repositoryPath = path.join( workspaceAbsolutePath, urlInfo.name );
+
+		sinon.assert.calledWithExactly( spies.isDirectory.secondCall, repositoryPath );
+		sinon.assert.notCalled( spies.cloneRepository );
+		sinon.assert.calledOnce( spies.checkout );
+		sinon.assert.calledWithExactly( spies.checkout, repositoryPath, urlInfo.branch );
+
+		sinon.assert.calledOnce( spies.npmInstall );
+		sinon.assert.calledWithExactly( spies.npmInstall, repositoryPath );
+
+		const linkPath = path.join( ckeditor5Path, 'node_modules', urlInfo.name );
+
+		sinon.assert.calledOnce( spies.linkDirectories );
+		sinon.assert.calledWithExactly( spies.linkDirectories, repositoryPath, linkPath );
+
+		const packageJsonPath = path.join( ckeditor5Path, 'package.json' );
+
+		sinon.assert.calledOnce( spies.updateJSON );
+		expect( spies.updateJSON.firstCall.args[ 0 ] ).to.equal( packageJsonPath );
+		const updateFn = spies.updateJSON.firstCall.args[ 1 ];
+		const json = updateFn( {} );
+		expect( json.dependencies ).to.be.a( 'object' );
+		expect( json.dependencies[ urlInfo.name ] ).to.equal( repositoryUrl );
+
+		sinon.assert.calledOnce( spies.installGitHooks );
+		sinon.assert.calledWithExactly( spies.installGitHooks, repositoryPath );
+	} );
+
+	it( 'should use local relative path', () => {
+		spies.isDirectory.onFirstCall().returns( true );
+		spies.isDirectory.onSecondCall().returns( true );
+		spies.readPackageName.returns( moduleName );
+
+		installTask( ckeditor5Path, workspacePath, '../ckeditor5-core', () => {} );
+
+		sinon.assert.calledThrice( spies.isDirectory );
+		sinon.assert.calledOnce( spies.readPackageName );
+	} );
+
+	it( 'should use local absolute path if provided', () => {
+		spies.isDirectory.onFirstCall().returns( true );
+		spies.isDirectory.onSecondCall().returns( true );
+		spies.readPackageName.returns( moduleName );
+
+		installTask( ckeditor5Path, workspacePath, '/ckeditor5-core', () => {} );
+
+		sinon.assert.calledThrice( spies.isDirectory );
+		sinon.assert.calledOnce( spies.readPackageName );
+	} );
+
+	it( 'should throw an exception when invalid name is provided', () => {
+		spies.isDirectory.onFirstCall().returns( false );
+		spies.isDirectory.onSecondCall().returns( true );
+
+		expect( () => {
+			installTask( ckeditor5Path, workspacePath, moduleName, () => {} );
+		} ).to.throw();
+
+		sinon.assert.calledOnce( spies.parseUrl );
+		sinon.assert.calledWithExactly( spies.parseUrl.firstCall, moduleName );
+		expect( spies.parseUrl.firstCall.returnValue ).to.equal( null );
+	} );
+
+	it( 'should throw an exception when package.json is not present', () => {
+		spies.isDirectory.onFirstCall().returns( true );
+		spies.isDirectory.onSecondCall().returns( true );
+		spies.readPackageName.returns( null );
+
+		expect( () => {
+			installTask( ckeditor5Path, workspacePath, moduleName, () => {} );
+		} ).to.throw();
+
+		sinon.assert.calledOnce( spies.parseUrl );
+		sinon.assert.calledWithExactly( spies.parseUrl.firstCall, moduleName );
+		expect( spies.parseUrl.firstCall.returnValue ).to.equal( null );
+	} );
+} );

+ 91 - 0
dev/tests/dev-plugin-create.js

@@ -0,0 +1,91 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global describe, it, beforeEach, afterEach */
+
+'use strict';
+
+const chai = require( 'chai' );
+const sinon = require( 'sinon' );
+const expect = chai.expect;
+const tools = require( '../tasks/utils/tools' );
+const inquiries = require( '../tasks/utils/inquiries' );
+const git = require( '../tasks/utils/git' );
+const path = require( 'path' );
+const emptyFn = () => { };
+let spies;
+
+describe( 'dev-tasks', () => {
+	const mainRepositoryPath = '/path/to/repository';
+	const workspaceRoot = '..';
+	const workspacePath = path.join( mainRepositoryPath, workspaceRoot );
+	const pluginName = 'plugin-name';
+	const pluginVersion = '0.0.1';
+	const gitHubUrl = 'ckeditor5/plugin-name';
+
+	beforeEach( () => createSpies() );
+	afterEach( () => restoreSpies() );
+
+	function createSpies() {
+		spies = {
+			linkDirectories: sinon.stub( tools, 'linkDirectories' ),
+			npmInstall: sinon.stub( tools, 'npmInstall' ),
+			installGitHooks: sinon.stub( tools, 'installGitHooks' ),
+			getPluginName: sinon.stub( inquiries, 'getPluginName' ).returns( new Promise( ( r ) => r( pluginName ) ) ),
+			getPluginVersion: sinon.stub( inquiries, 'getPluginVersion' ).returns( new Promise( ( r ) => r( pluginVersion ) ) ),
+			getPluginGitHubUrl: sinon.stub( inquiries, 'getPluginGitHubUrl' ).returns( new Promise( ( r ) => r( gitHubUrl ) ) ),
+			initializeRepository: sinon.stub( git, 'initializeRepository' ),
+			updateJSONFile: sinon.stub( tools, 'updateJSONFile' ),
+			copyTemplateFiles: sinon.stub( tools, 'copyTemplateFiles' ),
+			initialCommit: sinon.stub( git, 'initialCommit' )
+		};
+	}
+
+	function restoreSpies() {
+		for ( let spy in spies ) {
+			spies[ spy ].restore();
+		}
+	}
+
+	describe( 'dev-plugin-create', () => {
+		const pluginCreateTask = require( '../tasks/utils/dev-plugin-create' );
+		const repositoryPath = path.join( workspacePath, pluginName );
+
+		it( 'should exist', () => expect( pluginCreateTask ).to.be.a( 'function' ) );
+
+		it( 'should create a plugin', () => {
+			return pluginCreateTask( mainRepositoryPath, workspaceRoot, emptyFn ).then( () => {
+				expect( spies.getPluginName.calledOnce ).to.equal( true );
+				expect( spies.getPluginVersion.calledOnce ).to.equal( true );
+				expect( spies.getPluginGitHubUrl.calledOnce ).to.equal( true );
+				expect( spies.initializeRepository.calledOnce ).to.equal( true );
+				expect( spies.initializeRepository.firstCall.args[ 0 ] ).to.equal( repositoryPath );
+				expect( spies.copyTemplateFiles.calledOnce ).to.equal( true );
+				expect( spies.copyTemplateFiles.firstCall.args[ 0 ] ).to.equal( repositoryPath );
+				expect( spies.updateJSONFile.calledTwice ).to.equal( true );
+				expect( spies.updateJSONFile.firstCall.args[ 0 ] ).to.equal( path.join( repositoryPath, 'package.json' ) );
+				let updateFn = spies.updateJSONFile.firstCall.args[ 1 ];
+				let json = updateFn( {} );
+				expect( json.name ).to.equal( pluginName );
+				expect( json.version ).to.equal( pluginVersion );
+				expect( spies.updateJSONFile.secondCall.args[ 0 ] ).to.equal( path.join( mainRepositoryPath, 'package.json' ) );
+				updateFn = spies.updateJSONFile.secondCall.args[ 1 ];
+				json = updateFn( {} );
+				expect( json.dependencies ).to.be.an( 'object' );
+				expect( json.dependencies[ pluginName ] ).to.equal( gitHubUrl );
+				expect( spies.initialCommit.calledOnce ).to.equal( true );
+				expect( spies.initialCommit.firstCall.args[ 0 ] ).to.equal( pluginName );
+				expect( spies.initialCommit.firstCall.args[ 1 ] ).to.equal( repositoryPath );
+				expect( spies.linkDirectories.calledOnce ).to.equal( true );
+				expect( spies.linkDirectories.firstCall.args[ 0 ] ).to.equal( repositoryPath );
+				expect( spies.linkDirectories.firstCall.args[ 1 ] ).to.equal( path.join( mainRepositoryPath, 'node_modules', pluginName ) );
+				expect( spies.npmInstall.calledOnce ).to.equal( true );
+				expect( spies.npmInstall.firstCall.args[ 0 ] ).to.equal( repositoryPath );
+				expect( spies.installGitHooks.calledOnce ).to.equal( true );
+				expect( spies.installGitHooks.firstCall.args[ 0 ] ).to.equal( repositoryPath );
+			} );
+		} );
+	} );
+} );

+ 110 - 0
dev/tests/dev-relink.js

@@ -0,0 +1,110 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global describe, it */
+
+'use strict';
+
+const sinon = require( 'sinon' );
+const tools = require( '../tasks/utils/tools' );
+const path = require( 'path' );
+
+describe( 'dev-relink', () => {
+	const task = require( '../tasks/utils/dev-relink' );
+	const ckeditor5Path = 'path/to/ckeditor5';
+	const modulesPath = path.join( ckeditor5Path, 'node_modules' );
+	const workspaceRoot = '..';
+	const workspaceAbsolutePath = path.join( ckeditor5Path, workspaceRoot );
+	const emptyFn = () => {};
+
+	it( 'should link dev repositories', () => {
+		const dirs = [ 'ckeditor5-core', 'ckeditor5-devtest' ];
+		const getDependenciesSpy = sinon.spy( tools, 'getCKEditorDependencies' );
+		const getDirectoriesStub = sinon.stub( tools, 'getCKE5Directories' ).returns( dirs );
+		const linkStub = sinon.stub( tools, 'linkDirectories' );
+		const json = {
+			dependencies: {
+				'ckeditor5-core': 'ckeditor/ckeditor5-core',
+				'ckeditor5-devtest': 'ckeditor/ckeditor5-devtest#new-branch',
+				'other-plugin': '1.2.3'
+			}
+		};
+
+		task( ckeditor5Path, json, workspaceRoot, emptyFn, emptyFn );
+
+		getDependenciesSpy.restore();
+		getDirectoriesStub.restore();
+		linkStub.restore();
+
+		sinon.assert.calledTwice( linkStub );
+		sinon.assert.calledWithExactly( linkStub.firstCall, path.join( workspaceAbsolutePath, dirs[ 0 ] ), path.join( modulesPath, dirs[ 0 ] ) );
+		sinon.assert.calledWithExactly( linkStub.secondCall, path.join( workspaceAbsolutePath, dirs[ 1 ] ), path.join( modulesPath, dirs[ 1 ] ) );
+	} );
+
+	it( 'should not link when no dependencies are found', () => {
+		const getDependenciesSpy = sinon.spy( tools, 'getCKEditorDependencies' );
+		const getDirectoriesStub = sinon.stub( tools, 'getCKE5Directories' );
+		const linkStub = sinon.stub( tools, 'linkDirectories' );
+		const json = {
+			dependencies: {
+				'other-plugin': '1.2.3'
+			}
+		};
+
+		task( ckeditor5Path, json, workspaceRoot, emptyFn, emptyFn );
+
+		getDependenciesSpy.restore();
+		getDirectoriesStub.restore();
+		linkStub.restore();
+
+		sinon.assert.notCalled( linkStub );
+	} );
+
+	it( 'should not link when no plugins in dev mode', () => {
+		const getDependenciesSpy = sinon.spy( tools, 'getCKEditorDependencies' );
+		const getDirectoriesStub = sinon.stub( tools, 'getCKE5Directories' ).returns( [] );
+		const linkStub = sinon.stub( tools, 'linkDirectories' );
+		const json = {
+			dependencies: {
+				'ckeditor5-devtest': 'ckeditor/ckeditor5-devtest#new-branch',
+				'other-plugin': '1.2.3'
+			}
+		};
+
+		task( ckeditor5Path, json, workspaceRoot, emptyFn, emptyFn );
+
+		getDependenciesSpy.restore();
+		getDirectoriesStub.restore();
+		linkStub.restore();
+
+		sinon.assert.notCalled( linkStub );
+	} );
+
+	it( 'should write error message when linking is unsuccessful', () => {
+		const dirs = [ 'ckeditor5-core' ];
+		const getDependenciesSpy = sinon.spy( tools, 'getCKEditorDependencies' );
+		const getDirectoriesStub = sinon.stub( tools, 'getCKE5Directories' ).returns( dirs );
+		const error = new Error( 'Error message.' );
+		const linkStub = sinon.stub( tools, 'linkDirectories' ).throws( error );
+		const json = {
+			dependencies: {
+				'ckeditor5-core': 'ckeditor/ckeditor5-core',
+				'ckeditor5-devtest': 'ckeditor/ckeditor5-devtest#new-branch',
+				'other-plugin': '1.2.3'
+			}
+		};
+		const writeErrorSpy = sinon.spy();
+
+		task( ckeditor5Path, json, workspaceRoot, emptyFn, writeErrorSpy );
+
+		getDependenciesSpy.restore();
+		getDirectoriesStub.restore();
+		linkStub.restore();
+
+		sinon.assert.calledOnce( linkStub );
+		sinon.assert.calledOnce( writeErrorSpy );
+		sinon.assert.calledWithExactly( writeErrorSpy, error );
+	} );
+} );

+ 110 - 0
dev/tests/dev-status.js

@@ -0,0 +1,110 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global describe, it */
+
+'use strict';
+
+const sinon = require( 'sinon' );
+const tools = require( '../tasks/utils/tools' );
+const git = require( '../tasks/utils/git' );
+const path = require( 'path' );
+
+describe( 'dev-status', () => {
+	const statusTask = require( '../tasks/utils/dev-status' );
+	const ckeditor5Path = 'path/to/ckeditor5';
+	const workspaceRoot = '..';
+	const workspaceAbsolutePath = path.join( ckeditor5Path, workspaceRoot );
+	const emptyFn = () => {};
+
+	it( 'should show status of dev repositories', () => {
+		const dirs = [ 'ckeditor5-core', 'ckeditor5-devtest' ];
+		const getDependenciesSpy = sinon.spy( tools, 'getCKEditorDependencies' );
+		const getDirectoriesStub = sinon.stub( tools, 'getCKE5Directories' ).returns( dirs );
+		const statusStub = sinon.stub( git, 'getStatus' );
+		const json = {
+			dependencies: {
+				'ckeditor5-core': 'ckeditor/ckeditor5-core',
+				'ckeditor5-devtest': 'ckeditor/ckeditor5-devtest#new-branch',
+				'other-plugin': '1.2.3'
+			}
+		};
+
+		statusTask( ckeditor5Path, json, workspaceRoot, emptyFn, emptyFn );
+
+		getDependenciesSpy.restore();
+		getDirectoriesStub.restore();
+		statusStub.restore();
+
+		sinon.assert.calledTwice( statusStub );
+		sinon.assert.calledWithExactly( statusStub.firstCall, path.join( workspaceAbsolutePath, dirs[ 0 ] ) );
+		sinon.assert.calledWithExactly( statusStub.secondCall, path.join( workspaceAbsolutePath, dirs[ 1 ] ) );
+	} );
+
+	it( 'should not get status when no dependencies are found', () => {
+		const getDependenciesSpy = sinon.spy( tools, 'getCKEditorDependencies' );
+		const getDirectoriesStub = sinon.stub( tools, 'getCKE5Directories' );
+		const statusStub = sinon.stub( git, 'getStatus' );
+		const json = {
+			dependencies: {
+				'other-plugin': '1.2.3'
+			}
+		};
+
+		statusTask( ckeditor5Path, json, workspaceRoot, emptyFn, emptyFn );
+
+		getDependenciesSpy.restore();
+		getDirectoriesStub.restore();
+		statusStub.restore();
+
+		sinon.assert.notCalled( statusStub );
+	} );
+
+	it( 'should not get status when no plugins in dev mode', () => {
+		const getDependenciesSpy = sinon.spy( tools, 'getCKEditorDependencies' );
+		const getDirectoriesStub = sinon.stub( tools, 'getCKE5Directories' ).returns( [] );
+		const statusStub = sinon.stub( git, 'getStatus' );
+		const json = {
+			dependencies: {
+				'ckeditor5-devtest': 'ckeditor/ckeditor5-devtest#new-branch',
+				'other-plugin': '1.2.3'
+			}
+		};
+
+		statusTask( ckeditor5Path, json, workspaceRoot, emptyFn, emptyFn );
+
+		getDependenciesSpy.restore();
+		getDirectoriesStub.restore();
+		statusStub.restore();
+
+		sinon.assert.notCalled( statusStub );
+	} );
+
+	it( 'should write error message when getStatus is unsuccessful', () => {
+		const dirs = [ 'ckeditor5-core' ];
+		const getDependenciesSpy = sinon.spy( tools, 'getCKEditorDependencies' );
+		const getDirectoriesStub = sinon.stub( tools, 'getCKE5Directories' ).returns( dirs );
+		const error = new Error( 'Error message.' );
+		const statusStub = sinon.stub( git, 'getStatus' ).throws( error );
+		const json = {
+			dependencies: {
+				'ckeditor5-core': 'ckeditor/ckeditor5-core',
+				'ckeditor5-devtest': 'ckeditor/ckeditor5-devtest#new-branch',
+				'other-plugin': '1.2.3'
+			}
+		};
+		const writeErrorSpy = sinon.spy();
+
+		statusTask( ckeditor5Path, json, workspaceRoot, emptyFn, writeErrorSpy );
+
+		getDependenciesSpy.restore();
+		getDirectoriesStub.restore();
+		statusStub.restore();
+
+		sinon.assert.calledOnce( statusStub );
+		sinon.assert.calledOnce( writeErrorSpy );
+		sinon.assert.calledWithExactly( writeErrorSpy, error );
+	} );
+} );

+ 0 - 322
dev/tests/dev-tasks.js

@@ -1,322 +0,0 @@
-/**
- * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/* global describe, it, beforeEach, afterEach */
-
-'use strict';
-
-const chai = require( 'chai' );
-const sinon = require( 'sinon' );
-const expect = chai.expect;
-const tools = require( '../tasks/utils/tools' );
-const inquiries = require( '../tasks/utils/inquiries' );
-const git = require( '../tasks/utils/git' );
-const path = require( 'path' );
-const emptyFn = () => { };
-let spies;
-
-describe( 'dev-tasks', () => {
-	const mainRepositoryPath = '/path/to/repository';
-	const workspaceRoot = '..';
-	const workspacePath = path.join( mainRepositoryPath, workspaceRoot );
-	const pluginName = 'plugin-name';
-	const repositoryPath = path.join( workspacePath, pluginName );
-	const pluginVersion = '0.0.1';
-	const gitHubUrl = 'ckeditor5/plugin-name';
-
-	beforeEach( () => createSpies() );
-	afterEach( () => restoreSpies() );
-
-	function createSpies() {
-		spies = {
-			getDependencies: sinon.spy( tools, 'getCKEditorDependencies' ),
-			getDirectories: sinon.stub( tools, 'getCKE5Directories', () => [] ),
-			parseRepositoryUrl: sinon.spy( git, 'parseRepositoryUrl' ),
-			cloneRepository: sinon.stub( git, 'cloneRepository' ),
-			linkDirectories: sinon.stub( tools, 'linkDirectories' ),
-			pull: sinon.stub( git, 'pull' ),
-			checkout: sinon.stub( git, 'checkout' ),
-			npmInstall: sinon.stub( tools, 'npmInstall' ),
-			installGitHooks: sinon.stub( tools, 'installGitHooks' ),
-			getPluginName: sinon.stub( inquiries, 'getPluginName' ).returns( new Promise( ( r ) => r( pluginName ) ) ),
-			getPluginVersion: sinon.stub( inquiries, 'getPluginVersion' ).returns( new Promise( ( r ) => r( pluginVersion ) ) ),
-			getPluginGitHubUrl: sinon.stub( inquiries, 'getPluginGitHubUrl' ).returns( new Promise( ( r ) => r( gitHubUrl ) ) ),
-			initializeRepository: sinon.stub( git, 'initializeRepository' ),
-			updateJSONFile: sinon.stub( tools, 'updateJSONFile' ),
-			getStatus: sinon.stub( git, 'getStatus' ),
-			updateBoilerplate: sinon.stub( git, 'updateBoilerplate' ),
-			copyTemplateFiles: sinon.stub( tools, 'copyTemplateFiles' ),
-			initialCommit: sinon.stub( git, 'initialCommit' )
-		};
-	}
-
-	function restoreSpies() {
-		for ( let spy in spies ) {
-			spies[ spy ].restore();
-		}
-	}
-
-	describe( 'dev-init', () => {
-		const initTask = require( '../tasks/utils/dev-init' );
-
-		it( 'task should exist', () => expect( initTask ).to.be.a( 'function' ) );
-
-		it( 'performs no action when no ckeditor dependencies are found', () => {
-			const packageJSON = {
-				dependencies: {
-					'non-ckeditor-plugin': 'other/plugin'
-				}
-			};
-
-			initTask( mainRepositoryPath, packageJSON, workspaceRoot, emptyFn, emptyFn );
-			expect( spies.getDependencies.calledOnce ).to.equal( true );
-			expect( spies.getDependencies.firstCall.args[ 0 ] ).to.equal( packageJSON.dependencies );
-			expect( spies.getDirectories.called ).to.equal( false );
-			expect( spies.parseRepositoryUrl.called ).to.equal( false );
-			expect( spies.cloneRepository.called ).to.equal( false );
-			expect( spies.checkout.called ).to.equal( false );
-			expect( spies.pull.called ).to.equal( false );
-			expect( spies.npmInstall.called ).to.equal( false );
-			expect( spies.installGitHooks.called ).to.equal( false );
-		} );
-
-		it( 'clones repositories if no directories are found', () => {
-			const packageJSON = {
-				dependencies: {
-					'ckeditor5-core': 'ckeditor/ckeditor5-core',
-					'ckeditor5-plugin-devtest': 'ckeditor/ckeditor5-plugin-devtest',
-					'non-ckeditor-plugin': 'other/plugin'
-				}
-			};
-
-			initTask( mainRepositoryPath, packageJSON, workspaceRoot, emptyFn, emptyFn );
-			expect( spies.getDependencies.calledOnce ).to.equal( true );
-			expect( spies.getDependencies.firstCall.args[ 0 ] ).to.equal( packageJSON.dependencies );
-			expect( spies.getDirectories.calledOnce ).to.equal( true );
-			expect( spies.getDirectories.firstCall.args[ 0 ] ).to.equal( path.join( mainRepositoryPath, workspaceRoot ) );
-			expect( spies.parseRepositoryUrl.calledTwice ).to.equal( true );
-			expect( spies.cloneRepository.calledTwice ).to.equal( true );
-			expect( spies.cloneRepository.firstCall.args[ 0 ] ).to.equal( spies.parseRepositoryUrl.firstCall.returnValue );
-			expect( spies.cloneRepository.firstCall.args[ 1 ] ).to.equal( workspacePath );
-			expect( spies.cloneRepository.secondCall.args[ 0 ] ).to.equal( spies.parseRepositoryUrl.secondCall.returnValue );
-			expect( spies.cloneRepository.secondCall.args[ 1 ] ).to.equal( workspacePath );
-			expect( spies.checkout.calledTwice ).to.equal( true );
-			expect( spies.pull.calledTwice ).to.equal( true );
-			expect( spies.linkDirectories.calledTwice ).to.equal( true );
-			expect( spies.npmInstall.calledTwice ).to.equal( true );
-			expect( spies.installGitHooks.calledTwice ).to.equal( true );
-		} );
-
-		it( 'only checks out repositories if directories are found', () => {
-			const packageJSON = {
-				dependencies: {
-					'ckeditor5-core': 'ckeditor/ckeditor5-core',
-					'ckeditor5-plugin-devtest': 'ckeditor/ckeditor5-plugin-devtest',
-					'non-ckeditor-plugin': 'other/plugin'
-				}
-			};
-
-			spies.getDirectories.restore();
-			spies.getDirectories = sinon.stub( tools, 'getCKE5Directories', () => [ 'ckeditor5-core', 'ckeditor5-plugin-devtest' ] );
-
-			initTask( mainRepositoryPath, packageJSON, workspaceRoot, emptyFn, emptyFn );
-			expect( spies.getDependencies.calledOnce ).to.equal( true );
-			expect( spies.getDependencies.firstCall.args[ 0 ] ).to.equal( packageJSON.dependencies );
-			expect( spies.getDirectories.calledOnce ).to.equal( true );
-			expect( spies.getDirectories.firstCall.args[ 0 ] ).to.equal( path.join( mainRepositoryPath, workspaceRoot ) );
-			expect( spies.parseRepositoryUrl.calledTwice ).to.equal( true );
-			expect( spies.cloneRepository.called ).to.equal( false );
-			expect( spies.checkout.calledTwice ).to.equal( true );
-			expect( spies.pull.calledTwice ).to.equal( true );
-			expect( spies.linkDirectories.calledTwice ).to.equal( true );
-			expect( spies.npmInstall.calledTwice ).to.equal( true );
-			expect( spies.installGitHooks.calledTwice ).to.equal( true );
-		} );
-	} );
-
-	describe( 'dev-plugin-create', () => {
-		const pluginCreateTask = require( '../tasks/utils/dev-plugin-create' );
-		const repositoryPath = path.join( workspacePath, pluginName );
-
-		it( 'should exist', () => expect( pluginCreateTask ).to.be.a( 'function' ) );
-
-		it( 'should create a plugin', () => {
-			return pluginCreateTask( mainRepositoryPath, workspaceRoot, emptyFn ).then( () => {
-				expect( spies.getPluginName.calledOnce ).to.equal( true );
-				expect( spies.getPluginVersion.calledOnce ).to.equal( true );
-				expect( spies.getPluginGitHubUrl.calledOnce ).to.equal( true );
-				expect( spies.initializeRepository.calledOnce ).to.equal( true );
-				expect( spies.initializeRepository.firstCall.args[ 0 ] ).to.equal( repositoryPath );
-				expect( spies.copyTemplateFiles.calledOnce ).to.equal( true );
-				expect( spies.copyTemplateFiles.firstCall.args[ 0 ] ).to.equal( repositoryPath );
-				expect( spies.updateJSONFile.calledTwice ).to.equal( true );
-				expect( spies.updateJSONFile.firstCall.args[ 0 ] ).to.equal( path.join( repositoryPath, 'package.json' ) );
-				expect( spies.updateJSONFile.secondCall.args[ 0 ] ).to.equal( path.join( mainRepositoryPath, 'package.json' ) );
-				expect( spies.initialCommit.calledOnce ).to.equal( true );
-				expect( spies.initialCommit.firstCall.args[ 0 ] ).to.equal( pluginName );
-				expect( spies.initialCommit.firstCall.args[ 1 ] ).to.equal( repositoryPath );
-				expect( spies.linkDirectories.calledOnce ).to.equal( true );
-				expect( spies.linkDirectories.firstCall.args[ 0 ] ).to.equal( repositoryPath );
-				expect( spies.linkDirectories.firstCall.args[ 1 ] ).to.equal( path.join( mainRepositoryPath, 'node_modules', pluginName ) );
-				expect( spies.npmInstall.calledOnce ).to.equal( true );
-				expect( spies.npmInstall.firstCall.args[ 0 ] ).to.equal( repositoryPath );
-				expect( spies.installGitHooks.calledOnce ).to.equal( true );
-				expect( spies.installGitHooks.firstCall.args[ 0 ] ).to.equal( repositoryPath );
-			} );
-		} );
-	} );
-
-	describe( 'dev-plugin-install', () => {
-		const pluginInstallTask = require( '../tasks/utils/dev-plugin-install' );
-
-		it( 'should exist', () => expect( pluginInstallTask ).to.be.a( 'function' ) );
-
-		it( 'should install a plugin', () => {
-			return pluginInstallTask( mainRepositoryPath, workspaceRoot, emptyFn ).then( () => {
-				expect( spies.getPluginName.calledOnce ).to.equal( true );
-				expect( spies.getPluginGitHubUrl.calledOnce ).to.equal( true );
-				expect( spies.parseRepositoryUrl.calledOnce ).to.equal( true );
-				const urlInfo = spies.parseRepositoryUrl.firstCall.returnValue;
-				expect( spies.parseRepositoryUrl.firstCall.args[ 0 ] ).to.equal( gitHubUrl );
-				expect( spies.cloneRepository.calledOnce ).to.equal( true );
-				expect( spies.cloneRepository.firstCall.args[ 0 ] ).to.equal( urlInfo );
-				expect( spies.cloneRepository.firstCall.args[ 1 ] ).to.equal( workspacePath );
-				expect( spies.checkout.calledOnce ).to.equal( true );
-				expect( spies.checkout.firstCall.args[ 0 ] ).to.equal( repositoryPath );
-				expect( spies.checkout.firstCall.args[ 1 ] ).to.equal( urlInfo.branch );
-				expect( spies.updateJSONFile.calledOnce ).to.equal( true );
-				expect( spies.updateJSONFile.firstCall.args[ 0 ] ).to.equal( path.join( mainRepositoryPath, 'package.json' ) );
-				expect( spies.linkDirectories.calledOnce ).to.equal( true );
-				expect( spies.linkDirectories.firstCall.args[ 0 ] ).to.equal( repositoryPath );
-				expect( spies.linkDirectories.firstCall.args[ 1 ] ).to.equal( path.join( mainRepositoryPath, 'node_modules', pluginName ) );
-				expect( spies.npmInstall.calledOnce ).to.equal( true );
-				expect( spies.npmInstall.firstCall.args[ 0 ] ).to.equal( repositoryPath );
-				expect( spies.installGitHooks.calledOnce ).to.equal( true );
-				expect( spies.installGitHooks.firstCall.args[ 0 ] ).to.equal( repositoryPath );
-			} );
-		} );
-	} );
-
-	describe( 'dev-relink', () => {
-		const devRelinkTask = require( '../tasks/utils/dev-relink' );
-
-		it( 'should exist', () => expect( devRelinkTask ).to.be.a( 'function' ) );
-
-		it( 'should relink repositories', () => {
-			const packageJSON = {
-				dependencies: {
-					'ckeditor5-core': 'ckeditor/ckeditor5-core',
-					'ckeditor5-plugin-devtest': 'ckeditor/ckeditor5-plugin-devtest',
-					'non-ckeditor-plugin': 'other/plugin'
-				}
-			};
-
-			spies.getDirectories.restore();
-			const dirs = [ 'ckeditor5-core', 'ckeditor5-plugin-devtest' ];
-			spies.getDirectories = sinon.stub( tools, 'getCKE5Directories', () => dirs );
-
-			devRelinkTask( mainRepositoryPath, packageJSON, workspaceRoot, emptyFn, emptyFn );
-
-			expect( spies.getDependencies.calledOnce ).to.equal( true );
-			expect( spies.getDependencies.firstCall.args[ 0 ] ).to.equal( packageJSON.dependencies );
-			expect( spies.linkDirectories.calledTwice ).to.equal( true );
-			expect( spies.linkDirectories.firstCall.args[ 0 ] ).to.equal( path.join( workspacePath, dirs[ 0 ] ) );
-			expect( spies.linkDirectories.firstCall.args[ 1 ] ).to.equal( path.join( mainRepositoryPath, 'node_modules', dirs[ 0 ] ) );
-			expect( spies.linkDirectories.secondCall.args[ 0 ] ).to.equal( path.join( workspacePath, dirs[ 1 ] ) );
-			expect( spies.linkDirectories.secondCall.args[ 1 ] ).to.equal( path.join( mainRepositoryPath, 'node_modules', dirs[ 1 ] ) );
-		} );
-	} );
-
-	describe( 'dev-status', () => {
-		const devStatusTask = require( '../tasks/utils/dev-status' );
-
-		it( 'should exist', () => expect( devStatusTask ).to.be.a( 'function' ) );
-
-		it( 'should show repositories status', () => {
-			const packageJSON = {
-				dependencies: {
-					'ckeditor5-core': 'ckeditor/ckeditor5-core',
-					'ckeditor5-plugin-devtest': 'ckeditor/ckeditor5-plugin-devtest',
-					'non-ckeditor-plugin': 'other/plugin'
-				}
-			};
-
-			const dirs = [ 'ckeditor5-core', 'ckeditor5-plugin-devtest' ];
-			spies.getDirectories.restore();
-			spies.getDirectories = sinon.stub( tools, 'getCKE5Directories', () => dirs );
-
-			devStatusTask( mainRepositoryPath, packageJSON, workspaceRoot, emptyFn, emptyFn );
-
-			expect( spies.getDependencies.calledOnce ).to.equal( true );
-			expect( spies.getDependencies.firstCall.args[ 0 ] ).to.equal( packageJSON.dependencies );
-			expect( spies.getStatus.calledTwice ).to.equal( true );
-			expect( spies.getStatus.firstCall.args[ 0 ] ).to.equal( path.join( workspacePath, dirs[ 0 ] ) );
-			expect( spies.getStatus.secondCall.args[ 0 ] ).to.equal( path.join( workspacePath, dirs[ 1 ] ) );
-		} );
-	} );
-
-	describe( 'dev-update', () => {
-		const devUpdateTask = require( '../tasks/utils/dev-update' );
-
-		it( 'should exist', () => expect( devUpdateTask ).to.be.a( 'function' ) );
-
-		it( 'should show repositories status', () => {
-			const packageJSON = {
-				dependencies: {
-					'ckeditor5-core': 'ckeditor/ckeditor5-core',
-					'ckeditor5-plugin-devtest': 'ckeditor/ckeditor5-plugin-devtest',
-					'non-ckeditor-plugin': 'other/plugin'
-				}
-			};
-
-			const dirs = [ 'ckeditor5-core', 'ckeditor5-plugin-devtest' ];
-			spies.getDirectories.restore();
-			spies.getDirectories = sinon.stub( tools, 'getCKE5Directories', () => dirs );
-
-			devUpdateTask( mainRepositoryPath, packageJSON, workspaceRoot, emptyFn, emptyFn );
-
-			expect( spies.getDependencies.calledOnce ).to.equal( true );
-			expect( spies.getDependencies.firstCall.args[ 0 ] ).to.equal( packageJSON.dependencies );
-			expect( spies.parseRepositoryUrl.calledTwice ).to.equal( true );
-			expect( spies.pull.calledTwice ).to.equal( true );
-
-			let urlInfo = spies.parseRepositoryUrl.firstCall.returnValue;
-			expect( spies.pull.firstCall.args[ 0 ] ).to.equal( path.join( workspacePath, dirs[ 0 ] ) );
-			expect( spies.pull.firstCall.args[ 1 ] ).to.equal( urlInfo.branch );
-
-			urlInfo = spies.parseRepositoryUrl.secondCall.returnValue;
-			expect( spies.pull.secondCall.args[ 0 ] ).to.equal( path.join( workspacePath, dirs[ 1 ] ) );
-			expect( spies.pull.secondCall.args[ 1 ] ).to.equal( urlInfo.branch );
-		} );
-	} );
-
-	describe( 'dev-boilerplate-update', () => {
-		const devBoilerplateTask = require( '../tasks/utils/dev-boilerplate-update' );
-
-		it( 'should exist', () => expect( devBoilerplateTask ).to.be.a( 'function' ) );
-
-		it( 'should update boilerplate in repositories', () => {
-			const packageJSON = {
-				dependencies: {
-					'ckeditor5-core': 'ckeditor/ckeditor5-core',
-					'ckeditor5-plugin-devtest': 'ckeditor/ckeditor5-plugin-devtest',
-					'non-ckeditor-plugin': 'other/plugin'
-				}
-			};
-
-			const dirs = [ 'ckeditor5-core', 'ckeditor5-plugin-devtest' ];
-			spies.getDirectories.restore();
-			spies.getDirectories = sinon.stub( tools, 'getCKE5Directories', () => dirs );
-
-			devBoilerplateTask( mainRepositoryPath, packageJSON, workspaceRoot, emptyFn, emptyFn );
-
-			expect( spies.getDependencies.calledOnce ).to.equal( true );
-			expect( spies.getDependencies.firstCall.args[ 0 ] ).to.equal( packageJSON.dependencies );
-			expect( spies.updateBoilerplate.calledTwice ).to.equal( true );
-			expect( spies.updateBoilerplate.firstCall.args[ 0 ] ).to.equal( path.join( workspacePath, dirs[ 0 ] ) );
-			expect( spies.updateBoilerplate.secondCall.args[ 0 ] ).to.equal( path.join( workspacePath, dirs[ 1 ] ) );
-		} );
-	} );
-} );

+ 103 - 0
dev/tests/dev-update.js

@@ -0,0 +1,103 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global describe, it, beforeEach, afterEach */
+
+'use strict';
+
+const sinon = require( 'sinon' );
+const tools = require( '../tasks/utils/tools' );
+const git = require( '../tasks/utils/git' );
+const path = require( 'path' );
+
+describe( 'dev-update', () => {
+	const updateTask = require( '../tasks/utils/dev-update' );
+	const ckeditor5Path = 'path/to/ckeditor5';
+	const workspaceRoot = '..';
+	const workspaceAbsolutePath = path.join( ckeditor5Path, workspaceRoot );
+	const emptyFn = () => {};
+	const spies = {};
+
+	beforeEach( () => {
+		spies.getDependencies = sinon.spy( tools, 'getCKEditorDependencies' );
+		spies.checkout = sinon.stub( git, 'checkout' );
+		spies.pull = sinon.stub( git, 'pull' );
+		spies.npmUpdate = sinon.stub( tools, 'npmUpdate' );
+	} );
+
+	afterEach( () => {
+		Object.keys( spies ).forEach( ( spy ) => spies[ spy ].restore() );
+	} );
+
+	it( 'should update dev repositories', () => {
+		const dirs = [ 'ckeditor5-core', 'ckeditor5-devtest' ];
+		spies.getDirectories = sinon.stub( tools, 'getCKE5Directories' ).returns( dirs );
+
+		const json = {
+			dependencies: {
+				'ckeditor5-core': 'ckeditor/ckeditor5-core',
+				'ckeditor5-devtest': 'ckeditor/ckeditor5-devtest#new-branch',
+				'other-plugin': '1.2.3'
+			}
+		};
+
+		updateTask( ckeditor5Path, json, workspaceRoot, emptyFn, true );
+
+		sinon.assert.calledTwice( spies.pull );
+		sinon.assert.calledWithExactly( spies.pull.firstCall, path.join( workspaceAbsolutePath, dirs[ 0 ] ), 'master' );
+		sinon.assert.calledWithExactly( spies.pull.secondCall, path.join( workspaceAbsolutePath, dirs[ 1 ] ), 'new-branch' );
+
+		sinon.assert.calledThrice( spies.npmUpdate );
+		sinon.assert.calledWithExactly( spies.npmUpdate.firstCall, path.join( workspaceAbsolutePath, dirs[ 0 ] ) );
+		sinon.assert.calledWithExactly( spies.npmUpdate.secondCall, path.join( workspaceAbsolutePath, dirs[ 1 ] ) );
+		sinon.assert.calledWithExactly( spies.npmUpdate.thirdCall, ckeditor5Path );
+	} );
+
+	it( 'should update dev repositories without running npm update', () => {
+		const dirs = [ 'ckeditor5-core' ];
+		spies.getDirectories = sinon.stub( tools, 'getCKE5Directories' ).returns( dirs );
+
+		const json = {
+			dependencies: {
+				'ckeditor5-core': 'ckeditor/ckeditor5-core',
+				'ckeditor5-devtest': 'ckeditor/ckeditor5-devtest#new-branch',
+				'other-plugin': '1.2.3'
+			}
+		};
+
+		updateTask( ckeditor5Path, json, workspaceRoot, emptyFn, false );
+		sinon.assert.calledWithExactly( spies.pull.firstCall, path.join( workspaceAbsolutePath, dirs[ 0 ] ), 'master' );
+		sinon.assert.notCalled( spies.npmUpdate );
+	} );
+
+	it( 'should not update when no dependencies are found', () => {
+		spies.getDirectories = sinon.stub( tools, 'getCKE5Directories' );
+		const json = {
+			dependencies: {
+				'other-plugin': '1.2.3'
+			}
+		};
+
+		updateTask( ckeditor5Path, json, workspaceRoot, emptyFn, false );
+
+		sinon.assert.notCalled( spies.pull );
+		sinon.assert.notCalled( spies.npmUpdate );
+	} );
+
+	it( 'should not update when no plugins in dev mode', () => {
+		spies.getDirectories = sinon.stub( tools, 'getCKE5Directories' ).returns( [] );
+		const json = {
+			dependencies: {
+				'ckeditor5-devtest': 'ckeditor/ckeditor5-devtest#new-branch',
+				'other-plugin': '1.2.3'
+			}
+		};
+
+		updateTask( ckeditor5Path, json, workspaceRoot, emptyFn, false );
+
+		sinon.assert.notCalled( spies.pull );
+		sinon.assert.notCalled( spies.npmUpdate );
+	} );
+} );

+ 48 - 14
dev/tests/git.js

@@ -29,64 +29,98 @@ describe( 'utils', () => {
 				const urlInfo = git.parseRepositoryUrl( 'ckeditor/ckeditor5-core' );
 
 				expect( urlInfo.server ).to.equal( 'https://github.com/' );
-				expect( urlInfo.branch ).to.equal( 'master' );
 				expect( urlInfo.repository ).to.equal( 'ckeditor/ckeditor5-core' );
+				expect( urlInfo.user ).to.equal( 'ckeditor' );
+				expect( urlInfo.name ).to.equal( 'ckeditor5-core' );
+				expect( urlInfo.branch ).to.equal( 'master' );
 			} );
 
 			it( 'should parse short GitHub URL with provided branch ', () => {
 				const urlInfo = git.parseRepositoryUrl( 'ckeditor/ckeditor5-core#experimental' );
 
 				expect( urlInfo.server ).to.equal( 'https://github.com/' );
-				expect( urlInfo.branch ).to.equal( 'experimental' );
 				expect( urlInfo.repository ).to.equal( 'ckeditor/ckeditor5-core' );
+				expect( urlInfo.user ).to.equal( 'ckeditor' );
+				expect( urlInfo.name ).to.equal( 'ckeditor5-core' );
+				expect( urlInfo.branch ).to.equal( 'experimental' );
 			} );
 
 			it( 'should parse full GitHub URL (http)', () => {
-				const urlInfo = git.parseRepositoryUrl( 'http://github.com/ckeditor/ckeditor5-core' );
+				const urlInfo = git.parseRepositoryUrl( 'http://github.com/ckeditor/ckeditor5-core.git' );
 
 				expect( urlInfo.server ).to.equal( 'http://github.com/' );
+				expect( urlInfo.repository ).to.equal( 'ckeditor/ckeditor5-core.git' );
+				expect( urlInfo.user ).to.equal( 'ckeditor' );
+				expect( urlInfo.name ).to.equal( 'ckeditor5-core' );
 				expect( urlInfo.branch ).to.equal( 'master' );
-				expect( urlInfo.repository ).to.equal( 'ckeditor/ckeditor5-core' );
 			} );
 
 			it( 'should parse full GitHub URL (http) with provided branch', () => {
-				const urlInfo = git.parseRepositoryUrl( 'http://github.com/ckeditor/ckeditor5-core#experimental' );
+				const urlInfo = git.parseRepositoryUrl( 'http://github.com/ckeditor/ckeditor5-core.git#experimental' );
 
 				expect( urlInfo.server ).to.equal( 'http://github.com/' );
+				expect( urlInfo.repository ).to.equal( 'ckeditor/ckeditor5-core.git' );
+				expect( urlInfo.user ).to.equal( 'ckeditor' );
+				expect( urlInfo.name ).to.equal( 'ckeditor5-core' );
 				expect( urlInfo.branch ).to.equal( 'experimental' );
-				expect( urlInfo.repository ).to.equal( 'ckeditor/ckeditor5-core' );
 			} );
 
 			it( 'should parse full GitHub URL (https)', () => {
-				const urlInfo = git.parseRepositoryUrl( 'https://github.com/ckeditor/ckeditor5-core' );
+				const urlInfo = git.parseRepositoryUrl( 'https://github.com/ckeditor/ckeditor5-core.git' );
 
 				expect( urlInfo.server ).to.equal( 'https://github.com/' );
+				expect( urlInfo.repository ).to.equal( 'ckeditor/ckeditor5-core.git' );
+				expect( urlInfo.user ).to.equal( 'ckeditor' );
+				expect( urlInfo.name ).to.equal( 'ckeditor5-core' );
 				expect( urlInfo.branch ).to.equal( 'master' );
-				expect( urlInfo.repository ).to.equal( 'ckeditor/ckeditor5-core' );
 			} );
 
 			it( 'should parse full GitHub URL (https) with provided branch', () => {
-				const urlInfo = git.parseRepositoryUrl( 'https://github.com/ckeditor/ckeditor5-core#t/122' );
+				const urlInfo = git.parseRepositoryUrl( 'https://github.com/ckeditor/ckeditor5-core.git#t/122' );
 
 				expect( urlInfo.server ).to.equal( 'https://github.com/' );
+				expect( urlInfo.repository ).to.equal( 'ckeditor/ckeditor5-core.git' );
+				expect( urlInfo.user ).to.equal( 'ckeditor' );
+				expect( urlInfo.name ).to.equal( 'ckeditor5-core' );
 				expect( urlInfo.branch ).to.equal( 't/122' );
-				expect( urlInfo.repository ).to.equal( 'ckeditor/ckeditor5-core' );
 			} );
 
 			it( 'should parse full GitHub URL (git)', () => {
-				const urlInfo = git.parseRepositoryUrl( 'git@github.com:ckeditor/ckeditor5-core' );
+				const urlInfo = git.parseRepositoryUrl( 'git@github.com:ckeditor/ckeditor5-core.git' );
 
 				expect( urlInfo.server ).to.equal( 'git@github.com:' );
+				expect( urlInfo.repository ).to.equal( 'ckeditor/ckeditor5-core.git' );
+				expect( urlInfo.user ).to.equal( 'ckeditor' );
+				expect( urlInfo.name ).to.equal( 'ckeditor5-core' );
+				expect( urlInfo.branch ).to.equal( 'master' );
+			} );
+
+			it( 'should parse full GitHub URL (git)', () => {
+				const urlInfo = git.parseRepositoryUrl( 'git://github.com/ckeditor/ckeditor5-core.git' );
+
+				expect( urlInfo.server ).to.equal( 'git://github.com/' );
+				expect( urlInfo.repository ).to.equal( 'ckeditor/ckeditor5-core.git' );
+				expect( urlInfo.user ).to.equal( 'ckeditor' );
+				expect( urlInfo.name ).to.equal( 'ckeditor5-core' );
 				expect( urlInfo.branch ).to.equal( 'master' );
-				expect( urlInfo.repository ).to.equal( 'ckeditor/ckeditor5-core' );
 			} );
 
 			it( 'should parse full GitHub URL (git) with provided branch', () => {
-				const urlInfo = git.parseRepositoryUrl( 'git@github.com:ckeditor/ckeditor5-core#new-feature' );
+				const urlInfo = git.parseRepositoryUrl( 'git@github.com:ckeditor/ckeditor5-core.git#new-feature' );
 
 				expect( urlInfo.server ).to.equal( 'git@github.com:' );
+				expect( urlInfo.repository ).to.equal( 'ckeditor/ckeditor5-core.git' );
+				expect( urlInfo.user ).to.equal( 'ckeditor' );
+				expect( urlInfo.name ).to.equal( 'ckeditor5-core' );
 				expect( urlInfo.branch ).to.equal( 'new-feature' );
-				expect( urlInfo.repository ).to.equal( 'ckeditor/ckeditor5-core' );
+			} );
+
+			it( 'should return null if GitHub URL is not valid', () => {
+				let urlInfo = git.parseRepositoryUrl( 'https://ckeditor.com' );
+				expect( urlInfo ).to.equal( null );
+
+				urlInfo = git.parseRepositoryUrl( 'https://github.com/info.html' );
+				expect( urlInfo ).to.equal( null );
 			} );
 		} );
 

+ 184 - 0
dev/tests/tools.js

@@ -23,6 +23,31 @@ describe( 'utils', () => {
 	} );
 
 	describe( 'tools', () => {
+		describe( 'shExec', () => {
+			it( 'should be defined', () => expect( tools.shExec ).to.be.a( 'function' ) );
+
+			it( 'should execute command', () => {
+				const sh = require( 'shelljs' );
+				const execStub = sinon.stub( sh, 'exec' ).returns( { code: 0 } );
+				toRestore.push( execStub );
+
+				tools.shExec( 'command' );
+
+				sinon.assert.calledOnce( execStub );
+			} );
+
+			it( 'should throw error on unsuccessful call', () => {
+				const sh = require( 'shelljs' );
+				const execStub = sinon.stub( sh, 'exec' ).returns( { code: 1 } );
+				toRestore.push( execStub );
+
+				expect( () => {
+					tools.shExec( 'command' );
+				} ).to.throw();
+				sinon.assert.calledOnce( execStub );
+			} );
+		} );
+
 		describe( 'linkDirectories', () => {
 			it( 'should be defined', () => expect( tools.linkDirectories ).to.be.a( 'function' ) );
 
@@ -68,6 +93,7 @@ describe( 'utils', () => {
 					'plugin3': ''
 				};
 				expect( tools.getCKEditorDependencies( dependencies ) ).to.equal( null );
+				expect( tools.getCKEditorDependencies() ).to.equal( null );
 			} );
 
 			it( 'should return only ckeditor5- dependencies', () => {
@@ -151,6 +177,49 @@ describe( 'utils', () => {
 			} );
 		} );
 
+		describe( 'isFile', () => {
+			it( 'should be defined', () => expect( tools.isFile ).to.be.a( 'function' ) );
+
+			it( 'should return true if path points to file', () => {
+				const fs = require( 'fs' );
+				const statSyncStub = sinon.stub( fs, 'statSync', () => ( { isFile: () => true } ) );
+				const path = 'path';
+				toRestore.push( statSyncStub );
+
+				const result = tools.isFile( path );
+
+				expect( statSyncStub.calledOnce ).to.equal( true );
+				expect( statSyncStub.firstCall.args[ 0 ] ).to.equal( path );
+				expect( result ).to.equal( true );
+			} );
+
+			it( 'should return false if path does not point to directory', () => {
+				const fs = require( 'fs' );
+				const statSyncStub = sinon.stub( fs, 'statSync', () => ( { isFile: () => false } ) );
+				const path = 'path';
+				toRestore.push( statSyncStub );
+
+				const result = tools.isFile( path );
+
+				expect( statSyncStub.calledOnce ).to.equal( true );
+				expect( statSyncStub.firstCall.args[ 0 ] ).to.equal( path );
+				expect( result ).to.equal( false );
+			} );
+
+			it( 'should return false if statSync method throws', () => {
+				const fs = require( 'fs' );
+				const statSyncStub = sinon.stub( fs, 'statSync' ).throws();
+				const path = 'path';
+				toRestore.push( statSyncStub );
+
+				const result = tools.isFile( path );
+
+				expect( statSyncStub.calledOnce ).to.equal( true );
+				expect( statSyncStub.firstCall.args[ 0 ] ).to.equal( path );
+				expect( result ).to.equal( false );
+			} );
+		} );
+
 		describe( 'getCKE5Directories', () => {
 			it( 'should be defined', () => expect( tools.getCKE5Directories ).to.be.a( 'function' ) );
 
@@ -189,6 +258,41 @@ describe( 'utils', () => {
 			} );
 		} );
 
+		describe( 'readPackageName', () => {
+			const modulePath = 'path/to/module';
+			it( 'should read package name from NPM module', () => {
+				const isFileStub = sinon.stub( tools, 'isFile' ).returns( true );
+				const fs = require( 'fs' );
+				const name = 'module-name';
+				const readFileStub = sinon.stub( fs, 'readFileSync' ).returns( JSON.stringify( { name: name } ) );
+				toRestore.push( isFileStub, readFileStub );
+
+				const result = tools.readPackageName( modulePath );
+
+				expect( result ).to.equal( name );
+			} );
+
+			it( 'should return null if no package.json is found', () => {
+				const isFileStub = sinon.stub( tools, 'isFile' ).returns( false );
+				toRestore.push( isFileStub );
+
+				const result = tools.readPackageName( modulePath );
+
+				expect( result ).to.equal( null );
+			} );
+
+			it( 'should return null if no name in package.json is provided', () => {
+				const isFileStub = sinon.stub( tools, 'isFile' ).returns( true );
+				const fs = require( 'fs' );
+				const readFileStub = sinon.stub( fs, 'readFileSync' ).returns( JSON.stringify( { } ) );
+				toRestore.push( isFileStub, readFileStub );
+
+				const result = tools.readPackageName( modulePath );
+
+				expect( result ).to.equal( null );
+			} );
+		} );
+
 		describe( 'npmInstall', () => {
 			it( 'should be defined', () => expect( tools.npmInstall ).to.be.a( 'function' ) );
 			it( 'should execute npm install command', () => {
@@ -203,6 +307,35 @@ describe( 'utils', () => {
 			} );
 		} );
 
+		describe( 'npmUpdate', () => {
+			it( 'should be defined', () => expect( tools.npmUpdate ).to.be.a( 'function' ) );
+			it( 'should execute npm update command', () => {
+				const shExecStub = sinon.stub( tools, 'shExec' );
+				const path = '/path/to/repository';
+				toRestore.push( shExecStub );
+
+				tools.npmUpdate( path );
+
+				expect( shExecStub.calledOnce ).to.equal( true );
+				expect( shExecStub.firstCall.args[ 0 ] ).to.equal( `cd ${ path } && npm update` );
+			} );
+		} );
+
+		describe( 'npmUninstall', () => {
+			it( 'should be defined', () => expect( tools.npmUninstall ).to.be.a( 'function' ) );
+			it( 'should execute npm uninstall command', () => {
+				const shExecStub = sinon.stub( tools, 'shExec' );
+				const path = '/path/to/repository';
+				const moduleName = 'module-name';
+				toRestore.push( shExecStub );
+
+				tools.npmUninstall( path, moduleName );
+
+				expect( shExecStub.calledOnce ).to.equal( true );
+				expect( shExecStub.firstCall.args[ 0 ] ).to.equal( `cd ${ path } && npm uninstall ${ moduleName }` );
+			} );
+		} );
+
 		describe( 'installGitHooks', () => {
 			it( 'should be defined', () => expect( tools.installGitHooks ).to.be.a( 'function' ) );
 			it( 'should execute grunt githooks command', () => {
@@ -233,5 +366,56 @@ describe( 'utils', () => {
 				expect( shExecStub.firstCall.args[ 0 ] ).to.equal( `cp ${ path.join( templatesPath, '*.md' ) } ${ repositoryPath }` );
 			} );
 		} );
+
+		describe( 'getGitUrlFromNpm', () => {
+			const repository = {
+				type: 'git',
+				url: 'git@github.com:ckeditor/ckeditor5-core'
+			};
+			const moduleName = 'ckeditor5-core';
+
+			it( 'should be defined', () => expect( tools.getGitUrlFromNpm ).to.be.a( 'function' ) );
+			it( 'should call npm view command', () => {
+				const shExecStub = sinon.stub( tools, 'shExec', () => {
+					return JSON.stringify( repository );
+				} );
+				toRestore.push( shExecStub );
+				const url = tools.getGitUrlFromNpm( moduleName );
+
+				expect( shExecStub.calledOnce ).to.equal( true );
+				expect( shExecStub.firstCall.args[ 0 ] ).to.equal( `npm view ${ moduleName } repository --json` );
+				expect( url ).to.equal( repository.url );
+			} );
+
+			it( 'should return null if module is not found', () => {
+				const shExecStub = sinon.stub( tools, 'shExec' ).throws( new Error( 'npm ERR! code E404' ) );
+				toRestore.push( shExecStub );
+
+				const url = tools.getGitUrlFromNpm( moduleName );
+				expect( url ).to.equal( null );
+			} );
+
+			it( 'should return null if module has no repository information', () => {
+				const shExecStub = sinon.stub( tools, 'shExec' ).returns( JSON.stringify( {} ) );
+				toRestore.push( shExecStub );
+
+				const url = tools.getGitUrlFromNpm( moduleName );
+				expect( url ).to.equal( null );
+			} );
+
+			it( 'should throw on other errors', () => {
+				const error = new Error( 'Random error.' );
+				const shExecStub = sinon.stub( tools, 'shExec' ).throws( error );
+				const getUrlSpy = sinon.spy( tools, 'getGitUrlFromNpm' );
+				toRestore.push( shExecStub );
+				toRestore.push( getUrlSpy );
+
+				try {
+					tools.getGitUrlFromNpm( moduleName );
+				} catch ( e ) {}
+
+				expect( getUrlSpy.threw( error ) ).to.equal( true );
+			} );
+		} );
 	} );
 } );

+ 4 - 2
package.json

@@ -28,6 +28,7 @@
     "grunt-jscs": "^2.0.0",
     "grunt-text-replace": "^0.4.0",
     "inquirer": "^0.11.0",
+    "istanbul": "^0.4.1",
     "mocha": "^2.2.5",
     "ncp": "^2.0.0",
     "replace": "^0.3.0",
@@ -43,6 +44,7 @@
     "url": "https://github.com/ckeditor/ckeditor5.git"
   },
   "scripts": {
-    "tests": "mocha dev/tests"
+    "test": "mocha dev/tests",
+    "coverage": "istanbul cover _mocha dev/tests/"
   }
-}
+}