Ind.ie is now Small Technology Foundation.
Builder.coffee 8.76 KB
Newer Older
1 2
################################################################################
#
3 4
#	Better
#
5
#	Builds the Better data for the site and the apps.
6
#
7
#	Updates the repositories, runs the Blockdown parser on the content, and
8 9
#	applies the respective theme.
#
10 11 12
#	This is Independent Technology.
#
#	▲❤ We practice Ethical Design (https://ind.ie/ethical-design)
13
#
14 15
#	© Aral Balkan. © Ind.ie. All Rights Reserved.
#	Released with love by Ind.ie under GNU AGPLv3 or later.
16 17 18 19 20 21 22 23 24 25 26 27 28
#	Free as in freedom. Please see the LICENSE file.
#
################################################################################

spawn = require('child-process-promise').spawn
path = require 'path-extra'

winston = require 'winston'

app = require './App'
Blockdown = require './Blockdown'
NodeGit = require 'nodegit'

29 30
exec = (require 'child_process').exec

31 32
touch = require 'touch'

33 34 35
class Builder
	instance = null

36 37 38
	# This is the authorisation object we send
	authorisation: null

39 40 41 42
	sshPath: null
	privateSSHKeyPath: null
	publicSSHKeyPath: null

43 44 45 46 47 48
	constructor: ->
		# Singleton access.
		if instance
			return instance

		# Set up logging.
49
		logFile = path.join app.logsDirectory, 'Builder.log'
50 51 52
		logger = new winston.Logger
			transports:
				[
53
					new (winston.transports.Console)({ level: 'info' , formatter: (options) -> return options.message})
54 55 56 57
					new (winston.transports.File)({ filename: logFile, level: 'debug' })
				]
		logger.extend @

58
		# Convenience: important paths
59
		@sshPath = path.join app.privateDirectory, 'ssh'
60 61 62
		@privateSSHKeyPath = path.join @sshPath, 'id_rsa'
		@publicSSHKeyPath = path.join @sshPath, 'id_rsa.pub'

63 64 65 66 67 68 69 70 71 72
		# Create the authorisation object (that we use when making calls to the origin remotes
		# on source.ind.ie)
		@authorisation =
			callbacks:
				credentials: (url, userName) =>
					@info "Providing credentials…"
					NodeGit.Cred.sshKeyNew userName, @publicSSHKeyPath, @privateSSHKeyPath, ''
				# certificateCheck: () ->
				# 	return 1

73 74 75 76 77 78 79 80 81 82
		# Save reference to the singleton instance and return it.
		instance = @
		return instance


	#
	# Start a build.
	#
	build: =>
		@pullContentRepository()
83
			.then @pullThemesRepository
84 85
			.then @renderBlockdown
			.then @updateRepositories
86
			.then =>
87 88
				@info '❤ Blockdown content is ready.'

89 90 91 92
				# If the app is running in development, fire off a
				# notification to let people know that rendering is done.
				exec "osascript -e 'display notification \"♥ Ready\" with title \"Better\" sound name \"Ping\"'"

93 94 95 96 97
				# Touch a file to signal that the build is complete.
				# (Used by the installer on the first run to know
				# when to continue with the installation process.)
				touch.sync (path.join app.homeDirectory, '.private', 'last-build-stamp')

98
		.catch (e) =>
99 100 101 102
			# If the app is running in development, fire off a
			# notification to let people know that rendering is done.
			exec "osascript -e 'display notification \"✗ Error\" with title \"Better\" sound name \"Basso\"'"

103 104 105 106
			@error "Build error.\n\n#{e}"
			console.trace()
			process.exit 1

107 108

	#
109
	# Render content in Blockdown format.
110 111
	#
	renderBlockdown: =>
112
		@info 'Rendering content…'
113 114
		blockdown = new Blockdown
		blockdown.renderDataForSite().then =>
115
			@info "\t✓ Rendered content for site."
116
			blockdown.renderDataForApp().then =>
117
				@info "\t✓ Rendered content for data."
118 119 120 121 122 123 124


	#
	# Returns the latest content repository changes.
	# (Fetches and merges the latest changes from the content repository.)
	#
	pullContentRepository: =>
125
		@debug 'Pulling content repository.'
126
		return @pullRepository app.contentDirectory
127 128
			.then ->
				app.refreshContentIndex()
129 130 131 132 133


	#
	# Pulls the theme repository at the given path and then refreshes the themes.
	#
134 135
	pullThemesRepository: (repositoryDirectory) =>
		@pullRepository app.themesDirectory
136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155
			.then ->
				app.loadThemes()

	#
	# Returns the latest content repository changes for the repository at the given path.
	# (Fetches and merges the latest changes.)
	#
	pullRepository: (repositoryDirectory) =>
		repository = null

		# Perform a pull (fetch + merge), based on
		# https://github.com/nodegit/nodegit/blob/master/examples/pull.js
		NodeGit.Repository.open repositoryDirectory
			.then (repo) =>
				# Fetch
				repository = repo
				return repository.fetch 'origin', @authorisation
			.then =>
				# Merge
				repository.mergeBranches 'master', 'origin/master'
156 157 158 159
			.catch (e) =>
				@error "Build error.\n\n#{e}"
				console.trace()
				process.exit 1
