UNPKG

11.1 kBJavaScriptView Raw
1
2/**
3 * Module dependencies
4 */
5
6var debug = require('debug')('duo-package');
7var Emitter = require('events').EventEmitter;
8var write = require('fs').createWriteStream;
9var read = require('fs').createReadStream;
10var resolve = require('gh-resolve');
11var mkdir = require('mkdirp').sync;
12var netrc = require('node-netrc');
13var fmt = require('util').format;
14var enstore = require('enstore');
15var tmp = require('os').tmpdir();
16var unyield = require('unyield');
17var thunk = require('thunkify');
18var semver = require('semver');
19var tar = require('tar-fs');
20var path = require('path');
21var zlib = require('zlib');
22var fs = require('co-fs');
23var url = require('url');
24var join = path.join;
25
26/**
27 * Export `Package`
28 */
29
30module.exports = Package;
31
32/**
33 * Thunkify functions
34 */
35
36resolve = thunk(resolve);
37
38/**
39 * Inflight
40 */
41
42var inflight = {};
43
44/**
45 * Refs.
46 */
47
48var refs = {};
49
50/**
51 * Home directory
52 */
53
54var home = process.env.HOME || process.env.HOMEPATH;
55
56/**
57 * Cache tarballs in "$tmp/duo"
58 * and make sure it exists
59 */
60
61var cachepath = join(tmp, 'duo');
62mkdir(cachepath);
63
64/**
65 * API url
66 */
67
68var api = 'https://api.github.com';
69
70/**
71 * auth from ~/.netrc
72 */
73
74var auth = netrc('api.github.com') || {
75 password: process.env.GH_TOKEN
76};
77
78/**
79 * Logging
80 */
81
82auth.password
83 ? debug('read auth details from ~/.netrc')
84 : debug('could not read auth details from ~/.netrc')
85
86/**
87 * Unauthorized string
88 */
89
90var unauthorized = 'You have not authenticated and this repo may be private.'
91 + ' Make sure you have a ~/.netrc entry or specify $GH_TOKEN=<token>.';
92
93/**
94 * Initialize `Package`
95 *
96 * @param {String} repo
97 * @param {String} ref
98 * @api public
99 */
100
101function Package(repo, ref) {
102 if (!(this instanceof Package)) return new Package(repo, ref);
103 this.repo = repo.replace(':', '/');
104 this.tok = auth.password || null;
105 this.setMaxListeners(Infinity);
106 this.dir = process.cwd();
107 this.ua = 'duo-package';
108 this.resolved = false;
109 this.ref = ref || '*';
110 this._cache = true;
111 Emitter.call(this);
112};
113
114/**
115 * Inherit `EventEmitter`
116 */
117
118Package.prototype.__proto__ = Emitter.prototype;
119
120/**
121 * Set the directory to install into
122 *
123 * @param {String} dir
124 * @return {Package} self
125 * @api public
126 */
127
128Package.prototype.directory = function(dir) {
129 if (!dir) return this.dir;
130 this.dir = dir;
131 return this;
132};
133
134/**
135 * Get the local directory path
136 *
137 * @return {String}
138 * @api public
139 */
140
141Package.prototype.path = function(path) {
142 return join(this.dir, this.slug(), path || '');
143};
144
145/**
146 * Get or set the User-Agent
147 *
148 * @param {String} ua (optional)
149 * @return {Package|String}
150 */
151
152Package.prototype.useragent = function(ua) {
153 if (!ua) return this.ua;
154 this.ua = ua;
155 return this;
156};
157
158/**
159 * Authenticate with github
160 *
161 * @param {String} token
162 * @return {String|Package} self
163 * @api public
164 */
165
166Package.prototype.token = function(token) {
167 if (!arguments.length) return this.tok;
168 this.tok = token;
169 return this;
170};
171
172/**
173 * Resolve the reference on github
174 *
175 * @return {String}
176 * @api public
177 */
178
179Package.prototype.resolve = unyield(function *() {
180 // if it's a valid version
181 // or invalid range, no need to resolve.
182 if (semver.valid(this.ref) || !semver.validRange(this.ref)) {
183 this.debug('don\'t need to resolve %s', this.ref);
184 this.resolved = this.ref;
185 return this.resolved;
186 }
187
188 // check if ref is in the cache
189 var slug = this.repo + '@' + this.ref;
190 this.resolved = this.resolved || cached(this.repo, this.ref);
191
192 // resolved
193 if (this.resolved) {
194 this.debug('resolved from cache %s', this.resolved);
195 return this.resolved;
196 }
197
198 // resolving
199 this.emit('resolving');
200 this.debug('resolving');
201
202 // resolve
203 var ref = yield resolve(slug, { token: this.tok });
204
205 // couldn't resolve
206 if (!ref) throw this.error('%s: reference %s not found', this.slug(), this.ref);
207
208 // resolved
209 this.resolved = ref.name;
210 (refs[this.repo] = refs[this.repo] || []).push(ref.name);
211 this.debug('resolved');
212 this.emit('resolve');
213
214 return ref.name;
215});
216
217/**
218 * Fetch the tarball from github
219 * extracting to `dir`
220 *
221 * @return {Package} self
222 * @api public
223 */
224
225Package.prototype.fetch = unyield(function *() {
226
227 // resolve
228 var ref = this.resolved || (yield this.resolve());
229 var token = this.tok ? this.tok + '@' : '';
230 var url = fmt('https://%sgithub.com/%s/archive/%s.tar.gz', token, this.repo, ref);
231 var cache = join(cachepath, this.slug() + '.tar.gz');
232 var dest = this.path();
233
234 // inflight, wait till other package completes
235 if (inflight[dest]) {
236 var pkg = inflight[dest];
237 yield function(done){ pkg.once('fetch', done); }
238 }
239
240 // set package as inflight
241 inflight[dest] = this;
242
243 // check if directory already exists
244 if (yield this.exists()) {
245
246 // already exists
247 this.emit('fetching');
248 this.debug('already exists');
249 this.emit('fetch');
250 delete inflight[dest];
251
252 return this;
253 }
254
255 // check the cache
256 if (yield exists(cache)) {
257
258 // extracting
259 this.emit('fetching');
260 this.emit('installing');
261 this.debug('extracting from cache')
262
263 // extract
264 yield this.extract(cache, dest);
265
266 // extracted
267 this.emit('fetch');
268 this.emit('install')
269 this.debug('extracted from cache')
270 delete inflight[dest];
271
272 return this;
273 }
274
275 // fetching
276 this.emit('fetching');
277 this.emit('installing');
278 this.debug('fetching from %s', url);
279
280 // download tarball and extract
281 var store = yield this.download(url);
282
283 // cache, extract
284 var cacheable = this._cache && semver.validRange(ref);
285 var gens = [];
286
287 if (cacheable) gens.push(this.write(store, cache));
288 gens.push(this.extract(store, dest));
289
290 yield gens;
291
292 // fetch
293 this.emit('fetch');
294 this.emit('install');
295 this.debug('fetched from %s', url);
296 delete inflight[dest];
297
298 return this;
299});
300
301/**
302 * Check if the package exists.
303 *
304 * @return {Boolean}
305 * @api private
306 */
307
308Package.prototype.exists = unyield(function*(){
309 return yield exists(this.path());
310});
311
312/**
313 * Get the slug
314 *
315 * @return {String}
316 * @api public
317 */
318
319Package.prototype.toString =
320Package.prototype.slug = function() {
321 var repo = this.repo.replace('/', '-');
322 var ref = this.resolved || this.ref;
323 return repo + '@' + ref;
324};
325
326/**
327 * Return request options for `url`.
328 *
329 * @param {String} url
330 * @param {Object} [opts]
331 * @return {Object}
332 * @api private
333 */
334
335Package.prototype.options = function(url, other){
336 var token = this.token;
337 var user = this.user;
338
339 var opts = {
340 url: url,
341 headers: { 'User-Agent': this.ua }
342 };
343
344 if (other) {
345 for (var k in other) opts[k] = other[k];
346 }
347
348 if (token) opts.headers.Authorization = 'Bearer ' + token;
349
350 return opts;
351};
352
353/**
354 * Reliably download the package.
355 * Returns a store to be piped around.
356 *
357 * @param {String} url
358 * @return {Function}
359 * @api private
360 */
361
362Package.prototype.download = function(url) {
363 var store = enstore();
364 var gzip = store.createWriteStream();
365 var opts = { headers: {} };
366 var tok = this.token();
367 var self = this;
368 var prev = 0;
369 var len = 0;
370
371 // options
372 opts.headers['User-Agent'] = this.ua;
373 opts.url = url;
374
375 return function(fn) {
376 var req = request(opts);
377 debug(curl(opts));
378
379 // handle any errors from the request
380 req.on('error', error);
381
382 store.on('end', function() {
383 return fn(null, store);
384 });
385
386 req.on('response', function(res) {
387 var status = res.statusCode;
388 var headers = res.headers;
389
390 // github doesn't always return a content-length (wtf?)
391 var total = +headers['content-length'];
392
393 // Ensure that we have the write status code
394 if (status < 200 || status >= 300) {
395 var statusError = 406 == status && !tok
396 ? self.error('returned with status code: %s. %s', status, unauthorized)
397 : self.error('returned with status code: %s', status);
398 return fn(statusError);
399 }
400
401 // listen for data and emit percentages
402 req.on('data', function(buf) {
403 len += buf.length;
404 var percent = Math.round(len / total * 100);
405 // TODO figure out what to do when no total
406 if (!total || prev >= percent) return;
407 self.debug('progress %s', percent);
408 self.emit('progress', percent);
409 prev = percent;
410 });
411
412 // pipe data into gunzip, then in-memory store
413 req.pipe(zlib.createGunzip())
414 .on('error', error)
415 .pipe(gzip);
416 });
417
418 function error(err) {
419 return fn(self.error(err));
420 }
421 }
422};
423
424/**
425 * Extract the tarball
426 *
427 * @param {Enstore|String} store
428 * @param {String} dest
429 * @return {Function}
430 * @api private
431 */
432
433Package.prototype.extract = function(store, dest) {
434 var self = this;
435
436 // create a stream
437 var stream = 'string' == typeof store
438 ? read(store)
439 : store.createReadStream();
440
441 return function(fn) {
442 stream
443 .on('error', error)
444 .pipe(tar.extract(dest, { strip: 1 }))
445 .on('error', error)
446 .on('finish', fn);
447
448 function error(err) {
449 return fn(self.error(err));
450 }
451 };
452};
453
454/**
455 * Write the tarball
456 *
457 * @param {Enstore} store
458 * @param {String} dest
459 * @return {Function}
460 * @api private
461 */
462
463Package.prototype.write = function(store, dest) {
464 var read = store.createReadStream();
465 var stream = write(dest);
466 var self = this;
467
468 return function(fn) {
469 read.pipe(stream)
470 .on('error', error)
471 .on('finish', fn)
472
473 function error(err) {
474 return fn(self.error(err));
475 }
476 };
477};
478
479/**
480 * Debug
481 *
482 * @param {String} msg
483 * @param {Mixed, ...} args
484 * @return {Package}
485 * @api private
486 */
487
488Package.prototype.debug = function(msg) {
489 var args = [].slice.call(arguments, 1);
490 msg = fmt('%s: %s', this.slug(), msg);
491 debug.apply(debug, [msg].concat(args));
492 return this;
493};
494
495/**
496 * Error
497 *
498 * @param {String} msg
499 * @return {Error}
500 * @api private
501 */
502
503Package.prototype.error = function(msg) {
504 msg = fmt('%s: %s', this.slug(), msg.message || msg);
505 var args = [].slice.call(arguments, 1);
506 return new Error(fmt.apply(null, [msg].concat(args)));
507};
508
509/**
510 * Get a cached `repo`, `ref`.
511 *
512 * @param {String} repo
513 * @param {String} ref
514 * @return {String}
515 * @api private
516 */
517
518function cached(repo, ref){
519 var revs = refs[repo] || [];
520
521 for (var i = 0; i < revs.length; ++i) {
522 try {
523 if (semver.satisfies(revs[i], ref)) return revs[i];
524 } catch (e) {
525 if (revs[i] == ref) return revs[i];
526 }
527 }
528}
529
530/**
531 * Display the curl request
532 *
533 * @param {Object} opts
534 * @return {String}
535 * @api private
536 */
537
538function curl(opts) {
539 var arr = ['curl'];
540
541 // options
542 arr.push('-v');
543 arr.push('-L');
544
545 // headers
546 Object
547 .keys(opts.headers)
548 .forEach(function(header) {
549 arr.push(fmt('-H "%s: %s"', header, opts.headers[header]));
550 });
551
552 // url
553 arr.push(opts.url);
554
555 return arr.join(' ');
556}
557
558/**
559 * Lazy-load request
560 */
561
562function request() {
563 var req = require('request');
564 return req.apply(req, arguments);
565}
566
567/**
568 * Exists
569 *
570 * @param {String} path
571 * @return {Boolean}
572 * @api private
573 */
574
575function *exists(path) {
576 try {
577 yield fs.stat(path);
578 return true;
579 } catch (e) {
580 return false;
581 }
582}