src/cli/commands/Init.js
import { readdir, writeFile } from 'fs';
import { join } from 'path';
import { spawn } from 'child_process';
import which from 'which';
import { prompt } from 'inquirer';
import { satisfies as validVersion } from 'semver';
import Command from '../../lib/cli/Command';
import Logger from '../../lib/util/Logger';
import pkg from '../../../package.json';
import CliOptions from '../Options';
const IgnoredFiles = ['.ds_store', 'thumbs.db'];
/**
* Returns the default export of a module, if present.
* @param {any | { default: any }} mod The required module.
* @return {any} The module's default export.
*/
function defaultExport(mod) {
return mod.default || mod;
}
/**
* Utility that returns any non-function values and calls them with the given args otherwise.
* @param {function(...args: any[]): any | any} value The value to return or function to call.
* @param {...any} [args] The arguments to apply if value is a function.
* @return {any} The value or function call result.
*/
function allowFunction(value, ...args) {
if (typeof value === 'function') {
return value(...args);
}
return value;
}
/**
* The command invoked when running "init".
*/
export default class InitCommand extends Command {
/**
* Creates a new {@link InitCommand} with the specified name and description.
* @param {string} name The command's name.
* @param {string} description The command's description.
*/
constructor(name, description) {
super(name, description, {
options: {
yes: CliOptions.yes,
force: CliOptions.force,
beta: CliOptions.beta,
link: CliOptions.link,
},
});
}
/**
* Checks if the given path contains an empty directory. OS specific temporary files (*.DS_Store*
* under macOS, *thumbs* under Windows) are ignored.
* @param {string} path The path to check.
* @param {boolean} [overwrite=false] If existing files should be overwritten.
* @return {Promise<string, Error>} Fulfilled with the valid directory's path, rejected if `path`
* contains no or a non-empty directory.
*/
checkDirectory(path, overwrite = false) {
return new Promise((resolve, reject) => {
readdir(path, (err, files) => {
if (err) {
if (err.code === 'ENOENT') {
reject(new Error(`${Logger.format.path(path)} does not exist`));
} else if (err.code === 'ENOTDIR') {
reject(new Error(`${Logger.format.path(path)} is not a directory`));
} else {
reject(err);
}
} else if (files.filter((f) => !IgnoredFiles.includes(f.toLowerCase())).length > 0) {
const message = `${Logger.format.path(path)} is not empty`;
if (overwrite) {
Logger.warn(message);
Logger.warn(Logger.colors.yellow('Using --force, continue...'));
resolve(path);
} else {
reject(new Error(message));
}
} else {
resolve(path);
}
});
});
}
/**
* Creates a an empty *package* file at the given path.
* @param {string} path The location to create the package at.
* @return {Promise<undefined, Error>} Rejected if an error occurred while writing the file.
*/
createEmptyPackage(path) {
return new Promise((resolve, reject) => {
writeFile(join(path, 'package.json'), '{}', (err) => {
if (err) {
// FIXME: Call with SystemError class
reject(
Object.assign(err, {
message: `Unable to create package.json at ${path}`,
originalMessage: err.message,
})
);
} else {
resolve();
}
});
});
}
/**
* Runs npm with the given args.
* @param {string[]} args The arguments to call npm with.
* @param {Object} options Options applied to the spawn call.
*/
runNpm(args, options = {}) {
return new Promise((resolve, reject) => {
which('npm', (err, npm) => {
if (err) {
return reject(err);
}
const child = spawn(
npm,
args,
Object.assign({}, options, {
/* stdio: 'inherit' */
})
)
.on('error', (npmErr) => reject(npmErr))
.on('close', (code) => {
if (code > 0) {
reject(new Error(`npm ${args[0]} returned code ${code}`));
} else {
resolve();
}
});
Logger.pipeLastLine(child.stderr);
Logger.pipeLastLine(child.stdout);
return child;
});
});
}
/**
* Runs `npm install --save-dev {packages}` at the given path.
* @param {string} path The path to install packages at.
* @param {string|string[]} packages Names of the packages to install.
* @return {Promise<undefined, Error>} Rejected if installing failed, resolved otherwise.
*/
install(path, packages) {
return this.runNpm(['install', '--save-dev'].concat(packages), { cwd: path });
}
/**
* Installs the local atscm module at the given path.
* @param {string} path The path to install the module at.
* @param {Object} options The options to use.
* @param {boolean} [options.useBetaRelease=false] If beta versions should be used.
* @param {boolean} [options.link=false] Link instead of installing.
* @return {Promise<undefined, Error>} Rejected if installing failed, resolved otherwise.
*/
async installLocal(path, { beta: useBetaRelease = false, link = false } = {}) {
Logger.info('Installing latest version of atscm...');
if (useBetaRelease) {
Logger.debug(Logger.colors.gray('Using beta release'));
}
await this.install(path, useBetaRelease ? 'atscm@beta' : 'atscm');
if (link) {
Logger.info('Linking atscm...');
await this.runNpm(['link', 'atscm'], { cwd: path });
}
}
/**
* Checks the version of this package against the "engines > atscm-cli" field of the newly
* installed atscm module's package file.
* @param {Liftoff.Environment} env The environment to check.
* @return {Liftoff.Environment} The environment to check.
* @throws {Error} Throws an error if the atscm-cli version does not match.
*/
checkCliVersion(env) {
Logger.debug('Checking atscm-cli version...');
const required = env.modulePackage.engines['atscm-cli'];
if (!validVersion(pkg.version.split('-beta')[0], required)) {
Logger.info('Your version of atscm-cli is not compatible with the latest version atscm.');
Logger.info('Please run', Logger.format.command('npm install -g atscm-cli'), 'to update.');
throw new Error(`Invalid atscm-cli version: ${required} required.`);
}
return env;
}
/**
* Returns the default values for the given init options.
* @param {Object[]} options An array of init options to check.
*/
getDefaultOptions(options) {
return options.reduce((current, option) => {
if (option.when && !allowFunction(option.when, current)) {
return current;
}
let value;
if (option.default !== undefined) {
value = option.default;
} else if (option.choices) {
const [firstChoice] = allowFunction(option.choices, current);
value = firstChoice.value || firstChoice;
}
return Object.assign(current, {
[option.name]: value,
});
}, {});
}
/**
* Resolves the needed options from the local atscm module and asks for them. These options are
* stored in the `atscm` module inside `out/init/options`.
* @param {string} modulePath The path to the local module to use.
* @param {Object} [options] The options to use.
* @param {boolean} [options.useDefaults=false] Use default values.
* @return {Promise<Object, Error>} Resolved with the chosen options.
*/
getOptions(modulePath, { useDefaults = false } = {}) {
// eslint-disable-next-line global-require
const options = defaultExport(require(join(modulePath, '../init/options')));
if (useDefaults) {
return this.getDefaultOptions(options);
}
Logger.info('Answer these questions to create a new project:');
return prompt(options);
}
/**
* Runs the local atscm module's init script. This script is stored in the `atscm` module inside
* `out/init/init`.
* @param {string} modulePath The path to the local module to use.
* @param {Object} options The options to apply (Received by calling
* {@link InitCommand#getOptions}).
* @return {Promise<{install: string[]}, Error>} Resolved with information on the further init
* steps (which dependencies are needed), rejected with an error if running the init script
* failed.
*/
writeFiles(modulePath, options) {
// eslint-disable-next-line global-require
return defaultExport(require(join(modulePath, '../init/init')))(options);
}
/**
* Installs any additional dependencies needed after writing files.
* @param {string} path The path to install the dependencies at.
* @param {string[]} deps Names of the packages to install.
* @return {Promise<undefined, Error>} Rejected if installing failed, resolved otherwise.
*/
installDependencies(path, deps) {
if (!deps.length) return Promise.resolve();
Logger.info('Installing dependencies...');
return this.install(path, deps);
}
/**
* Creates a new atscm project.
* @param {AtSCMCli} cli The current Cli instance.
*/
run(cli) {
return cli
.getEnvironment(false)
.then((env) => this.checkDirectory(env.cwd, cli.options.force))
.then(() => this.createEmptyPackage(cli.environment.cwd))
.then(() => this.installLocal(cli.environment.cwd, cli.options))
.then(() => cli.getEnvironment(false))
.then((env) => this.checkCliVersion(env))
.then((env) => process.chdir(env.cwd))
.then(() => this.getOptions(cli.environment.modulePath, { useDefaults: cli.options.yes }))
.then((options) =>
this.writeFiles(cli.environment.modulePath, Object.assign({}, cli.environment, options))
)
.then((result) => this.installDependencies(cli.environment.cwd, result.install))
.then(async () => {
if (cli.options.link) {
Logger.info('Linking atscm...');
await this.runNpm(['link', 'atscm'], { cwd: cli.environment.cwd });
}
})
.then(() => {
Logger.info('Created new project at', Logger.format.path(cli.environment.cwd));
});
}
/**
* This command never requires an {@link Liftoff.Environment}.
* @return {boolean} Always `false`.
*/
requiresEnvironment() {
return false;
}
}