UNPKG

22.7 kBtext/coffeescriptView Raw
1
2fs = require 'fs'
3path = require 'path'
4fs.exists ?= path.exists
5util = require 'util'
6each = require 'each'
7eco = require 'eco'
8rimraf = require 'rimraf'
9exec = require('child_process').exec
10open = require 'open-uri'
11
12conditions = require './conditions'
13misc = require './misc'
14
15###
16
17Mecano gather a set of functions usually used during system deployment. All the functions share a
18common API with flexible options.
19
20###
21mecano = module.exports =
22 ###
23
24 `cp` `copy(options, callback)`
25 ------------------------------
26
27 Copy a file.
28
29 `options` Command options include:
30
31 * `source` The file or directory to copy.
32 * `destination` Where the file or directory is copied.
33 * `force` Copy the file even if one already exists.
34 * `not_if_exists` Equals destination if true.
35 * `chmod` Permissions of the file or the parent directory
36
37 `callback` Received parameters are:
38
39 * `err` Error object if any.
40 * `copied` Number of files or parent directories copied.
41
42 todo:
43 * deal with directories
44 * preserve permissions if `chmod` is `true`
45 * Compare files with checksum
46
47 ###
48 copy: (options, callback) ->
49 options = misc.options options
50 copied = 0
51 each( options )
52 .on 'item', (options, next) ->
53 return next new Error 'Missing source' unless options.source
54 return next new Error 'Missing destination' unless options.destination
55 options.not_if_exists = options.destination if options.not_if_exists is true
56 dstStat = null
57 source = ->
58 fs.stat options.source, (err, stat) ->
59 # Source does not exists or any error occured
60 return next err if err
61 return next new Error 'Source is a directory' if stat.isDirectory()
62 copy()
63 copy = (destination = options.destination) ->
64 fs.stat destination, (err, stat) ->
65 dstStat = stat
66 return next err if err and err.code isnt 'ENOENT'
67 # do not copy if destination exists
68 dirExists = not err and stat.isDirectory()
69 fileExists = not err and stat.isFile()
70 return next null, 0 if fileExists and not options.force
71 # Update destination name and call copy again
72 return copy path.resolve options.destination, path.basename options.source if dirExists
73 # Copy
74 input = fs.createReadStream options.source
75 output = fs.createWriteStream destination
76 util.pump input, output, (err) ->
77 return next err if err
78 chmod()
79 chmod = ->
80 return finish() if not options.chmod or options.chmod is dstStat.mode
81 fs.chmod options.destination, options.chmod, (err) ->
82 return next err if err
83 finish()
84 finish = ->
85 copied++
86 next()
87 conditions.all(options, next, copy)
88 .on 'both', (err) ->
89 callback err, copied
90 ###
91
92 `download(options, callback)`
93 -----------------------------
94
95 Download files using various protocols. The excellent
96 [open-uri](https://github.com/publicclass/open-uri) module provides support for HTTP(S),
97 file and FTP. All the options supported by open-uri are passed to it.
98
99 Note, GIT is not yet supported but documented as a wished feature.
100
101 `options` Command options include:
102
103 * `source` File, HTTP URL, FTP, GIT repository. File is the default protocol if source is provided without a scheme.
104 * `destination` Path where the file is downloaded.
105 * `force` Overwrite destination file if it exists.
106
107 `callback` Received parameters are:
108
109 * `err` Error object if any.
110 * `downloaded` Number of downloaded files
111
112 Basic example:
113 mecano.download
114 source: 'https://github.com/wdavidw/node-sigar/tarball/v0.0.1'
115 destination: 'node-sigar.tgz'
116 , (err, downloaded) ->
117 fs.exists 'node-sigar.tgz', (exists) ->
118 assert.ok exists
119
120 ###
121 download: (options, callback) ->
122 options = misc.options options
123 downloaded = 0
124 each( options )
125 .on 'item', (options, next) ->
126 return next new Error "Missing source: #{options.source}" unless options.source
127 return next new Error "Missing destination: #{options.destination}" unless options.destination
128 options.force ?= false
129 download = () ->
130 destination = fs.createWriteStream(options.destination)
131 open(options.source, destination)
132 destination.on 'close', () ->
133 downloaded++
134 next()
135 destination.on 'error', (err) ->
136 next err
137 # fs.exists options.destination, (exists) ->
138 # # Use previous download
139 # if exists and not options.force
140 # return next()
141 # # Remove previous dowload and download again
142 # else if exists
143 # return rimraf options.destination, (err) ->
144 # return next err if err
145 # download()
146 # else download()
147 fs.exists options.destination, (exists) ->
148 # Use previous download
149 if exists and not options.force
150 next()
151 # Remove previous dowload and download again
152 else if exists
153 rimraf options.destination, (err) ->
154 return next err if err
155 download()
156 else download()
157 .on 'both', (err) ->
158 callback err, downloaded
159 ###
160
161 `exec` `execute([goptions], options, callback)`
162 -----------------------------------------------
163 Run a command locally or with ssh if the `host` is provided. Global options is
164 optional and is used in case where options is defined as an array of
165 multiple commands. Note, `opts` inherites all the properties of `goptions`.
166
167 `goptions` Global options includes:
168
169 * `parallel` Wether the command are run in sequential, parallel
170 or limited concurrent mode. See the `node-each` documentation for more
171 details. Default to sequential (false).
172
173 `options` Include all conditions as well as:
174
175 * `cmd` String, Object or array; Command to execute.
176 * `env` Environment variables, default to `process.env`.
177 * `cwd` Current working directory.
178 * `uid` Unix user id.
179 * `gid` Unix group id.
180 * `code` Expected code(s) returned by the command, int or array of int, default to 0.
181 * `host` SSH host or IP address.
182 * `username` SSH host or IP address.
183 * `stdout` Writable EventEmitter in which command output will be piped.
184 * `stderr` Writable EventEmitter in which command error will be piped.
185
186 `callback` Received parameters are:
187
188 * `err` Error if any.
189 * `executed` Number of executed commandes.
190 * `stdout` Stdout value(s) unless `stdout` option is provided.
191 * `stderr` Stderr value(s) unless `stderr` option is provided.
192
193 ###
194 execute: (goptions, options, callback) ->
195 if arguments.length is 2
196 callback = options
197 options = goptions
198 isArray = Array.isArray options
199 options = misc.options options
200 executed = 0
201 stdouts = []
202 stderrs = []
203 escape = (cmd) ->
204 esccmd = ''
205 for char in cmd
206 if char is '$'
207 esccmd += '\\'
208 esccmd += char
209 esccmd
210 each( options )
211 .parallel( goptions.parallel )
212 .on 'item', (option, i, next) ->
213 option = { cmd: option } if typeof option is 'string'
214 misc.merge true, option, goptions
215 return next new Error "Missing cmd: #{option.cmd}" unless option.cmd?
216 option.code ?= [0]
217 option.code = [option.code] unless Array.isArray option.code
218 cmdOption = {}
219 cmdOption.env = option.env or process.env
220 cmdOption.cwd = option.cwd or null
221 cmdOption.uid = option.uid if options.uid
222 cmdOption.gid = option.gid if options.gid
223 cmd = () ->
224 if option.host
225 option.cmd = escape option.cmd
226 option.cmd = option.host + ' "' + option.cmd + '"'
227 if option.username
228 option.cmd = option.username + '@' + option.cmd
229 option.cmd = 'ssh -o StrictHostKeyChecking=no ' + option.cmd
230 run = exec option.cmd, cmdOption
231 stdout = stderr = ''
232 if option.stdout
233 then run.stdout.pipe option.stdout
234 else run.stdout.on 'data', (data) ->
235 stdout += data
236 if option.stderr
237 then run.stderr.pipe option.stderr
238 else run.stderr.on 'data', (data) -> stderr += data
239 run.on "exit", (code) ->
240 # Givent some time because the "exit" event is sometimes
241 # called before the "stdout" "data" event when runing
242 # `make test`
243 setTimeout ->
244 executed++
245 stdouts.push if option.stdout then undefined else stdout
246 stderrs.push if option.stderr then undefined else stderr
247 if option.code.indexOf(code) is -1
248 err = new Error "Invalid exec code #{code}"
249 err.code = code
250 return next err
251 next()
252 , 1
253 # if option.not_if_exists
254 # fs.exists option.not_if_exists, (exists) ->
255 # if exists then next() else cmd()
256 # else
257 # cmd()
258 conditions.all(option, next, cmd)
259 .on 'both', (err) ->
260 stdouts = stdouts[0] unless isArray
261 stderrs = stderrs[0] unless isArray
262 callback err, executed, stdouts, stderrs
263 ###
264
265 `extract(options, callback)`
266 ----------------------------
267
268 Extract an archive. Multiple compression types are supported. Unless
269 specified asan option, format is derived from the source extension. At the
270 moment, supported extensions are '.tgz', '.tar.gz' and '.zip'.
271
272 `options` Command options include:
273
274 * `source` Archive to decompress.
275 * `destination` Default to the source parent directory.
276 * `format` One of 'tgz' or 'zip'.
277 * `creates` Ensure the given file is created or an error is send in the callback.
278 * `not_if_exists` Cancel extraction if file exists.
279
280 `callback` Received parameters are:
281
282 * `err` Error object if any.
283 * `extracted` Number of extracted archives.
284
285 ###
286 extract: (options, callback) ->
287 options = misc.options options
288 extracted = 0
289 each( options )
290 .on 'item', (options, next) ->
291 return next new Error "Missing source: #{options.source}" unless options.source
292 destination = options.destination ? path.dirname options.source
293 # Deal with format option
294 if options.format?
295 format = options.format
296 else
297 if /\.(tar\.gz|tgz)$/.test options.source
298 format = 'tgz'
299 else if /\.zip$/.test options.source
300 format = 'zip'
301 else
302 ext = path.extname options.source
303 return next new Error "Unsupported extension, got #{JSON.stringify(ext)}"
304 # Working step
305 extract = () ->
306 cmd = null
307 switch format
308 when 'tgz' then cmd = "tar xzf #{options.source} -C #{destination}"
309 when 'zip' then cmd = "unzip -u #{options.source} -d #{destination}"
310 exec cmd, (err, stdout, stderr) ->
311 return next err if err
312 creates()
313 # Step for `creates`
314 creates = () ->
315 return success() unless options.creates?
316 fs.exists options.creates, (exists) ->
317 return next new Error "Failed to create '#{path.basename options.creates}'" unless exists
318 success()
319 # Final step
320 success = () ->
321 extracted++
322 next()
323 conditions.all(options, next, extract)
324 .on 'both', (err) ->
325 callback err, extracted
326 ###
327
328 `git`
329 -----
330
331 `options` Command options include:
332
333 * `source` Git source repository address.
334 * `destination` Directory where to clone the repository.
335 * `revision` Git revision, branch or tag.
336
337 ###
338 git: (options, callback) ->
339 options = misc.options options
340 updated = 0
341 each( options )
342 .on 'item', (options, next) ->
343 options.revision ?= 'HEAD'
344 rev = null
345 prepare = ->
346 fs.stat options.destination, (err, stat) ->
347 return clone() if err and err.code is 'ENOENT'
348 return next new Error "Destination not a directory, got #{options.destination}" unless stat.isDirectory()
349 gitDir = "#{options.destination}/.git"
350 fs.stat gitDir, (err, stat) ->
351 return next err if err or not stat.isDirectory()
352 log()
353 clone = ->
354 mecano.exec
355 cmd: "git clone #{options.source} #{path.basename options.destination}"
356 cwd: path.dirname options.destination
357 , (err, executed, stdout, stderr) ->
358 return next err if err
359 checkout()
360 log = ->
361 mecano.exec
362 cmd: "git log --pretty=format:'%H' -n 1"
363 cwd: options.destination
364 , (err, executed, stdout, stderr) ->
365 return next err if err
366 current = stdout.trim()
367 mecano.exec
368 cmd: "git rev-list --max-count=1 #{options.revision}"
369 cwd: options.destination
370 , (err, executed, stdout, stderr) ->
371 return next err if err
372 if stdout.trim() isnt current
373 then checkout()
374 else next()
375 checkout = ->
376 mecano.exec
377 cmd: "git checkout #{options.revision}"
378 cwd: options.destination
379 , (err) ->
380 return next err if err
381 updated++
382 next()
383 conditions.all options, next, prepare
384 .on 'both', (err) ->
385 callback err, updated
386
387 ###
388
389 `ln` `link(options, callback)`
390 ------------------------------
391 Create a symbolic link and it's parent directories if they don't yet
392 exist.
393
394 `options` Command options include:
395
396 * `source` Referenced file to be linked.
397 * `destination` Symbolic link to be created.
398 * `exec` Create an executable file with an `exec` command.
399 * `chmod` Default to 0755.
400
401 `callback` Received parameters are:
402
403 * `err` Error object if any.
404 * `linked` Number of created links.
405
406 ###
407 link: (options, callback) ->
408 options = misc.options options
409 linked = 0
410
411 sym_exists = (option, callback) ->
412 fs.exists option.destination, (exists) ->
413 return callback null, false unless exists
414 fs.readlink option.destination, (err, resolvedPath) ->
415 return callback err if err
416 return callback null, true if resolvedPath is option.source
417 fs.unlink option.destination, (err) ->
418 return callback err if err
419 callback null, false
420 sym_create = (option, callback) ->
421 fs.symlink option.source, option.destination, (err) ->
422 return callback err if err
423 linked++
424 callback()
425 exec_exists = (option, callback) ->
426 fs.exists option.destination, (exists) ->
427 return callback null, false unless exists
428 fs.readFile option.destination, 'ascii', (err, content) ->
429 return callback err if err
430 exec_cmd = /exec (.*) \$@/.exec(content)[1]
431 callback null, exec_cmd and exec_cmd is option.source
432 exec_create = (option, callback) ->
433 content = """
434 #!/bin/bash
435 exec #{option.source} $@
436 """
437 fs.writeFile option.destination, content, (err) ->
438 return callback err if err
439 fs.chmod option.destination, option.chmod, (err) ->
440 return callback err if err
441 linked++
442 callback()
443 parents = for option in options then path.normalize path.dirname option.destination
444 mecano.mkdir parents, (err, created) ->
445 return callback err if err
446 each( options )
447 .parallel( true )
448 .on 'item', (option, next) ->
449 return next new Error "Missing source, got #{JSON.stringify(option.source)}" unless option.source
450 return next new Error "Missing destination, got #{JSON.stringify(option.destination)}" unless option.destination
451 option.chmod ?= 0o0755
452 dispatch = ->
453 if option.exec
454 exec_exists option, (err, exists) ->
455 return next() if exists
456 exec_create option, next
457 else
458 sym_exists option, (err, exists) ->
459 return next() if exists
460 sym_create option, next
461 dispatch()
462 .on 'both', (err) ->
463 callback err, linked
464 ###
465
466 `mkdir(options, callback)`
467 --------------------------
468
469 Recursively create a directory. The behavior is similar to the Unix command `mkdir -p`.
470 It supports an alternative syntax where options is simply the path of the directory
471 to create.
472
473 `options` Command options include:
474
475 * `source` Path or array of paths.
476 * `directory` Shortcut for `source`
477 * `exclude` Regular expression.
478 * `chmod` Default to 0755.
479 * `cwd` Current working directory for relative paths.
480
481 `callback` Received parameters are:
482
483 * `err` Error object if any.
484 * `created` Number of created directories
485
486 Simple usage:
487
488 mecano.mkdir './some/dir', (err, created) ->
489 console.log err?.message ? created
490
491 ###
492 mkdir: (options, callback) ->
493 options = misc.options options
494 created = 0
495 each( options )
496 .on 'item', (option, next) ->
497 option = { source: option } if typeof option is 'string'
498 option.source = option.directory if not option.source? and option.directory?
499 cwd = option.cwd ? process.cwd()
500 option.source = path.resolve cwd, option.source
501 return next new Error 'Missing source option' unless option.source?
502 check = () ->
503 # if exist and is a dir, skip
504 # if exists and isn't a dir, error
505 fs.stat option.source, (err, stat) ->
506 return create() if err and err.code is 'ENOENT'
507 return next err if err
508 return next() if stat.isDirectory()
509 next err 'Invalid source, got #{JSON.encode(option.source)}'
510 create = () ->
511 option.chmod ?= 0o0755
512 current = ''
513 dirCreated = false
514 dirs = option.source.split '/'
515 each( dirs )
516 .on 'item', (dir, next) ->
517 # Directory name contains variables
518 # eg /\${/ on './var/cache/${user}' creates './var/cache/'
519 if option.exclude? and option.exclude instanceof RegExp
520 return next() if option.exclude.test dir
521 # Empty Dir caused by split
522 # ..commented because `resolve` should clean the path
523 # return next() if dir is ''
524 current += "/#{dir}"
525 fs.exists current, (exists) ->
526 return next() if exists
527 fs.mkdir current, option.chmod, (err) ->
528 return next err if err
529 dirCreated = true
530 next()
531 .on 'both', (err) ->
532 created++ if dirCreated
533 next err
534 check()
535 .on 'both', (err) ->
536 callback err, created
537 ###
538
539 `rm` `remove(options, callback)`
540 --------------------------------
541
542 Recursively remove a file or directory. Internally, the function
543 use the [rimraf](https://github.com/isaacs/rimraf) library.
544
545 `options` Command options include:
546
547 * `source` File or directory.
548
549 `callback` Received parameters are:
550
551 * `err` Error object if any.
552 * `deleted` Number of deleted sources.
553
554 Example
555
556 mecano.rm './some/dir', (err, removed) ->
557 console.log "#{removed} dir removed"
558
559 Removing a directory unless a given file exists
560
561 mecano.rm
562 source: './some/dir'
563 not_if_exists: './some/file'
564 , (err, removed) ->
565 console.log "#{removed} dir removed"
566
567 Removing multiple files and directories
568
569 mecano.rm [
570 { source: './some/dir', not_if_exists: './some/file' }
571 './some/file'
572 ], (err, removed) ->
573 console.log "#{removed} dirs removed"
574
575 ###
576 remove: (options, callback) ->
577 options = misc.options options
578 deleted = 0
579 each( options )
580 .on 'item', (options, next) ->
581 options = source: options if typeof options is 'string'
582 return next new Error 'Missing source: #{option.source}' unless options.source?
583 # Use lstat instead of stat because it will report link presence
584 fs.lstat options.source, (err, stat) ->
585 return next() if err
586 options.options ?= {}
587 rimraf options.source, (err) ->
588 return next err if err
589 deleted++
590 next()
591 .on 'both', (err) ->
592 callback err, deleted
593 ###
594
595 `render(options, callback)`
596 ---------------------------
597
598 Render a template file At the moment, only the
599 [ECO](http://github.com/sstephenson/eco) templating engine is integrated.
600
601 `options` Command options include:
602
603 * `engine` Template engine to use, default to "eco"
604 * `content` Templated content, bypassed if source is provided.
605 * `source` File path where to extract content from.
606 * `destination` File path where to write content to.
607 * `context` Map of key values to inject into the template.
608
609 `callback` Received parameters are:
610
611 * `err` Error object if any.
612 * `rendered` Number of rendered files.
613
614 ###
615 render: (options, callback) ->
616 options = misc.options options
617 rendered = 0
618 each( options )
619 .on 'item', (option, next) ->
620 return next new Error 'Missing source or content' unless option.source or option.content
621 return next new Error 'Missing destination' unless option.destination
622 readSource = ->
623 return writeContent() unless option.source
624 fs.exists option.source, (exists) ->
625 return next new Error "Invalid source, got #{JSON.stringify(option.source)}" unless exists
626 fs.readFile option.source, (err, content) ->
627 return next err if err
628 option.content = content
629 writeContent()
630 writeContent = ->
631 try content = eco.render option.content.toString(), option.context or {}
632 catch err then return next err
633 fs.writeFile option.destination, content, (err) ->
634 return next err if err
635 rendered++
636 next()
637 readSource()
638 .on 'both', (err) ->
639 callback err, rendered
640
641# Alias definitions
642
643mecano.cp = mecano.copy
644mecano.exec = mecano.execute
645mecano.ln = mecano.link
646mecano.rm = mecano.remove
647
648