160 161 162 163 164 165

	#
	# Updates the data for site and apps repositories.
	#
	updateRepositories: =>

166
		@info 'Updating the data repositories for the app and the site…'
167 168 169

		@updateRepository app.dataForSiteDirectory
			.then =>
170
				@updateRepository app.dataForAppDirectory
171
			.then =>
172
				@info "\t ✓ Data repository updates complete."
173 174 175 176 177 178 179 180 181


	#
	# Update the requested repository at the repository path and push to the remote.
	#
	updateRepository: (repositoryPath) =>

		repository = null
		index = null
182
		signature = NodeGit.Signature.now 'Better Builder', 'hello+better+builder@ind.ie'
183 184 185 186 187 188

		indexTreeOid = null
		siteURL = null

		NodeGit.Repository.open repositoryPath
			.then (repositoryResult) =>
189
				@debug "Update: got repository open result for #{repositoryPath}"
190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230
				repository = repositoryResult
				repository.openIndex()
			.then (indexResult) =>
				@debug 'Update: got index result'
				index = indexResult
				index.read 1	# Force a read of the index into memory.
				# The ADD_CHECK_PATHSPEC should make addAll function like
				# git add --all (according to https://libgit2.github.com/libgit2/#HEAD/group/index/git_index_add_all)
				index.addAll '*', NodeGit.Index.ADD_OPTION.ADD_CHECK_PATHSPEC
				# DEBUG: Dry run
				# index.addAll '*', NodeGit.Index.ADD_OPTION.ADD_CHECK_PATHSPEC, (path, matchedPathSpec, payload) =>
				# 	@info "Path:"
				# 	@info path
				# 	@info "matchedPathSpec:"
				# 	@info matchedPathSpec
				# 	@info "payload:"
				# 	@info payload
				# 	return 1

			.then (result) =>
				writeResult = index.write() # sync
				index.writeTree()
			.then (writeTreeOidResult) =>
				indexTreeOid = writeTreeOidResult
				# Get the parent reference in the next two steps
				# (Courtesy Rafael: http://stackoverflow.com/a/28890806/253485)
				NodeGit.Reference.nameToId repository, 'HEAD'
			.then (head) =>
				repository.getCommit head
			.then (parent) =>
				repository.createCommit 'HEAD', signature, signature, 'Latest generated content.', indexTreeOid, [parent]
			.then (commitOidResult) =>
				NodeGit.Remote.lookup repository, 'origin'
			.then (origin) =>
				# NodeGit.Remote.initCallbacks @authorisation
				origin.push ['refs/heads/master:refs/heads/master'], @authorisation
					# null,
					# repository.defaultSignature()
					# 'Push to master'
			.then (pushResult) =>
				# If this is the data for the site, let’s also push it to the site to trigger a deployment.
231
				# TODO: In the future, when we have silent notifications implemented for the iOS app,
232 233 234 235
				# this is also when we’ll trigger a push for those.
				if repositoryPath == app.dataForSiteDirectory
					@pushToSite repository
			.then =>
236
				@debug "Repository update complete for: #{repositoryPath}"
237 238
			.catch ((e) =>
				@error "Git error: #{e}")
239

240 241 242 243 244 245 246 247 248 249 250

	#
	# Pushes to the site.
	#
	pushToSite: (repository) =>

		siteURL = null

		NodeGit.Remote.lookup repository, 'site'
			.then (site) =>
				siteURL = site.url()
251
				@debug "Got site: #{siteURL}"
252 253 254 255 256 257 258

				# NodeGit.Remote.initCallbacks @authorisation
				site.push ['refs/heads/master:refs/heads/master'], @authorisation
					# null,
					# repository.defaultSignature()
					# 'Push to master'
			.then (pushResult) =>
259
				@debug "Site push result: #{pushResult}"
260 261 262 263 264 265 266 267

				# If we pushed locally, we must manually trigger the post-receive hook.
				# (Nodegit — actually libgit2 — doesn’t trigger web hooks but, according to
				# the discussion below, it would not have triggered the post-receive hook
				# anyway as that’s the responsibility of the app.)
				#
				# (See https://github.com/libgit2/libgit2/issues/964#issuecomment-109998760)
				if siteURL[0] == '/'
268
					siteLocalPostReceiveHooksPath = path.join app.homeDirectory, '.private', 'site-local', 'data.git', 'hooks'
269
					siteLocalPostReceiveHookPath = path.join siteLocalPostReceiveHooksPath, 'post-receive'
270
					@debug "We pushed locally, about to manually trigger the post-receive hook at #{siteLocalPostReceiveHookPath}"
271 272
					spawn siteLocalPostReceiveHookPath, [], {cwd:siteLocalPostReceiveHooksPath}
						.progress (childProcess) =>
273
							@debug "Child process running with ID: #{childProcess.pid}"
274
							childProcess.stdout.on 'data', (data) =>
275
								@debug "Child stdout: #{data.toString()}"
276
							childProcess.stderr.on 'data', (data) =>
277
								@debug "Child stderr: #{data.toString()}"
278
						.then =>
279
							@debug "Local push complete."
280 281 282
						.fail (error) =>
							@error "Local push failed with error: #{error}"

283
				@debug 'Completed update.'
284 285

module.exports = Builder