# Natal # Bootstrap ClojureScript React Native apps # Dan Motzenbecker # http://oxism.com # MIT License fs = require 'fs' 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/' camelRx = /([a-z])([A-Z])/g projNameRx = /\$PROJECT_NAME\$/g projNameHyphRx = /\$PROJECT_NAME_HYPHENATED\$/g projNameUnderRx = /\$PROJECT_NAME_UNDERSCORED\$/g rnVersion = '0.11.4' podMinVersion = '0.38.2' process.title = 'natal' 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] writeConfig = (config) -> try fs.writeFileSync '.natal', JSON.stringify config, null, 2 catch {message} logErr \ if message.match /EACCES/i 'Invalid write permissions for creating .natal config file' else message readConfig = -> try JSON.parse readFile '.natal' catch {message} logErr \ if message.match /ENOENT/i 'No Natal config was found in this directory (.natal)' else if message.match /EACCES/i 'No read permissions for .natal' else if message.match /Unexpected/i '.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) -> projNameHyph = projName.replace(camelRx, '$1-$2').toLowerCase() projNameUs = projName.replace(camelRx, '$1_$2').toLowerCase() 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' podVersion = exec('pod --version', true).toString().trim() unless semver.satisfies podVersion, ">=#{podMinVersion}" throw new Error """ 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 corePath += 's' exec "cp #{resources}core.cljs #{corePath}" edit corePath, [[projNameHyphRx, projNameHyph], [projNameRx, projName]] exec "cp #{resources}ambly.sh start.sh" edit 'start.sh', [[projNameUnderRx, projNameUs]] log 'Compiling ClojureScript' exec 'lein cljsbuild once dev' log 'Creating React Native skeleton' fs.mkdirSync 'native' process.chdir 'native' 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' exec " node -e \"process.argv[3]='#{projName}'; require('react-native/local-cli/init')('.', '#{projName}')\" " exec 'rm -rf android' 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\>/ """ """ ] ] log 'Creating Natal config' process.chdir '../..' config = name: projName device: pluckUuid getDeviceList().find (line) -> /iPhone 6/.test line writeConfig config launch config log '\nWhen Xcode appears, click the play button to run the app on the simulator.', 'yellow' log 'Then run the following for an interactive workflow:', 'yellow' log "cd #{projNameHyph}", 'inverse' log './start.sh', 'inverse' log 'First, choose the correct device (Probably [1]).', 'yellow' log 'At the REPL prompt type this:', 'yellow' log "(in-ns '#{projNameHyph}.core)", 'inverse' log 'Changes you make via the REPL or by changing your .cljs files should appear live.', 'yellow' log 'Try this command as an example:', 'yellow' log '(swap! app-state assoc :text "Hello Native World")', '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 message launch = ({name, device}) -> log 'Compiling Xcode project' try exec " xcodebuild -workspace native/ios/#{name}.xcworkspace -scheme #{name} -destination platform='iOS Simulator',OS=latest,id='#{device}' clean test " log 'Launching simulator' exec "xcrun simctl launch #{device} #{getBundleId name}" catch {message} logErr message 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 startRepl = (name) -> 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 lein = 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)] (cljs.repl/repl repl-env :watch \"src\" :watch-fn (fn [] (cljs.repl/load-file repl-env \"src/#{name}/core.cljs\")) :analyze-path \"src\")) """), cwd: process.cwd() env: process.env stdio: 'inherit' catch {message} logErr message cli._name = 'natal' cli.version pkgJson.version cli.command 'init ' .description 'Create a new ClojureScript React Native project' .action (name) -> if typeof name isnt 'string' logErr ''' natal init requires a project name as the first argument. e.g. natal init HelloWorld ''' init name cli.command 'launch' .description 'Compile project and run in simulator' .action -> launch readConfig() cli.command 'repl' .description 'Launch a ClojureScript REPL with background compilation' .action -> startRepl readConfig().name 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 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.on '*', (command) -> logErr "Unknown command #{command[0]}. See natal --help for valid commands" unless semver.satisfies process.version[1...], nodeVersion logErr """ Natal requires Node.js version #{nodeVersion} You have #{process.version[1...]} """ if process.argv.length <= 2 cli.outputHelp() else cli.parse process.argv