├── .eslintrc.js ├── .gitignore ├── .huskyrc ├── .lintstagedrc ├── .nvmrc ├── .prettierrc ├── .vscode └── settings.json ├── README.md ├── babel.config.js ├── img └── create-grandstack-app.gif ├── package.json ├── src ├── create-grandstack-app.js └── utils │ ├── file.js │ ├── index.js │ ├── main.js │ └── options.js └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es2020: true, 4 | node: true, 5 | }, 6 | extends: ['eslint:recommended', 'plugin:prettier/recommended'], 7 | parserOptions: { 8 | ecmaVersion: 11, 9 | sourceType: 'module', 10 | }, 11 | plugins: ['prettier'], 12 | rules: {}, 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | yarn-error.log 4 | -------------------------------------------------------------------------------- /.huskyrc: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "yarn run format && yarn run lint" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.+(js)": [ 3 | "eslint" 4 | ], 5 | "**/*.+(js|json|md)": [ 6 | "prettier --write" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v12.13.0 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": false, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "es5" 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "editor.insertSpaces": true, 4 | "editor.renderWhitespace": "boundary", 5 | "editor.rulers": [100], 6 | "editor.formatOnPaste": false, 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll.eslint": true 9 | }, 10 | "eslint.enable": true, 11 | "files.encoding": "utf8", 12 | "files.trimTrailingWhitespace": true, 13 | "files.insertFinalNewline": true, 14 | "prettier.requireConfig": false, 15 | "eslint.validate": ["javascript", "javascriptreact"], 16 | "search.exclude": { 17 | "node_modules/**": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # create-grandstack-app 2 | 3 | ![](https://github.com/grand-stack/create-grandstack-app/blob/master/img/create-grandstack-app.gif) 4 | 5 | Create a new GRANDstack application skeleton from the command line. 6 | 7 | Usage: 8 | 9 | ``` 10 | yarn create grandstack-app 11 | ``` 12 | 13 | or with `npm`: 14 | 15 | ``` 16 | npx create-grandstack-app 17 | ``` 18 | 19 | If `yarn` is detected then packages will be installed with `yarn` with fallback to `npm`, use flag `--use-npm` to force use of `npm`. 20 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const TARGETS_NODE = '12.13.0' 2 | const CORE_JS_VERSION = '3.6' 3 | 4 | module.exports = { 5 | presets: [ 6 | [ 7 | '@babel/preset-env', 8 | { 9 | targets: { node: TARGETS_NODE }, 10 | useBuiltIns: 'usage', 11 | corejs: { 12 | version: CORE_JS_VERSION, 13 | proposals: true, 14 | }, 15 | }, 16 | ], 17 | ], 18 | plugins: [ 19 | [ 20 | 'babel-plugin-module-resolver', 21 | { 22 | alias: { 23 | src: './src', 24 | }, 25 | }, 26 | ], 27 | ['@babel/plugin-proposal-class-properties', { loose: true }], 28 | [ 29 | '@babel/plugin-transform-runtime', 30 | { 31 | corejs: { version: 3, proposals: true }, 32 | version: '^7.8.3', 33 | }, 34 | ], 35 | ], 36 | } 37 | -------------------------------------------------------------------------------- /img/create-grandstack-app.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grand-stack/create-grandstack-app/a15554ed3b80181dde283890a35341ab99598858/img/create-grandstack-app.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-grandstack-app", 3 | "version": "0.4.5", 4 | "description": "Create a new GRANDstack application", 5 | "bin": "./dist/create-grandstack-app.js", 6 | "files": [ 7 | "dist" 8 | ], 9 | "scripts": { 10 | "build": "yarn clean && babel src --out-dir dist", 11 | "build:watch": "nodemon --ignore dist --exec 'yarn build'", 12 | "lint": "eslint --ext .js src --color", 13 | "format": "prettier --write src/**/*.js", 14 | "clean": "rm -rf dist", 15 | "prepublishOnly": "yarn clean && yarn build", 16 | "test": "echo \"Error: no test specified\" && exit 1" 17 | }, 18 | "author": "William Lyon", 19 | "license": "MIT", 20 | "dependencies": { 21 | "@babel/cli": "^7.8.4", 22 | "@babel/core": "^7.9.0", 23 | "@babel/node": "^7.8.7", 24 | "@babel/plugin-proposal-class-properties": "^7.8.3", 25 | "@babel/plugin-transform-runtime": "^7.9.0", 26 | "@babel/preset-env": "^7.9.0", 27 | "@babel/preset-react": "^7.9.4", 28 | "@babel/preset-typescript": "^7.9.0", 29 | "@babel/runtime-corejs3": "^7.9.2", 30 | "arg": "^4.1.3", 31 | "axios": "^0.19.2", 32 | "babel-plugin-auto-import": "^1.0.5", 33 | "babel-plugin-module-resolver": "^4.0.0", 34 | "chalk": "^4.1.0", 35 | "check-node-version": "^4.0.3", 36 | "decompress": "^4.2.1", 37 | "execa": "^4.0.3", 38 | "inquirer": "^7.3.3", 39 | "listr": "^0.14.3", 40 | "ncp": "^2.0.0", 41 | "pkg-install": "^1.0.0", 42 | "rimraf": "^3.0.2", 43 | "tmp": "^0.1.0", 44 | "util": "^0.12.3" 45 | }, 46 | "devDependencies": { 47 | "eslint": "^7.6.0", 48 | "eslint-config-prettier": "^6.11.0", 49 | "eslint-plugin-prettier": "^3.1.4", 50 | "husky": "^4.2.5", 51 | "lint-staged": "^10.2.11", 52 | "nodemon": "^2.0.4", 53 | "prettier": "^2.0.5", 54 | "prettier-eslint-cli": "^5.0.0" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/create-grandstack-app.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('./utils').main(process.argv) 3 | -------------------------------------------------------------------------------- /src/utils/file.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import fs from 'fs' 3 | import execa from 'execa' 4 | import axios from 'axios' 5 | import path from 'path' 6 | import tmp from 'tmp' 7 | import decompress from 'decompress' 8 | import rimraf from 'rimraf' 9 | 10 | const dirExists = (dir) => fs.existsSync(dir) 11 | const dirIsNotEmpty = (dir) => fs.readdirSync(dir).length > 0 12 | 13 | export const checkAppDir = (targetDir) => { 14 | const exists = dirExists(targetDir) 15 | if (exists && dirIsNotEmpty(targetDir)) { 16 | console.log( 17 | `%s '${targetDir}' already exists and is not empty.`, 18 | chalk.yellow.bold('ALREADYEXISTS') 19 | ) 20 | process.exit(1) 21 | } 22 | } 23 | 24 | export const appDir = (targetDir) => path.resolve(process.cwd(), targetDir) 25 | 26 | export const initGit = async (newAppDir) => { 27 | const result = await execa('git', ['init'], { 28 | cwd: newAppDir, 29 | }) 30 | if (result.failed) { 31 | return Promise.reject(new Error('Failed to initialize git')) 32 | } 33 | return 34 | } 35 | 36 | export const writeDotEnv = ({ 37 | newAppDir, 38 | neo4jUri, 39 | neo4jUser, 40 | neo4jPassword, 41 | }) => { 42 | const dotenvpath = path.join(newAppDir, 'api') 43 | 44 | // FIXME: It would be better to replace into a template instead of rewrite entire file 45 | const dotenvstring = `# Use this file to set environment variables with credentials and configuration options 46 | # This file is provided as an example and should be replaced with your own values 47 | # You probably don't want to check this into version control! 48 | 49 | NEO4J_URI=${neo4jUri} 50 | NEO4J_USER=${neo4jUser} 51 | NEO4J_PASSWORD=${neo4jPassword} 52 | 53 | # Uncomment this line to specify a specific Neo4j database (v4.x+ only) 54 | #NEO4J_DATABASE=neo4j 55 | 56 | GRAPHQL_SERVER_HOST=0.0.0.0 57 | GRAPHQL_SERVER_PORT=4001 58 | GRAPHQL_SERVER_PATH=/graphql 59 | 60 | ` 61 | 62 | fs.writeFileSync(path.join(dotenvpath, '.env'), dotenvstring) 63 | } 64 | 65 | export const writeConfigJson = ({ 66 | newAppDir, 67 | templateName, 68 | templateFileName, 69 | }) => { 70 | const configPath = path.join(newAppDir, 'scripts', 'config') 71 | if (!fs.existsSync(configPath)) { 72 | fs.mkdirSync(configPath) 73 | } 74 | const config = { 75 | templateFileName, 76 | templateName, 77 | } 78 | 79 | fs.writeFileSync(path.join(configPath, 'index.json'), JSON.stringify(config)) 80 | } 81 | 82 | export const latestReleaseZipFile = async () => { 83 | const RELEASE_URL = 84 | 'https://api.github.com/repos/grand-stack/grand-stack-starter/releases' 85 | const res = await axios.get(RELEASE_URL) 86 | return res.data[0].zipball_url 87 | } 88 | 89 | export const downloadFile = async (sourceUrl, targetFile) => { 90 | const writer = fs.createWriteStream(targetFile) 91 | const response = await axios.get(sourceUrl, { 92 | responseType: 'stream', 93 | }) 94 | response.data.pipe(writer) 95 | 96 | return new Promise((resolve, reject) => { 97 | writer.on('finish', resolve) 98 | writer.on('error', reject) 99 | }) 100 | } 101 | 102 | export const removeUnusedTemplates = async ({ newAppDir, rmTemplates }) => { 103 | try { 104 | const rimRafPromises = rmTemplates.map((choice) => 105 | rimraf(path.join(newAppDir, choice), (err) => { 106 | if (err) { 107 | console.log(`%s Rimraf Exception`, chalk.redBright.bold('ERROR')) 108 | console.log(`%s ${err}`, chalk.redBright.bold('ERROR')) 109 | process.exit(1) 110 | } 111 | }) 112 | ) 113 | return Promise.all(rimRafPromises) 114 | } catch (err) { 115 | console.log(err) 116 | } 117 | } 118 | 119 | export const createProjectTasks = ({ 120 | newAppDir, 121 | rmTemplates, 122 | templateName, 123 | templateFileName, 124 | ...creds 125 | }) => { 126 | const tmpDownloadPath = tmp.tmpNameSync({ 127 | prefix: 'grandstack', 128 | postfix: '.zip', 129 | }) 130 | 131 | return [ 132 | { 133 | title: `${ 134 | dirExists(newAppDir) ? 'Using' : 'Creating' 135 | } directory '${newAppDir}'`, 136 | task: () => { 137 | fs.mkdirSync(newAppDir, { recursive: true }) 138 | }, 139 | }, 140 | { 141 | title: 'Downloading latest release', 142 | task: async () => { 143 | const url = await latestReleaseZipFile() 144 | return downloadFile(url, tmpDownloadPath) 145 | }, 146 | }, 147 | { 148 | title: 'Extracting latest release', 149 | task: () => decompress(tmpDownloadPath, newAppDir, { strip: 1 }), 150 | }, 151 | { 152 | title: 'Creating Local env file with configuration options...', 153 | task: () => 154 | writeDotEnv({ 155 | newAppDir, 156 | ...creds, 157 | }), 158 | }, 159 | { 160 | title: 'Creating scripts configuration...', 161 | task: () => 162 | writeConfigJson({ newAppDir, templateName, templateFileName }), 163 | }, 164 | { 165 | title: `Removing unused templates: 166 | \u2714 ${rmTemplates.join('\n \u2714 ')}`, 167 | task: async () => await removeUnusedTemplates({ newAppDir, rmTemplates }), 168 | }, 169 | ] 170 | } 171 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | export * from './file' 2 | 3 | export * from './options' 4 | 5 | export * from './main' 6 | -------------------------------------------------------------------------------- /src/utils/main.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import Listr from 'listr' 3 | import path from 'path' 4 | import { projectInstall } from 'pkg-install' 5 | import { createProjectTasks, checkAppDir, initGit, appDir } from './file' 6 | import { parseArgumentsIntoOptions, promptForMissingOptions } from './options' 7 | 8 | async function createApp(options) { 9 | const { 10 | projectPath, 11 | rmTemplates, 12 | templateName, 13 | templateFileName, 14 | gitInit, 15 | useNpm, 16 | neo4jUri, 17 | neo4jUser, 18 | neo4jPassword, 19 | runInstall, 20 | } = options 21 | 22 | const creds = { neo4jUri, neo4jUser, neo4jPassword } 23 | 24 | // Check to see if path exists and return joined path 25 | const newAppDir = appDir(projectPath) 26 | const packageManager = useNpm ? 'npm' : 'yarn' 27 | 28 | // Main task loop, build and concat based on options 29 | console.log('%s', chalk.green.bold('Initializing Project...')) 30 | const tasks = new Listr( 31 | [ 32 | { 33 | title: 'Create GRANDstack App', 34 | task: () => 35 | new Listr( 36 | createProjectTasks({ 37 | newAppDir, 38 | rmTemplates, 39 | templateName, 40 | templateFileName, 41 | ...creds, 42 | }) 43 | ), 44 | }, 45 | { 46 | title: 'Initialize git', 47 | task: () => initGit(newAppDir), 48 | enabled: () => gitInit, 49 | }, 50 | { 51 | title: `Installing Packages with ${packageManager}`, 52 | task: () => 53 | new Listr([ 54 | { 55 | title: 'Installing GRANDstack CLI and dependencies', 56 | task: () => 57 | projectInstall({ 58 | cwd: newAppDir, 59 | prefer: packageManager, 60 | }), 61 | }, 62 | { 63 | title: 'Installing api dependencies', 64 | task: () => 65 | projectInstall({ 66 | cwd: path.join(newAppDir, 'api'), 67 | prefer: packageManager, 68 | }), 69 | }, 70 | { 71 | title: 72 | templateFileName === 'api-only' 73 | ? 'Skipping Frontend Install' 74 | : `Installing ${templateFileName} dependencies`, 75 | task: () => 76 | projectInstall({ 77 | cwd: path.join(newAppDir, templateFileName), 78 | prefer: packageManager, 79 | }), 80 | skip: () => templateFileName === 'api-only', 81 | }, 82 | ]), 83 | skip: () => 84 | !runInstall 85 | ? 'Pass --install to automatically install dependencies' 86 | : undefined, 87 | }, 88 | ], 89 | { collapse: false, exitOnError: true } 90 | ) 91 | 92 | await tasks.run() 93 | console.log() 94 | console.log( 95 | chalk.green( 96 | `Thanks for using GRANDstack! We've created your app in '${newAppDir}'` 97 | ) 98 | ) 99 | console.log(`You can find documentation at: https://grandstack.io/docs`) 100 | console.log() 101 | console.log(` 102 | To start your GRANDstack web application and GraphQL API run: 103 | 104 | cd ${projectPath} 105 | npm run start 106 | 107 | Then (optionally) to seed the database with sample data, in the api/ directory in another terminal run: 108 | 109 | npm run seedDb 110 | 111 | The default application is a simple business reviews application. Feel free to suggest updates by visiting the open source template repo and opening an issue: https://github.com/grand-stack/grand-stack-starter/issues. 112 | `) 113 | return true 114 | } 115 | 116 | export const main = async (args) => { 117 | const options = parseArgumentsIntoOptions(args) 118 | checkAppDir(options.projectPath) 119 | const prompted = await promptForMissingOptions(options) 120 | createApp(prompted) 121 | } 122 | -------------------------------------------------------------------------------- /src/utils/options.js: -------------------------------------------------------------------------------- 1 | import inquirer from 'inquirer' 2 | import arg from 'arg' 3 | import chalk from 'chalk' 4 | import execa from 'execa' 5 | 6 | const templateOpts = ['React', 'React-TS', 'Angular', 'Flutter', 'API-Only'] 7 | const templateFileNameHashMap = { 8 | React: 'web-react', 9 | 'React-TS': 'web-react-ts', 10 | Angular: 'web-angular', 11 | Flutter: 'mobile_client_flutter', 12 | 'API-Only': 'api-only', 13 | } 14 | 15 | export const getTemplateFileName = (chosenTemplate) => { 16 | return templateFileNameHashMap[chosenTemplate] 17 | } 18 | 19 | const getRmTemplates = (chosenTemplate) => { 20 | return Object.values(templateFileNameHashMap).filter( 21 | (name) => 22 | name !== templateFileNameHashMap[chosenTemplate] && name !== 'api-only' 23 | ) 24 | } 25 | 26 | const shouldUseYarn = () => { 27 | try { 28 | execa.sync('yarnpkg', ['--version']) 29 | return true 30 | } catch (e) { 31 | return false 32 | } 33 | } 34 | 35 | const APIQuestions = [ 36 | { 37 | type: 'input', 38 | name: 'whatever', 39 | message: chalk.green( 40 | `Now let's configure your GraphQL API to connect to Neo4j. If you don't have a Neo4j instance you can create one for free in the cloud at https://neo4j.com/sandbox 41 | 42 | Hit When you are ready.` 43 | ), 44 | }, 45 | { 46 | type: 'input', 47 | name: 'neo4jUri', 48 | message: `Enter the connection string for Neo4j 49 | (use neo4j+s:// or bolt+s:// scheme for encryption)`, 50 | default: 'bolt://localhost:7687', 51 | }, 52 | { 53 | type: 'input', 54 | name: 'neo4jUser', 55 | message: 'Enter the Neo4j user', 56 | default: 'neo4j', 57 | }, 58 | { 59 | type: 'input', 60 | name: 'neo4jPassword', 61 | message: 'Enter the password for this user', 62 | default: 'letmein', 63 | }, 64 | ] 65 | 66 | export const parseArgumentsIntoOptions = (rawArgs) => { 67 | try { 68 | const args = arg( 69 | { 70 | '--git': Boolean, 71 | '--yes': Boolean, 72 | '--install': Boolean, 73 | '--use-npm': Boolean, 74 | '--init-db': Boolean, 75 | '-g': '--git', 76 | '-y': '--yes', 77 | '-i': '--install', 78 | '-un': '--use-npm', 79 | }, 80 | { 81 | argv: rawArgs.slice(2), 82 | } 83 | ) 84 | return { 85 | skipPrompts: args['--yes'] || false, 86 | gitInit: args['--git'] || false, 87 | projectPath: args._[0], 88 | template: args._[1], 89 | runInstall: args['--install'] || false, 90 | useNpm: args['--use-npm'] || !shouldUseYarn(), 91 | } 92 | } catch (error) { 93 | console.log('unknown option') 94 | process.exit(0) 95 | } 96 | } 97 | 98 | export const promptForMissingOptions = async (options) => { 99 | const { skipPrompts, template, projectPath, gitInit, runInstall } = options 100 | 101 | const defaultTemplate = 'React' 102 | const defaultPath = './GRANDStackStarter' 103 | if (skipPrompts) { 104 | return { 105 | ...options, 106 | neo4jUri: 'bolt://localhost:7687', 107 | neo4jUser: 'neo4j', 108 | neo4jPassword: 'letmein', 109 | rmTemplates: getRmTemplates(chosenTemplate), 110 | templateName: template || defaultTemplate, 111 | templateFileName: getTemplateFileName(chosenTemplate), 112 | projectPath: projectPath || defaultPath, 113 | } 114 | } 115 | 116 | const questions = [] 117 | if (!template) { 118 | questions.push({ 119 | type: 'list', 120 | name: 'template', 121 | message: 'Please choose which project template to use', 122 | choices: templateOpts, 123 | default: defaultTemplate, 124 | }) 125 | } 126 | 127 | if (!runInstall) { 128 | questions.push({ 129 | type: 'confirm', 130 | name: 'runInstall', 131 | message: 'Install dependencies?', 132 | default: true, 133 | }) 134 | } 135 | 136 | if (!gitInit) { 137 | questions.push({ 138 | type: 'confirm', 139 | name: 'gitInit', 140 | message: 'Initialize a git repository?', 141 | default: false, 142 | }) 143 | } 144 | 145 | const { 146 | template: inqTemplate, 147 | gitInit: inqGitInit, 148 | runInstall: inqRunInstall, 149 | ...rest 150 | } = await inquirer.prompt([...questions, ...APIQuestions]) 151 | const chosenTemplate = template || inqTemplate 152 | return { 153 | ...options, 154 | ...rest, 155 | rmTemplates: getRmTemplates(chosenTemplate), 156 | templateName: chosenTemplate, 157 | templateFileName: getTemplateFileName(chosenTemplate), 158 | projectPath: projectPath || defaultPath, 159 | gitInit: gitInit || inqGitInit, 160 | runInstall: runInstall || inqRunInstall, 161 | } 162 | } 163 | --------------------------------------------------------------------------------