UNPKG

@servicestack/cli

Version:

Simple CLI utils for ServiceStack projects

640 lines (526 loc) 22.1 kB
import * as fs from "fs"; import * as util from "util"; import * as os from "os"; import * as path from "path"; import * as url from 'url'; import * as request from "request"; import * as AsciiTable from "ascii-table"; import * as extractZip from "extract-zip"; import { normalizeSwitches, splitOnLast } from './index'; var packageConf = require('../package.json'); const TemplatePlaceholder = "MyApp"; let DEBUG = false; const DefaultConfigFile = 'dotnet-new.config'; const DefaultConfig = { "sources": [ { "name": "ServiceStack .NET Core 2.0 C# Templates", "url": "https://api.github.com/orgs/NetCoreTemplates/repos" }, { "name": "ServiceStack .NET Framework C# Templates", "url": "https://api.github.com/orgs/NetFrameworkTemplates/repos" }, { "name": "ServiceStack .NET Framework ASP.NET Core C# Templates", "url": "https://api.github.com/orgs/NetFrameworkCoreTemplates/repos" }, ], "postinstall": [ { "test": "MyApp/package.json", "exec": 'cd "MyApp" && npm install' }, { "test": "MyApp.sln", "exec": "nuget restore" }, ] }; const headers = { 'User-Agent': 'servicestack-cli' }; interface IConfig { sources: Array<ISource> postinstall?: Array<IExecRule> } interface ISource { name: string; url: string; } interface IExecRule { test: string; exec?: string; } interface IRepo { name: string; description: string; releases_url: string; } interface IRelease { name: string; zipball_url: string; prerelease: boolean; } const VALID_NAME_CHARS = /^[a-zA-Z_$][0-9a-zA-Z_$.]*$/; const ILLEGAL_NAMES = 'CON|AUX|PRN|COM1|LP2|.|..'.split('|'); const IGNORE_EXTENSIONS = "jpg|jpeg|png|gif|ico|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|ogg|dll|exe|pdb|so|zip|key|snk|p12|swf|xap|class|doc|xls|ppt|sqlite|db".split('|'); const camelToKebab = (str) => (str || '').replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); const escapeRegEx = str => (str || '').replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); const replaceRegEx = /MyApp/g; const replaceKebabRegEx = /my-app/g; const replaceSplitCaseRegEx = /My App/g; const removeWindowsCR = /\r/g; const splitCase = (t: string) => typeof t != 'string' ? t : t.replace(/([A-Z]|[0-9]+)/g, ' $1').replace(/_/g, ' ').trim(); const replaceMyApp = (s:string, projectName:string) => { if (!s) return ""; let ret = s.replace(replaceRegEx, projectName) .replace(replaceKebabRegEx, camelToKebab(projectName)) .replace(replaceSplitCaseRegEx, splitCase(projectName)); if (process.platform != 'win32') return ret.replace(removeWindowsCR, ""); return ret; } const exec = require('child_process').execSync; function runScript(script) { process.env.FORCE_COLOR = "1"; exec(script, {stdio: [process.stdin, process.stdout, process.stderr]}); } export function cli(args: string[]) { const nodeExe = args[0]; const cliPath = args[1]; const cwd = process.cwd(); let cmdArgs = args.slice(2); if (process.env.GITHUB_OAUTH_TOKEN) headers['Authorization'] = `token ${process.env.GITHUB_OAUTH_TOKEN}`; if (DEBUG) console.log({ cwd, cmdArgs }); const arg1 = cmdArgs.length > 0 ? normalizeSwitches(cmdArgs[0]) : null; const isConfig = arg1 && ["/c", "/config"].indexOf(arg1) >= 0; let configFile = DefaultConfigFile; if (isConfig) { configFile = cmdArgs[1]; cmdArgs = cmdArgs.slice(2); } if (["/d", "/debug"].indexOf(arg1) >= 0) { DEBUG = true; cmdArgs = cmdArgs.slice(1); } const config = getConfigSync(path.join(cwd, configFile)); if (DEBUG) console.log('config', config, cmdArgs); if (cmdArgs.length == 0) { showTemplates(config); return; } if (["/h", "/?", "/help"].indexOf(arg1) >= 0) { showHelp(); return; } if (["/v", "/version"].indexOf(arg1) >= 0) { console.log(`Version: ${packageConf.version}`); return; } if (["/clean"].indexOf(arg1) >= 0) { rmdir(cacheDirName()); console.log(`Cleared package cache: ${cacheDirName()}`) return; } var template = cmdArgs[0]; if (template.startsWith("-") || (template.startsWith("/") && template.split('/').length == 1)) { showHelp("Unknown switch: " + arg1); return; } if (parseInt(template) >= 0) { showHelp("Please specify a template name."); return; } const projectName = cmdArgs.length > 1 ? cmdArgs[1] : null; const isGitHubProject = template.indexOf('://') == -1 && template.split('/').length == 2; if (isGitHubProject) template = "https://github.com/" + template; const isUrl = template.indexOf('://') >= 0; const isZip = template.endsWith('.zip'); const done = (err) => { if (err) { console.log(err); } else { if (fs.existsSync(projectName)) { process.chdir(projectName); (config.postinstall || []).forEach(rule => { var path = replaceMyApp(rule.test, projectName); if (fs.existsSync(path)) { if (!rule.exec) return; var exec = replaceMyApp(rule.exec, projectName); if (DEBUG) console.log(`Matched: '${rule.test}', executing '${exec}'...`); try { runScript(exec); } catch(e) { console.log(e.message || e); } } else { if (DEBUG) console.log(`path does not exist: '${path}' in '${process.cwd()}'`); } }); } else { if (DEBUG) console.log(`${projectName} does not exist`); } } } if (isUrl && isZip) { createProjectFromZipUrl(template, projectName, done); } else if (isZip) { createProjectFromZip(template, projectName, done); } else if (isUrl) { //https://github.com/NetCoreTemplates/react-app //https://api.github.com/repos/NetCoreTemplates/react-app/releases if (template.endsWith("/releases")) { createProjectFromReleaseUrl(template, projectName, null, done); } else if (template.indexOf('github.com/') >= 0) { var repoName = template.substring(template.indexOf('github.com/') + 'github.com/'.length); if (repoName.split('/').length == 2) { var releaseUrl = `https://api.github.com/repos/${repoName}/releases`; createProjectFromReleaseUrl(releaseUrl, projectName, null, done); return; } } return showHelp("Invalid URL: only .zip URLs, GitHub repo URLs or release HTTP API URLs are supported."); } else { createProject(config, template, projectName, done); } } function getConfigSync(path: string): IConfig { try { if (!fs.existsSync(path)) return DefaultConfig; var json = fs.readFileSync(path, 'utf8'); var config = JSON.parse(json); return config as IConfig; } catch (e) { handleError(e); } } function handleError(e, msg: string = null) { if (msg) { console.error(msg); } console.error(e.message || e); process.exit(-1); } export function showTemplates(config: IConfig) { if (DEBUG) console.log('execShowTemplates', config); console.log('Help: dotnet-new -h\n'); if (config.sources == null || config.sources.length == 0) handleError('No sources defined'); let results:AsciiTable[] = []; const done = () => { results.forEach(table => { console.log(table.toString()); console.log(); }); console.log('Usage: dotnet-new <template> ProjectName'); }; var pending = 0; config.sources.forEach((source, index) => { let count = 0; pending++; request({ url:source.url, headers }, (err, res, json) => { if (err) handleError(err); if (res.statusCode >= 400) handleError(`Request failed '${url}': ${res.statusCode} ${res.statusMessage}`); try { var repos = JSON.parse(json); var table = new AsciiTable(source.name); table.setHeading('', 'template', 'description'); for (var i = 0; i < repos.length; i++) { var repo = repos[i] as IRepo; table.addRow(++count, repo.name, repo.description); } results[index] = table; if (--pending == 0) done(); } catch (e) { console.log('json', json) handleError(e, `ERROR: Could not parse JSON response from: ${url}`); } }); }); if (process.env.SERVICESTACK_TELEMETRY_OPTOUT != "1") { try { request(`https://servicestack.net/stats/dotnet-new/record?name=list&source=cli&version=${packageConf.version}`); } catch (ignore) { } } } export function createProject(config: IConfig, template: string, projectName: string, done:Function) { if (DEBUG) console.log('execCreateProject', config, template, projectName); if (config.sources == null || config.sources.length == 0) handleError('No sources defined'); assertValidProjectName(projectName); let found = false; const cb = () => { if (!found) { done(`Could not find template '${template}'. Run 'dotnet-new' to view list of templates available.`); } else { done(); } }; let version: string = null; const parts = splitOnLast(template, '@'); if (parts.length > 1) { template = parts[0]; version = parts[1]; } let pending = 0; config.sources.forEach(source => { pending++; if (found) return; request({ url:source.url, headers }, (err, res, json) => { if (err) handleError(err); if (res.statusCode >= 400) handleError(`Request failed '${url}': ${res.statusCode} ${res.statusMessage}`); if (found) return; try { let repos = JSON.parse(json) as IRepo[]; repos.forEach(repo => { if (repo.name === template) { found = true; let releaseUrl = urlFromTemplate(repo.releases_url); createProjectFromReleaseUrl(releaseUrl, projectName, version, cb); return; } }); if (--pending == 0) cb(); } catch (e) { if (DEBUG) console.log('Invalid JSON: ', json); handleError(e, `ERROR: Could not parse JSON response from: ${url}`); } }); }); if (process.env.SERVICESTACK_TELEMETRY_OPTOUT != "1") { try { request(`https://servicestack.net/stats/dotnet-new/record?name=${template}&source=cli&version=${packageConf.version}`); } catch (ignore) { } } } const urlFromTemplate = (urlTemplate: string) => splitOnLast(urlTemplate, '{')[0]; export function createProjectFromReleaseUrl(releasesUrl: string, projectName: string, version: string, done:Function) { if (DEBUG) console.log(`Creating project from: ${releasesUrl}`); let found = false; request({ url: releasesUrl, headers }, (err, res, json) => { if (err) handleError(err); if (res.statusCode >= 400) handleError(`Request failed '${releasesUrl}': ${res.statusCode} ${res.statusMessage}`); try { let releases = JSON.parse(json) as IRelease[]; releases.forEach(release => { if (found) return; if (release.prerelease) return; if (version != null && release.name != version) return; if (release.zipball_url == null) handleError(`Release ${release.name} does not have zipball_url`); found = true; createProjectFromZipUrl(release.zipball_url, projectName, done); }); if (!found) { console.log('Could not find any Releases for this project.'); const githubUrl = 'api.github.com/repos/'; if (releasesUrl.indexOf(githubUrl) >= 0 && releasesUrl.endsWith('/releases')) { let repoName = releasesUrl.substring(releasesUrl.indexOf(githubUrl) + githubUrl.length, releasesUrl.length - '/releases'.length); let masterZipUrl = `https://github.com/${repoName}/archive/master.zip`; console.log('Fallback to using master archive from: ' + masterZipUrl); createProjectFromZipUrl(masterZipUrl, projectName, done); } } } catch (e) { if (DEBUG) console.log('Invalid JSON: ', json); handleError(e, `ERROR: Could not parse JSON response from: ${releasesUrl}`); } }); } export function createProjectFromZipUrl(zipUrl: string, projectName: string, done:Function) { let cachedName = cacheFileName(filenamifyUrl(zipUrl)); if (!fs.existsSync(cachedName)) { request({ url: zipUrl, encoding: null, headers }, (err, res, body) => { if (err) throw err; if (res.statusCode >= 400) handleError(`Request failed '${zipUrl}': ${res.statusCode} ${res.statusMessage}`); if (DEBUG) console.log(`Writing zip file to: ${cachedName}`); ensureCacheDir(); fs.writeFile(cachedName, body, function (err) { createProjectFromZip(cachedName, projectName, done); }); }); } else { createProjectFromZip(cachedName, projectName, done); } } const execTimeoutMs = 10 * 1000; const retryAfterMs = 100; const sleep = ms => exec(`"${process.argv[0]}" -e setTimeout(function(){},${ms})`); // Rename can fail on Windows when Windows Defender real-time AV is on: // https://github.com/react-community/create-react-native-app/issues/191#issuecomment-304073970 const managedExec = (fn) => { const started = new Date().getTime(); do { try { fn(); return; } catch(e) { if (DEBUG) console.log(`${e.message || e}, retrying after ${retryAfterMs}ms...`); sleep(retryAfterMs); } } while (new Date().getTime() - started < execTimeoutMs); } export function createProjectFromZip(zipFile: string, projectName: string, done:Function) { assertValidProjectName(projectName); if (!fs.existsSync(zipFile)) throw new Error(`File does not exist: ${zipFile}`); if (!projectName) projectName = TemplatePlaceholder; let rootDirs = []; extractZip(zipFile, { dir: process.cwd(), onEntry: (entry, zipFile) => { var isRootDir = entry.fileName && entry.fileName.indexOf('/') == entry.fileName.length - 1; if (isRootDir) { rootDirs.push(entry.fileName); } } }, function (err) { if (DEBUG) console.log('Project extracted, rootDirs: ', rootDirs); if (rootDirs.length == 1) { const rootDir = rootDirs[0]; if (fs.lstatSync(rootDir).isDirectory()) { if (DEBUG) console.log(`Renaming single root dir '${rootDir}' to '${projectName}'`); managedExec(() => fs.renameSync(rootDir, projectName)); renameTemplateFolder(path.join(process.cwd(), projectName), projectName, done); } } else { if (DEBUG) console.log('No root folder found, renaming folders and files in: ' + process.cwd()); renameTemplateFolder(process.cwd(), projectName, done); } }) } export function renameTemplateFolder(dir: string, projectName: string, done:Function=null) { if (DEBUG) console.log('Renaming files and folders in: ', dir); const fileNames = fs.readdirSync(dir); for (let f = 0; f < fileNames.length; f += 1) { const fileName = fileNames[f]; const parts = splitOnLast(fileName, '.'); const ext = parts.length == 2 ? parts[1] : null; const oldPath = path.join(dir, fileName); const fstat = fs.statSync(oldPath); const newName = replaceMyApp(fileName, projectName); const newPath = path.join(dir, newName); managedExec(() => fs.renameSync(oldPath, newPath)); if (fstat.isFile()) { if (IGNORE_EXTENSIONS.indexOf(ext) == -1) { try { var data = fs.readFileSync(newPath, 'utf8'); var result = replaceMyApp(data, projectName); try { fs.writeFileSync(newPath, result, 'utf8'); } catch(e) { console.log("ERROR: " + e); } } catch(err) { return console.log(`ERROR readFile '${fileName}': ${err}`); } } } else if (fstat.isDirectory()) { renameTemplateFolder(newPath, projectName, null); } } if (done) done(); } export function assertValidProjectName(projectName: string) { if (!projectName) return; if (!VALID_NAME_CHARS.test(projectName)) handleError('Illegal char in project name: ' + projectName); if (ILLEGAL_NAMES.indexOf(projectName) != -1) handleError('Illegal project name: ' + projectName); if (fs.existsSync(projectName)) handleError('Project folder already exists: ' + projectName); } export function showHelp(msg: string = null) { const USAGE = `Version: ${packageConf.version} Syntax: dotnet-new [options] [TemplateName|Repo|ProjectUrl.zip] [ProjectName] View a list of available project templates: dotnet-new Create a new project: dotnet-new [TemplateName] dotnet-new [TemplateName] [ProjectName] # Use latest release of a GitHub Project dotnet-new [RepoUrl] dotnet-new [RepoUrl] [ProjectName] # Direct link to project release .zip tarball dotnet-new [ProjectUrl.zip] dotnet-new [ProjectUrl.zip] [ProjectName] Options: -c, --config [ConfigFile] Use specified config file -h, --help Print this message -v, --version Print this version --clean Clear template cache This tool collects anonymous usage to determine the most used languages to improve your experience. To disable set SERVICESTACK_TELEMETRY_OPTOUT=1 environment variable to 1 using your favorite shell.`; if (msg != null) console.log(msg + "\n"); console.log(USAGE); } //Helpers export const cacheFileName = (fileName: string) => path.join(os.homedir(), '.servicestack', 'cache', fileName); export const cacheDirName = () => path.join(os.homedir(), '.servicestack', 'cache'); export const ensureCacheDir = () => mkdir(cacheDirName()); export const mkdir = (dirPath: string) => { const sep = path.sep; const initDir = path.isAbsolute(dirPath) ? sep : ''; dirPath.split(sep).reduce((parentDir, childDir) => { const curDir = path.resolve(parentDir, childDir); if (!fs.existsSync(curDir)) { fs.mkdirSync(curDir); } return curDir; }, initDir); } export const rmdir = (path: string) => { if (fs.existsSync(path)) { fs.readdirSync(path).forEach(function (file, index) { var curPath = path + "/" + file; if (fs.lstatSync(curPath).isDirectory()) { rmdir(curPath); } else { // delete file fs.unlinkSync(curPath); } }); fs.rmdirSync(path); } }; //The MIT License (MIT) const matchOperatorsRe = /[|\\{}()[\]^$+*?.]/g; const escapeStringRegexp = (str: string) => str.replace(matchOperatorsRe, '\\$&'); const trimRepeated = (str: string, target: string) => str.replace(new RegExp('(?:' + escapeStringRegexp(target) + '){2,}', 'g'), target); const filenameReservedRegex = () => (/[<>:"\/\\|?*\x00-\x1F]/g); const filenameReservedRegexWindowNames = () => (/^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i); const stripOuter = (str: string, sub: string) => { sub = escapeStringRegexp(sub); return str.replace(new RegExp('^' + sub + '|' + sub + '$', 'g'), ''); } const MAX_FILENAME_LENGTH = 100; const reControlChars = /[\x00-\x1f\x80-\x9f]/g; // eslint-disable-line no-control-regex const reRelativePath = /^\.+/; const filenamify = (str: string, opts: any) => { opts = opts || {}; const replacement = opts.replacement || '!'; if (filenameReservedRegex().test(replacement) && reControlChars.test(replacement)) throw new Error('Replacement string cannot contain reserved filename characters'); str = str.replace(filenameReservedRegex(), replacement); str = str.replace(reControlChars, replacement); str = str.replace(reRelativePath, replacement); if (replacement.length > 0) { str = trimRepeated(str, replacement); str = str.length > 1 ? stripOuter(str, replacement) : str; } str = filenameReservedRegexWindowNames().test(str) ? str + replacement : str; str = str.slice(0, MAX_FILENAME_LENGTH); return str; } const normalizeUrl = (url: string) => url.toLowerCase(); //replaces: https://github.com/sindresorhus/normalize-url const stripUrlAuth = (input: string) => input.replace(/^((?:\w+:)?\/\/)(?:[^@/]+@)/, '$1'); const humanizeUrl = (str: string) => normalizeUrl(stripUrlAuth(str)).replace(/^(?:https?:)?\/\//, ''); export const filenamifyUrl = (str: string, opts: any = null) => filenamify(humanizeUrl(str), opts);