# Re-Natal # Bootstrap ClojureScript React Native apps # Dan Motzenbecker # http://oxism.com # MIT License fs = require 'fs-extra' net = require 'net' http = require 'http' os = require 'os' crypto = require 'crypto' child = require 'child_process' cli = require 'commander' chalk = require 'chalk' semver = require 'semver' ckDeps = require 'check-dependencies' pkgJson = require __dirname + '/package.json' nodeVersion = pkgJson.engines.node resources = __dirname + '/resources/' validNameRx = /^[A-Z][0-9A-Z]*$/i camelRx = /([a-z])([A-Z])/g projNameRx = /\$PROJECT_NAME\$/g projNameHyphRx = /\$PROJECT_NAME_HYPHENATED\$/g projNameUsRx = /\$PROJECT_NAME_UNDERSCORED\$/g platformRx = /\$PLATFORM\$/g devHostRx = /\$DEV_HOST\$/g figwheelUrlRx = /ws:\/\/[0-9a-zA-Z\.]*:/g rnVersion = '0.19.0' rnPackagerPort = 8081 process.title = 're-natal' sampleCommand = '(dispatch [:set-greeting "Hello Native World!"])' log = (s, color = 'green') -> console.log chalk[color] s logErr = (err, color = 'red') -> console.error chalk[color] err process.exit 1 exec = (cmd, keepOutput) -> if keepOutput child.execSync cmd else child.execSync cmd, stdio: 'ignore' ensureExecutableAvailable = (executable) -> if os.platform() == 'win32' try exec "where #{executable}" catch e throw new Error("type: #{executable}: not found") else exec "type #{executable}" ensureOSX = (cb) -> if os.platform() == 'darwin' cb() else logErr 'This command is only available on OSX' readFile = (path) -> fs.readFileSync path, encoding: 'ascii' edit = (path, pairs) -> fs.writeFileSync path, pairs.reduce (contents, [rx, replacement]) -> contents.replace rx, replacement , readFile path mkdirSync = (path) -> try fs.mkdirSync(path) catch {message} if not message.match /EEXIST/i throw new Error "Could not create dir #{path}: #{message}" ; toUnderscored = (s) -> s.replace(camelRx, '$1_$2').toLowerCase() checkPort = (port, cb) -> sock = net.connect {port}, -> sock.end() http.get "http://localhost:#{port}/status", (res) -> data = '' res.on 'data', (chunk) -> data += chunk res.on 'end', -> cb data.toString() isnt 'packager-status:running' .on 'error', -> cb true .setTimeout 3000 sock.on 'error', -> sock.end() cb false ensureFreePort = (cb) -> checkPort rnPackagerPort, (inUse) -> if inUse logErr " Port #{rnPackagerPort} is currently in use by another process and is needed by the React Native packager. " cb() ensureXcode = (cb) -> try ensureExecutableAvailable 'xcodebuild' cb(); catch {message} if message.match /type.+xcodebuild/i logErr 'Xcode Command Line Tools are required' generateConfig = (name) -> log 'Creating Re-Natal config' config = name: name androidHost: "localhost" modules: [] imageDirs: ["images"] writeConfig config config writeConfig = (config) -> try fs.writeFileSync '.re-natal', JSON.stringify config, null, 2 catch {message} logErr \ if message.match /EACCES/i 'Invalid write permissions for creating .re-natal config file' else message verifyConfig = (config) -> if !config.androidHost? || !config.modules? || !config.imageDirs? throw new Error 're-natal project needs to be upgraded, please run: re-natal upgrade' config readConfig = (verify = true)-> try config = JSON.parse readFile '.re-natal' if (verify) verifyConfig(config) else config catch {message} logErr \ if message.match /ENOENT/i 'No Re-Natal config was found in this directory (.re-natal)' else if message.match /EACCES/i 'No read permissions for .re-natal' else if message.match /Unexpected/i '.re-natal contains malformed JSON' else message scanImageDir = (dir) -> fnames = fs.readdirSync(dir) .map (fname) -> "#{dir}/#{fname}" .filter (path) -> fs.statSync(path).isFile() .map (path) -> path.replace /@2x|@3x/i, '' .filter (v, idx, slf) -> slf.indexOf(v) == idx dirs = fs.readdirSync(dir) .map (fname) -> "#{dir}/#{fname}" .filter (path) -> fs.statSync(path).isDirectory() fnames.concat scanImages(dirs) scanImages = (dirs) -> imgs = [] for dir in dirs imgs = imgs.concat(scanImageDir(dir)); imgs configureDevHostForAndroidDevice = (deviceType) -> try allowedTypes = {'real': 'localhost', 'avd': '10.0.2.2', 'genymotion': '10.0.3.2'} devHost = allowedTypes[deviceType] if (! devHost?) throw new Error "Unknown android device type #{deviceType}, known types are #{Object.keys(allowedTypes)}" log "Using host '#{devHost}' for android device type '#{deviceType}'" config = readConfig() config.androidHost = devHost writeConfig(config) catch {message} logErr message copyDevEnvironmentFiles = (projNameHyph, projName, devHost) -> mkdirSync "env/dev" mkdirSync "env/dev/env" mkdirSync "env/dev/env/ios" mkdirSync "env/dev/env/android" userNsPath = "env/dev/user.clj" fs.copySync("#{resources}user.clj", userNsPath) mainIosDevPath = "env/dev/env/ios/main.cljs" mainAndroidDevPath = "env/dev/env/android/main.cljs" fs.copySync("#{resources}cljs/main_dev.cljs", mainIosDevPath) edit mainIosDevPath, [[projNameHyphRx, projNameHyph], [projNameRx, projName], [platformRx, "ios"], [devHostRx, devHost] ] fs.copySync("#{resources}cljs/main_dev.cljs", mainAndroidDevPath) edit mainAndroidDevPath, [[projNameHyphRx, projNameHyph], [projNameRx, projName], [platformRx, "android"], [devHostRx, devHost]] copyProdEnvironmentFiles = (projNameHyph, projName) -> mkdirSync "env/prod" mkdirSync "env/prod/env" mkdirSync "env/prod/env/ios" mkdirSync "env/prod/env/android" mainIosProdPath = "env/prod/env/ios/main.cljs" mainAndroidProdPath = "env/prod/env/android/main.cljs" fs.copySync("#{resources}cljs/main_prod.cljs", mainIosProdPath) edit mainIosProdPath, [[projNameHyphRx, projNameHyph], [projNameRx, projName], [platformRx, "ios"]] fs.copySync("#{resources}cljs/main_prod.cljs", mainAndroidProdPath) edit mainAndroidProdPath, [[projNameHyphRx, projNameHyph], [projNameRx, projName], [platformRx, "android"]] copyFigwheelBridge = (projNameUs) -> fs.copySync("#{resources}figwheel-bridge.js", "./figwheel-bridge.js") edit "figwheel-bridge.js", [[projNameUsRx, projNameUs]] updateGitIgnore = () -> fs.appendFileSync(".gitignore", "\n# Generated by re-natal\n#\nindex.android.js\nindex.ios.js\ntarget/\n") fs.appendFileSync(".gitignore", "\n# Figwheel\n#\nfigwheel_server.log") patchReactNativePackager = () -> log "Patching react-native packager to serve *.map files" edit "node_modules/react-native/packager/react-packager/src/Server/index.js", [[/match.*\.map\$\/\)/m, "match(/index\\..*\\.map$/)"]] init = (projName) -> if projName.toLowerCase() is 'react' or !projName.match validNameRx logErr 'Invalid project name. Use an alphanumeric CamelCase name.' projNameHyph = projName.replace(camelRx, '$1-$2').toLowerCase() projNameUs = toUnderscored projName try log "Creating #{projName}", 'bgMagenta' log '' if fs.existsSync projNameHyph throw new Error "Directory #{projNameHyph} already exists" ensureExecutableAvailable 'lein' log 'Creating Leiningen project' exec "lein new #{projNameHyph}" log 'Updating Leiningen project' process.chdir projNameHyph fs.copySync("#{resources}project.clj", "project.clj") edit \ 'project.clj', [ [projNameHyphRx, projNameHyph] ] fs.removeSync "resources" corePath = "src/#{projNameUs}/core.clj" fs.unlinkSync corePath handlersPath = "src/#{projNameUs}/handlers.cljs" subsPath = "src/#{projNameUs}/subs.cljs" dbPath = "src/#{projNameUs}/db.cljs" fs.copySync("#{resources}cljs/handlers.cljs", handlersPath) fs.copySync("#{resources}cljs/subs.cljs", subsPath) fs.copySync("#{resources}cljs/db.cljs", dbPath) edit handlersPath, [[projNameHyphRx, projNameHyph], [projNameRx, projName]] edit subsPath, [[projNameHyphRx, projNameHyph], [projNameRx, projName]] edit dbPath, [[projNameHyphRx, projNameHyph], [projNameRx, projName]] fs.mkdirSync 'src/cljsjs' fs.writeFileSync("src/cljsjs/react.cljs", "(ns cljsjs.react)") fs.mkdirSync "src/#{projNameUs}/android" fs.mkdirSync "src/#{projNameUs}/ios" coreAndroidPath = "src/#{projNameUs}/android/core.cljs" coreIosPath = "src/#{projNameUs}/ios/core.cljs" fs.copySync("#{resources}cljs/core.cljs", coreAndroidPath) edit coreAndroidPath, [[projNameHyphRx, projNameHyph], [projNameRx, projName], [platformRx, "android"]] fs.copySync("#{resources}cljs/core.cljs", coreIosPath) edit coreIosPath, [[projNameHyphRx, projNameHyph], [projNameRx, projName], [platformRx, "ios"]] fs.mkdirSync "env" copyDevEnvironmentFiles(projNameHyph, projName, "localhost") copyProdEnvironmentFiles(projNameHyph, projName) fs.copySync("#{resources}images", "./images") log 'Creating React Native skeleton. Relax, this takes a while...' fs.writeFileSync 'package.json', JSON.stringify name: projName version: '0.0.1' private: true scripts: start: 'node_modules/react-native/packager/packager.sh' dependencies: 'react-native': rnVersion , null, 2 exec 'npm i' fs.unlinkSync '.gitignore' exec "node -e \"require('react-native/local-cli/cli').init('.', '#{projName}')\" " patchReactNativePackager(); updateGitIgnore() generateConfig projName copyFigwheelBridge(projNameUs) log 'Compiling ClojureScript' exec 'lein prod-build' log '' log 'To get started with your new app, first cd into its directory:', 'yellow' log "cd #{projNameHyph}", 'inverse' log '' log 'Open iOS app in xcode and run it:' , 'yellow' log 're-natal xcode', 'inverse' log '' log 'To use figwheel type:' , 'yellow' log 're-natal use-figwheel', 'inverse' log 'lein figwheel ios', 'inverse' log '' log 'Reload the app in simulator' log '' log 'At the REPL prompt type this:', 'yellow' log "(in-ns '#{projNameHyph}.ios.core)", 'inverse' log '' log 'Changes you make via the REPL or by changing your .cljs files should appear live.', 'yellow' log '' log 'Try this command as an example:', 'yellow' log sampleCommand, 'inverse' log '' log '✔ Done', 'bgMagenta' log '' catch {message} logErr \ if message.match /type.+lein/i 'Leiningen is required (http://leiningen.org)' else if message.match /npm/i "npm install failed. This may be a network issue. Check #{projNameHyph}/npm-debug.log for details." else message openXcode = (name) -> try exec "open ios/#{name}.xcodeproj" catch {message} logErr \ if message.match /ENOENT/i """ Cannot find #{name}.xcodeproj in ios. Run this command from your project's root directory. """ else if message.match /EACCES/i "Invalid permissions for opening #{name}.xcodeproj in ios" else message generateRequireModulesCode = (modules) -> jsCode = "var modules={'react-native': require('react-native')};" for m in modules jsCode += "modules['#{m}']=require('#{m}');"; jsCode += '\n' updateFigwheelUrlForAndroid= (devHost) -> mainAndroidDevPath = "env/dev/env/android/main.cljs" edit mainAndroidDevPath, [[figwheelUrlRx, "ws://#{devHost}:"]] generateDevScripts = () -> try config = readConfig() projName = config.name depState = ckDeps.sync {install: false, verbose: false} if (!depState.depsWereOk) throw new Error "Missing dependencies, please run: re-natal deps" log 'Cleaning...' exec 'lein clean' images = scanImages(config.imageDirs).map (fname) -> './' + fname; modulesAndImages = config.modules.concat images; moduleMap = generateRequireModulesCode modulesAndImages androidDevHost = config.androidHost fs.writeFileSync 'index.ios.js', "#{moduleMap}require('figwheel-bridge').withModules(modules).start('#{projName}','ios','localhost');" log 'index.ios.js was regenerated' fs.writeFileSync 'index.android.js', "#{moduleMap}require('figwheel-bridge').withModules(modules).start('#{projName}','android','#{androidDevHost}');" log 'index.android.js was regenerated' updateFigwheelUrlForAndroid(androidDevHost) log 'Dev server host for iOS: localhost' log 'Dev server host for Android: ' + androidDevHost catch {message} logErr \ if message.match /EACCES/i 'Invalid write permissions for creating development scripts' else message doUpgrade = (config) -> projName = config.name; projNameHyph = projName.replace(camelRx, '$1-$2').toLowerCase() projNameUs = toUnderscored projName ckDeps.sync {install: true, verbose: false} patchReactNativePackager() copyDevEnvironmentFiles(projNameHyph, projName, "localhost") copyProdEnvironmentFiles(projNameHyph, projName) log 'upgraded files in env/' copyFigwheelBridge(projNameUs) log 'upgraded figwheel-bridge.js' if (!config.modules) config.modules = [] if (!config.imageDirs) config.imageDirs = ["images"] if (!config.androidHost) config.androidHost = "localhost" writeConfig(config) log 'upgraded .re-natal' edit "src/#{projNameUs}/ios/core.cljs", [[/\^:figwheel-load\s/g, ""]] edit "src/#{projNameUs}/android/core.cljs", [[/\^:figwheel-load\s/g, ""]] log 'upgraded core.cljs' edit '.gitignore', [[/^\s*env\/dev\s*$/m, ""]] gignore = readFile '.gitignore' if (!gignore.match /^\s*target\/\s*$/m) fs.appendFileSync(".gitignore", "\ntarget/\n") log 'upgraded .gitignore' useComponent = (name) -> log "Component '#{name}' is now configured for figwheel, please re-run 'use-figwheel' command to take effect" try config = readConfig() config.modules.push name writeConfig(config) catch {message} logErr message cli._name = 're-natal' cli.version pkgJson.version cli.command 'init ' .description 'create a new ClojureScript React Native project' .action (name) -> if typeof name isnt 'string' logErr ''' re-natal init requires a project name as the first argument. e.g. re-natal init HelloWorld ''' ensureFreePort -> init name cli.command 'upgrade' .description 'upgrades project files to current installed version of re-natal (the upgrade of re-natal itself is done via npm)' .action -> doUpgrade readConfig(false) cli.command 'xcode' .description 'open Xcode project' .action -> ensureOSX -> ensureXcode -> openXcode readConfig().name cli.command 'deps' .description 'install all dependencies for the project' .action -> ckDeps.sync {install: true, verbose: true} cli.command 'use-figwheel' .description 'generate index.ios.js and index.android.js for development with figwheel' .action () -> generateDevScripts() cli.command 'use-android-device ' .description 'sets up the host for android device type: \'real\' - localhost, \'avd\' - 10.0.2.2, \'genymotion\' - 10.0.3.2' .action (type) -> configureDevHostForAndroidDevice type cli.command 'use-component ' .description 'configures a custom component to work with figwheel. name is the value you pass to (js/require) function.' .action (name) -> useComponent(name) cli.command 'copy-figwheel-bridge' .description 'copy figwheel-bridge.js into project' .action () -> copyFigwheelBridge(readConfig(false).name) log "Copied figwheel-bridge.js" cli.on '*', (command) -> logErr "unknown command #{command[0]}. See re-natal --help for valid commands" unless semver.satisfies process.version[1...], nodeVersion logErr """ Re-Natal requires Node.js version #{nodeVersion} You have #{process.version[1...]} """ if process.argv.length <= 2 cli.outputHelp() else cli.parse process.argv