UNPKG

9.75 kBJavaScriptView Raw
1'use strict'
2
3const chalk = require('chalk')
4const spawn = require('child_process').spawn
5const fs = require('fs')
6const mm = require('micromatch')
7const path = require('path')
8const watch = require('simple-watcher')
9const subarg = require('subarg'
10)
11const CHILD_EXIT_WAIT = 50
12const FILE_WATCH_WAIT = 300
13const RNA = chalk.blue('runna')
14const ERR = chalk.red('err')
15const LOG = chalk.green('log')
16const HELP = `
17Usage:
18 runna <chain> [options]
19
20Options:
21 -f <flavors> Enable flavors; a comma separated list.
22 -w [<path-to-watch>] Default is current.
23`
24
25class Runner {
26 async main () {
27 const version = this.getJson(path.join(__dirname, 'package.json')).version
28 console.log(`Runna version ${version}.`)
29
30 const args = subarg(process.argv.slice(2))
31 if (!args['_'] || args['_'].length === 0 || !args['_'][0]['_'] || args['_'][0]['_'].length === 0) {
32 console.log(HELP)
33 process.exit(0)
34 }
35
36 const chain = args['_'][0]['_'].join(' ')
37 const pathToWatch = (args.w === true && process.cwd()) || (typeof args.w === 'string' && path.resolve(args.w))
38 const flavors = args.f ? args.f.trim().split(',') : []
39
40 this.init(chain, flavors, pathToWatch)
41 }
42
43 async init (chain, flavors, pathToWatch) {
44 this.handleExit()
45
46 this.cfg = this.getCfg()
47 this.queue = []
48 this.children = {}
49
50 await this.runChain(chain, flavors)
51 pathToWatch && this.observe(pathToWatch, flavors)
52 }
53
54 //
55 // Chain processing.
56 //
57
58 // chain ~ '+foo - bar baz'
59 async runChain (chain, flavors, exitOnError = true) {
60 const timestamp = Date.now()
61
62 // Get scripts: [{
63 // name: 'some:script'
64 // isBackground: false,
65 // isPause: false,
66 // code: 'node some-script.js -f flavor1'
67 // }]
68 const scripts = []
69 for (const text of chain.split(' ')) {
70 const name = text.replace(/[+]*(.*)/g, '$1')
71 const isBackground = text.includes('+')
72 const isPause = text === '-'
73 const code = this.cfg.scripts[name]
74
75 // Add non-flavoured script.
76 if (!code || !code.includes('$FLV')) {
77 scripts.push({name, isBackground, isPause, code})
78 continue
79 }
80
81 // Add flavoured scripts.
82 for (const flavor of flavors) {
83 scripts.push({name: `${name}::${flavor}`, isBackground, isPause, code: code.replace(/\$FLV/g, flavor)})
84 }
85 }
86
87 // Run all the scripts in a chain.
88 let msg = flavors.length ? `${chalk.magenta(chain)} :: ${chalk.magenta(flavors)}` : chalk.magenta(chain)
89
90 console.log(`${RNA} ${LOG} Chain ${msg} started.`)
91 for (const script of scripts) {
92 if (script.isPause) {
93 await this.waitForAllChildrenToComplete()
94 } else if (script.code) {
95 this.runScript(script, exitOnError)
96 } else {
97 console.error(`${RNA} ${ERR} Script ${script.name} does not exists.`)
98 this.handleError(exitOnError)
99 }
100 }
101
102 await this.waitForAllChildrenToComplete()
103 const duration = Date.now() - timestamp
104 console.log(`${RNA} ${LOG} Chain ${msg} completed in ${duration} ms.`)
105 }
106
107 async waitForAllChildrenToComplete () {
108 console.log(`${RNA} ${LOG} Waiting for all running scripts to complete...`)
109 while (Object.keys(this.children).length !== 0) {
110 await this.wait(CHILD_EXIT_WAIT)
111 }
112 }
113
114 // Spawn child process.
115 async runScript (script, exitOnError) {
116 const [args, shell] = this.getSpawnArgs(script.code)
117 return new Promise(resolve => {
118 const timestamp = Date.now()
119
120 // Spawn child process.
121 console.log(`${RNA} ${LOG} Script ${script.name} started.`)
122 const child = spawn(args[0], args.slice(1), {shell})
123
124 // Finalization handling.
125 let done
126 const end = () => {
127 if (!done) {
128 let duration = Date.now() - timestamp
129 console.log(`${RNA} ${LOG} Script ${script.name} completed in ${duration} ms.`)
130 delete this.children[child.pid]
131 done = resolve()
132 }
133 }
134
135 child.on('close', code => {
136 if (code !== 0) {
137 console.error(`${RNA} ${ERR} Script ${script.name} exited with error code ${code}.`)
138 this.handleError(exitOnError)
139 }
140 end()
141 })
142
143 child.on('error', err => {
144 console.error(`${RNA} ${ERR} Script ${script.name} threw an error.`)
145 console.error(err)
146 this.handleError(exitOnError)
147 end()
148 // throw err
149 })
150
151 // Capture stdout.
152 child.stdout.on('data', buf => {
153 this.getLogLines(buf, script.name, LOG).forEach(line => process.stdout.write(line))
154 })
155
156 // Capture stderr.
157 child.stderr.on('data', buf => {
158 this.getLogLines(buf, script.name, ERR).forEach(line => process.stderr.write(line))
159 this.handleError(exitOnError)
160 })
161
162 // Memorize.
163 if (!script.isBackground) {
164 this.children[child.pid] = child
165 }
166 })
167 }
168
169 getSpawnArgs (cmd) {
170 const args = cmd.split(' ')
171 const packageName = args[0]
172 let shell = true
173
174 // Resolve local package binary.
175 if (this.cfg.binaries[packageName]) {
176 args[0] = this.cfg.binaries[packageName]
177 args.unshift(process.execPath)
178 shell = false
179 }
180
181 return [args, shell]
182 }
183
184 //
185 // Watching.
186 //
187
188 async observe (pathToWatch, flavors) {
189 // Get rules: [{
190 // chain: '+foo - bar baz'
191 // pattern: 'c:/absolute/path/to/flavor1/**'
192 // flavors: ['flavor1']
193 // },{
194 // chain: '+foo - bar baz'
195 // pattern: 'c:/absolute/path/to/flavor2/**'
196 // flavors: [flavor2']
197 // },{
198 // chain: '+foo - bar baz'
199 // pattern: 'c:/absolute/path/to/base/**'
200 // flavors: ['flavor1', 'flavor2']
201 // }]
202 const rules = []
203 for (const [chain, patterns] of Object.entries(this.cfg.observe)) {
204 for (let pattern of patterns) {
205 // Align with directory structure and normalize slashes.
206 pattern = path.resolve(pathToWatch, pattern).replace(/\\/g, '/')
207
208 // Non-flavoured pattern means all the flavors apply.
209 if (!pattern.includes('$FLV')) {
210 rules.push({chain, pattern, flavors})
211 continue
212 }
213 // Add rule for each flavor separately.
214 for (const flavor of flavors) {
215 rules.push({chain, pattern: pattern.replace(/\$FLV/g, flavor), flavors: [flavor]})
216 }
217 }
218 }
219
220 // Initialize queue.
221 this.queue = []
222 const waitMsg = `${RNA} ${LOG} Watching ${chalk.yellow(pathToWatch)} for changes...`
223 console.log(waitMsg)
224 watch(pathToWatch, localPath => this.queue.push(localPath))
225
226 // Main loop.
227 while (true) {
228 if (await this.processQueue(rules)) {
229 console.log(waitMsg)
230 }
231 await this.wait(FILE_WATCH_WAIT)
232 }
233 }
234
235 async processQueue (rules) {
236 if (this.lock || this.queue.length === 0) {
237 return
238 }
239
240 this.lock = true
241
242 // Dequeue items and normalize slashes.
243 const paths = Array.from(new Set(this.queue.splice(0))).map(p => p.replace(/\\/g, '/'))
244
245 // Iterate over changes and look for a match.
246 const chainsToRun = {}
247 for (const rule of rules) {
248 const match = mm.match(paths, rule.pattern)
249 if (match.length === 0) {
250 continue
251 }
252
253 for (const m of match) {
254 console.log(`${RNA} ${LOG} Changed ${chalk.yellow(path.resolve(m))}`)
255 }
256
257 if (!chainsToRun[rule.chain]) {
258 chainsToRun[rule.chain] = new Set(rule.flavors)
259 continue
260 }
261
262 for (const flavor of rule.flavors) {
263 chainsToRun[rule.chain].add(flavor)
264 }
265 }
266
267 const any = Object.keys(chainsToRun).length > 0
268 for (const [chain, flavors] of Object.entries(chainsToRun)) {
269 await this.runChain(chain, Array.from(flavors), false)
270 }
271
272 this.lock = false
273 return any
274 }
275
276 //
277 // Exit handling.
278 //
279
280 killChildren () {
281 for (const child of Object.values(this.children)) {
282 child.pid && child.kill('SIGINT')
283 }
284 }
285
286 handleError (exitOnError) {
287 process.exitCode = 1
288 if (exitOnError) {
289 console.log(`${RNA} ${LOG} Shutting down.\n`)
290 this.killChildren()
291 process.exit(1)
292 }
293 }
294
295 handleExit () {
296 process.on('SIGINT', () => {
297 console.log(`${RNA} ${LOG} Shutting down.\n`)
298 this.killChildren()
299 process.exit()
300 })
301 }
302
303 //
304 // Helpers.
305 //
306
307 getJson (filePath) {
308 return JSON.parse(fs.readFileSync(filePath, 'utf8'))
309 }
310
311 resolveLocalBinaries (cfg) {
312 cfg.binaries = {}
313 const deps = [].concat(Object.keys(cfg.dependencies || []), Object.keys(cfg.devDependencies || []))
314 for (const packageName of deps) {
315 const packagePath = path.join(process.cwd(), 'node_modules', packageName)
316 if (!fs.existsSync(packagePath)) {
317 continue
318 }
319
320 const packageCfg = this.getJson(path.join(packagePath, 'package.json'))
321 if (!packageCfg.bin) {
322 continue
323 }
324
325 if (typeof packageCfg.bin === 'string') {
326 cfg.binaries[packageName] = path.join(packagePath, packageCfg.bin)
327 continue
328 }
329
330 for (const [binName, binPath] of Object.entries(packageCfg.bin)) {
331 cfg.binaries[binName] = path.join(packagePath, binPath)
332 }
333 }
334
335 return cfg
336 }
337
338 getCfg () {
339 const cfg = this.getJson(path.join(process.cwd(), 'package.json'))
340 cfg.flavors = cfg.flavors || {}
341 return this.resolveLocalBinaries(cfg)
342 }
343
344 getLogLines (buf, name, log) {
345 const trimmed = buf.toString('utf8').trim()
346 return trimmed ? trimmed.split('\n').map(line => `${chalk.blue(name)} ${log} ${line}\n`) : []
347 }
348
349 async wait (ms) {
350 return new Promise(resolve => setTimeout(resolve, ms))
351 }
352}
353
354if (require.main === module) {
355 const runner = new Runner()
356 runner.main()
357}
358
359module.exports = Runner