# Re-Natal # Bootstrap ClojureScript React Native apps # Dan Motzenbecker # http://oxism.com # MIT License fs = require 'fs' net = require 'net' http = require 'http' crypto = require 'crypto' child = require 'child_process' cli = require 'commander' chalk = require 'chalk' semver = require 'semver' 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 rnPackagerPort = 8081 podMinVersion = '0.38.2' process.title = 're-natal' sampleCommand = '(re-frame.core/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' readFile = (path) -> fs.readFileSync path, encoding: 'ascii' edit = (path, pairs) -> fs.writeFileSync path, pairs.reduce (contents, [rx, replacement]) -> contents.replace rx, replacement , readFile path pluckUuid = (line) -> line.match(/\[(.+)\]/)[1] getUuidForDevice = (deviceName) -> device = getDeviceList().find (line) -> line.match deviceName unless device logErr "Cannot find device `#{deviceName}`" pluckUuid device 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() generateConfig = (name) -> log 'Creating Re-Natal config' config = name: name device: getUuidForDevice 'iPhone 6s' 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 readConfig = -> try JSON.parse readFile '.re-natal' 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 getBundleId = (name) -> try if line = readFile "native/ios/#{name}.xcodeproj/project.pbxproj" .match /PRODUCT_BUNDLE_IDENTIFIER = (.+);/ line[1] else if line = readFile "native/ios/#{name}/Info.plist" .match /\CFBundleIdentifier\<\/key\>\n?\s*\(.+)\<\/string\>/ rfcIdRx = /\$\(PRODUCT_NAME\:rfc1034identifier\)/ if line[1].match rfcIdRx line[1].replace rfcIdRx, name else line[1] else throw new Error 'Cannot find bundle identifier in project.pbxproj or Info.plist' catch {message} logErr message 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" exec 'type lein' exec 'type pod' exec 'type watchman' exec 'type xcodebuild' podVersion = exec('pod --version', true).toString().trim() unless semver.satisfies podVersion, ">=#{podMinVersion}" throw new Error """ Re-Natal requires CocoaPods #{podMinVersion} or higher (you have #{podVersion}). Run [sudo] gem update cocoapods and try again. """ log 'Creating Leiningen project' exec "lein new #{projNameHyph}" log 'Updating Leiningen project' process.chdir projNameHyph exec "cp #{resources}project.clj project.clj" edit \ 'project.clj', [ [projNameHyphRx, projNameHyph] ] corePath = "src/#{projNameUs}/core.clj" fs.unlinkSync corePath handlersPath = "src/#{projNameUs}/handlers.cljs" subsPath = "src/#{projNameUs}/subs.cljs" exec "cp #{resources}handlers.cljs #{handlersPath}" exec "cp #{resources}subs.cljs #{subsPath}" edit handlersPath, [[projNameHyphRx, projNameHyph], [projNameRx, projName]] edit subsPath, [[projNameHyphRx, projNameHyph], [projNameRx, projName]] fs.mkdirSync 'src/cljsjs' exec "echo '(ns cljsjs.react)' > src/cljsjs/react.cljs" fs.mkdirSync 'src-android' fs.mkdirSync "src-android/#{projNameUs}" fs.mkdirSync "src-android/#{projNameUs}/android" fs.mkdirSync 'src-ios' fs.mkdirSync "src-ios/#{projNameUs}" fs.mkdirSync "src-ios/#{projNameUs}/ios" coreAndroidPath = "src-android/#{projNameUs}/android/core.cljs" coreIosPath = "src-ios/#{projNameUs}/ios/core.cljs" exec "cp #{resources}core-android.cljs #{coreAndroidPath}" edit coreAndroidPath, [[projNameHyphRx, projNameHyph], [projNameRx, projName]] exec "cp #{resources}core-ios.cljs #{coreIosPath}" edit coreIosPath, [[projNameHyphRx, projNameHyph], [projNameRx, projName]] log 'Creating React Native skeleton' exec "react-native init #{projName}" exec "mv #{projName} native" process.chdir 'native' fs.unlinkSync 'index.android.js' log 'Installing Pod dependencies' process.chdir 'ios' exec "cp #{resources}Podfile ." exec 'pod install' log 'Updating Xcode project' for ext in ['m', 'h'] path = "#{projName}/AppDelegate.#{ext}" exec "cp #{resources}AppDelegate.#{ext} #{path}" edit path, [[projNameRx, projName], [projNameHyphRx, projNameHyph]] uuid1 = crypto .createHash 'md5' .update projName, 'utf8' .digest('hex')[...24] .toUpperCase() uuid2 = uuid1.split '' uuid2.splice 7, 1, ((parseInt(uuid1[7], 16) + 1) % 16).toString(16).toUpperCase() uuid2 = uuid2.join '' edit \ "#{projName}.xcodeproj/project.pbxproj", [ [ /OTHER_LDFLAGS = "-ObjC";/g 'OTHER_LDFLAGS = "${inherited}";' ] [ /\/\* End PBXBuildFile section \*\// "\t\t#{uuid2} /* out in Resources */ = {isa = PBXBuildFile; fileRef = #{uuid1} /* out */; }; \n/* End PBXBuildFile section */" ] [ /\/\* End PBXFileReference section \*\// "\t\t#{uuid1} /* out */ = {isa = PBXFileReference; lastKnownFileType = folder; name = out; path = ../../target/out; sourceTree = \"\"; };\n/* End PBXFileReference section */" ] [ /main.jsbundle \*\/\,/ "main.jsbundle */,\n\t\t\t\t#{uuid1} /* out */," ] [ /\/\* LaunchScreen.xib in Resources \*\/\,/ "/* LaunchScreen.xib in Resources */, \n\t\t\t\t#{uuid2} /* out in Resources */," ] ] testId = readFile("#{projName}.xcodeproj/project.pbxproj") .match(new RegExp "([0-9A-F]+) \/\\* #{projName}Tests \\*\/ = \\{")[1] edit \ "#{projName}.xcodeproj/xcshareddata/xcschemes/#{projName}.xcscheme", [ [ /\\n\s*\<\/Testables\>/ """ """ ] ] process.chdir '../..' launch generateConfig projName log '' log 'To get started with your new app, first cd into its directory:', 'yellow' log "cd #{projNameHyph}", 'inverse' log '' log 'Boot the REPL by typing:', 'yellow' log 're-natal repl', 'inverse' 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 /type.+pod/i 'CocoaPods is required (https://cocoapods.org)' else if message.match /type.+watchman/i 'Watchman is required (https://facebook.github.io/watchman)' else if message.match /type.+xcodebuild/i 'Xcode Command Line Tools are required' else if message.match /npm/i "npm install failed. This may be a network issue. Check #{projNameHyph}/native/npm-debug.log for details." else message launch = ({name, device}) -> unless device in getDeviceUuids() log 'Device ID not available, defaulting to iPhone 6s simulator', 'yellow' {device} = generateConfig name try fs.statSync 'native/node_modules' fs.statSync 'native/ios/Pods' catch logErr 'Dependencies are missing. Run re-natal deps to install them.' log 'Compiling ClojureScript' exec 'lein cljsbuild once dev' log 'Compiling Xcode project' try exec " xcodebuild -workspace native/ios/#{name}.xcworkspace -scheme #{name} -destination platform='iOS Simulator',OS=latest,id='#{device}' test " log 'Launching simulator' exec "xcrun simctl launch #{device} #{getBundleId name}" catch {message} logErr message runAndroid = -> log 'Compiling ClojureScript' exec 'lein cljsbuild once android' process.chdir 'native' log 'Running application in running Android simulator or connected device' exec 'react-native run-android' openXcode = (name) -> try exec "open native/ios/#{name}.xcworkspace" catch {message} logErr \ if message.match /ENOENT/i """ Cannot find #{name}.xcworkspace in native/ios. Run this command from your project's root directory. """ else if message.match /EACCES/i "Invalid permissions for opening #{name}.xcworkspace in native/ios" else message getDeviceList = -> try exec 'xcrun instruments -s devices', true .toString() .split '\n' .filter (line) -> /^i/.test line catch {message} logErr 'Device listing failed: ' + message getDeviceUuids = -> getDeviceList().map (line) -> line.match(/\[(.+)\]/)[1] startRepl = (name, autoChoose) -> log 'Starting REPL' hasRlwrap = try exec 'type rlwrap' true catch log ' Warning: rlwrap is not installed.\nInstall it to make the REPL a much better experience with arrow key support. ', 'red' false try child.spawn (if hasRlwrap then 'rlwrap' else 'lein'), "#{if hasRlwrap then 'lein ' else ''}trampoline run -m clojure.main -e" .split(' ').concat( """ (require '[cljs.repl :as repl]) (require '[ambly.core :as ambly]) (let [repl-env (ambly.core/repl-env#{if autoChoose then ' :choose-first-discovered true' else ''})] (cljs.repl/repl repl-env :watch \"src\" :watch-fn (fn [] (cljs.repl/load-file repl-env \"src/#{toUnderscored name}/core.cljs\")) :analyze-path \"src\")) """), cwd: process.cwd() env: process.env stdio: 'inherit' 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 'launch' .description 'compile project and run in iOS simulator' .action -> ensureFreePort -> launch readConfig() cli.command 'run-android' .description 'compile project and run in Android simulator or connected device' .action -> runAndroid() cli.command 'repl' .description 'launch a ClojureScript REPL with background compilation' .option '-c, --choose', 'choose target device from list' .action (cmd) -> startRepl readConfig().name, !cmd.choose cli.command 'listdevices' .description 'list available simulator devices by index' .action -> console.log (getDeviceList() .map (line, i) -> "#{i}\t#{line.replace /\[.+\]/, ''}" .join '\n') cli.command 'setdevice ' .description 'choose simulator device by index' .action (index) -> unless device = getDeviceList()[parseInt index, 10] logErr 'Invalid device index. Run re-natal listdevices for valid indexes.' config = readConfig() config.device = pluckUuid device writeConfig config cli.command 'xcode' .description 'open Xcode project' .action -> openXcode readConfig().name cli.command 'deps' .description 'install all dependencies for the project' .action -> try process.chdir 'native' log 'Installing npm packages' exec 'npm i' log 'Installing pods' process.chdir 'ios' exec 'pod install' catch {message} logErr message 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