1 | 'use strict'
|
2 |
|
3 | const chalk = require('chalk')
|
4 | const spawn = require('child_process').spawn
|
5 | const fs = require('fs')
|
6 | const mm = require('micromatch')
|
7 | const path = require('path')
|
8 | const watch = require('simple-watcher')
|
9 | const subarg = require('subarg'
|
10 | )
|
11 | const CHILD_EXIT_WAIT = 50
|
12 | const FILE_WATCH_WAIT = 300
|
13 | const RNA = chalk.blue('runna')
|
14 | const ERR = chalk.red('err')
|
15 | const LOG = chalk.green('log')
|
16 | const HELP = `
|
17 | Usage:
|
18 | runna <chain> [options]
|
19 |
|
20 | Options:
|
21 | -f <flavors> Enable flavors; a comma separated list.
|
22 | -w [<path-to-watch>] Default is current.
|
23 | `
|
24 |
|
25 | class 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 |
|
56 |
|
57 |
|
58 |
|
59 | async runChain (chain, flavors, exitOnError = true) {
|
60 | const timestamp = Date.now()
|
61 |
|
62 |
|
63 |
|
64 |
|
65 |
|
66 |
|
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 |
|
76 | if (!code || !code.includes('$FLV')) {
|
77 | scripts.push({name, isBackground, isPause, code})
|
78 | continue
|
79 | }
|
80 |
|
81 |
|
82 | for (const flavor of flavors) {
|
83 | scripts.push({name: `${name}::${flavor}`, isBackground, isPause, code: code.replace(/\$FLV/g, flavor)})
|
84 | }
|
85 | }
|
86 |
|
87 |
|
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 |
|
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 |
|
121 | console.log(`${RNA} ${LOG} Script ${script.name} started.`)
|
122 | const child = spawn(args[0], args.slice(1), {shell})
|
123 |
|
124 |
|
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 |
|
149 | })
|
150 |
|
151 |
|
152 | child.stdout.on('data', buf => {
|
153 | this.getLogLines(buf, script.name, LOG).forEach(line => process.stdout.write(line))
|
154 | })
|
155 |
|
156 |
|
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 |
|
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 |
|
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 |
|
186 |
|
187 |
|
188 | async observe (pathToWatch, flavors) {
|
189 |
|
190 |
|
191 |
|
192 |
|
193 |
|
194 |
|
195 |
|
196 |
|
197 |
|
198 |
|
199 |
|
200 |
|
201 |
|
202 | const rules = []
|
203 | for (const [chain, patterns] of Object.entries(this.cfg.observe)) {
|
204 | for (let pattern of patterns) {
|
205 |
|
206 | pattern = path.resolve(pathToWatch, pattern).replace(/\\/g, '/')
|
207 |
|
208 |
|
209 | if (!pattern.includes('$FLV')) {
|
210 | rules.push({chain, pattern, flavors})
|
211 | continue
|
212 | }
|
213 |
|
214 | for (const flavor of flavors) {
|
215 | rules.push({chain, pattern: pattern.replace(/\$FLV/g, flavor), flavors: [flavor]})
|
216 | }
|
217 | }
|
218 | }
|
219 |
|
220 |
|
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 |
|
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 |
|
243 | const paths = Array.from(new Set(this.queue.splice(0))).map(p => p.replace(/\\/g, '/'))
|
244 |
|
245 |
|
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 |
|
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 |
|
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 |
|
354 | if (require.main === module) {
|
355 | const runner = new Runner()
|
356 | runner.main()
|
357 | }
|
358 |
|
359 | module.exports = Runner
|