UNPKG

14.2 kBJavaScriptView Raw
1"use strict";
2Object.defineProperty(exports, "__esModule", { value: true });
3const tslib_1 = require("tslib");
4const cli_ux_1 = require("cli-ux");
5const path = require("path");
6const rwlockfile_1 = require("rwlockfile");
7const ts_lodash_1 = require("ts-lodash");
8const deps_1 = require("./deps");
9const { spawn } = require('cross-spawn');
10const debug = require('debug')('cli:updater');
11function mtime(f) {
12 return tslib_1.__awaiter(this, void 0, void 0, function* () {
13 const { mtime } = yield deps_1.default.file.stat(f);
14 return deps_1.default.moment(mtime);
15 });
16}
17function timestamp(msg) {
18 return `[${deps_1.default.moment().format()}] ${msg}`;
19}
20class Updater {
21 constructor(config) {
22 this.config = config;
23 this.http = deps_1.default.HTTP.defaults({ headers: { 'user-agent': config.userAgent } });
24 }
25 get autoupdatefile() {
26 return path.join(this.config.cacheDir, 'autoupdate');
27 }
28 get autoupdatelogfile() {
29 return path.join(this.config.cacheDir, 'autoupdate.log');
30 }
31 get versionFile() {
32 return path.join(this.config.cacheDir, `${this.config.channel}.version`);
33 }
34 get clientRoot() {
35 return path.join(this.config.dataDir, 'client');
36 }
37 get clientBin() {
38 let b = path.join(this.clientRoot, 'bin', this.config.bin);
39 return this.config.windows ? `${b}.cmd` : b;
40 }
41 get binPath() {
42 return this.config.reexecBin || this.config.bin;
43 }
44 get s3Host() {
45 return this.config.s3 && this.config.s3.host;
46 }
47 s3url(channel, p) {
48 if (!this.s3Host)
49 throw new Error('S3 host not defined');
50 return `https://${this.s3Host}/${this.config.name}/channels/${channel}/${p}`;
51 }
52 fetchManifest(channel, oclif) {
53 return tslib_1.__awaiter(this, void 0, void 0, function* () {
54 try {
55 let url = this.s3url(channel, `${this.config.platform}-${this.config.arch}`);
56 if (oclif) {
57 url = `https://${this.s3Host}/${this.config.name}/${this.config.platform}-${this.config.arch}`;
58 }
59 let { body } = yield this.http.get(url);
60 return body;
61 }
62 catch (err) {
63 if (err.statusCode === 403)
64 throw new Error(`HTTP 403: Invalid channel ${channel}`);
65 throw err;
66 }
67 });
68 }
69 fetchVersion(download) {
70 return tslib_1.__awaiter(this, void 0, void 0, function* () {
71 let v;
72 try {
73 if (!download)
74 v = yield deps_1.default.file.readJSON(this.versionFile);
75 }
76 catch (err) {
77 if (err.code !== 'ENOENT')
78 throw err;
79 }
80 if (!v) {
81 debug('fetching latest %s version', this.config.channel);
82 let { body } = yield this.http.get(this.s3url(this.config.channel, 'version'));
83 v = body;
84 yield this._catch(() => deps_1.default.file.outputJSON(this.versionFile, v));
85 }
86 return v;
87 });
88 }
89 warnIfUpdateAvailable() {
90 return tslib_1.__awaiter(this, void 0, void 0, function* () {
91 yield this._catch(() => tslib_1.__awaiter(this, void 0, void 0, function* () {
92 if (!this.s3Host)
93 return;
94 let v = yield this.fetchVersion(false);
95 if (deps_1.default.util.minorVersionGreater(this.config.version, v.version)) {
96 cli_ux_1.cli.warn(`${this.config.name}: update available from ${this.config.version} to ${v.version}`);
97 }
98 if (v.message) {
99 cli_ux_1.cli.warn(`${this.config.name}: ${v.message}`);
100 }
101 }));
102 });
103 }
104 autoupdate(force = false) {
105 return tslib_1.__awaiter(this, void 0, void 0, function* () {
106 try {
107 const clientDir = path.join(this.clientRoot, this.config.version);
108 if (yield deps_1.default.file.exists(clientDir)) {
109 yield deps_1.default.file.touch(clientDir);
110 }
111 yield this.warnIfUpdateAvailable();
112 if (!force && !(yield this.autoupdateNeeded()))
113 return;
114 debug('autoupdate running');
115 debug(`spawning autoupdate on ${this.binPath}`);
116 let fd = yield deps_1.default.file.open(this.autoupdatelogfile, 'a');
117 deps_1.default.file.write(fd, timestamp(`starting \`${this.binPath} update --autoupdate\` from ${process.argv.slice(1, 3).join(' ')}\n`));
118 spawn(this.binPath, ['update', '--autoupdate'], {
119 detached: !this.config.windows,
120 stdio: ['ignore', fd, fd],
121 env: this.autoupdateEnv,
122 })
123 .on('error', (e) => cli_ux_1.cli.warn(e, { context: 'autoupdate:' }))
124 .unref();
125 }
126 catch (e) {
127 cli_ux_1.cli.warn(e, { context: 'autoupdate:' });
128 }
129 finally {
130 yield deps_1.default.file.touch(this.autoupdatefile);
131 }
132 });
133 }
134 update(manifest, oclif) {
135 return tslib_1.__awaiter(this, void 0, void 0, function* () {
136 if (!this.s3Host)
137 throw new Error('S3 host not defined');
138 const base = this.base(manifest);
139 const output = path.join(this.clientRoot, manifest.version);
140 let tmp = path.join(this.clientRoot, base);
141 const lock = new rwlockfile_1.default(this.autoupdatefile, { ifLocked: () => cli_ux_1.cli.action.start('CLI is updating') });
142 let url = `https://${this.s3Host}/${this.config.name}/channels/${manifest.channel}/${base}.tar.gz`;
143 if (oclif) {
144 tmp = path.join(this.clientRoot, this.config.bin);
145 url = manifest.gz;
146 }
147 yield lock.add('write', { reason: 'update' });
148 try {
149 let { response: stream } = yield this.http.stream(url);
150 yield deps_1.default.file.emptyDir(tmp);
151 let extraction = this.extract(stream, this.clientRoot, manifest.sha256gz);
152 // TODO: use cli.action.type
153 if (deps_1.default.filesize && cli_ux_1.cli.action.frames) {
154 // if spinner action
155 let total = stream.headers['content-length'];
156 let current = 0;
157 const updateStatus = ts_lodash_1.default.throttle((newStatus) => {
158 cli_ux_1.cli.action.status = newStatus;
159 }, 500, { leading: true, trailing: false });
160 stream.on('data', data => {
161 current += data.length;
162 updateStatus(`${deps_1.default.filesize(current)}/${deps_1.default.filesize(total)}`);
163 });
164 }
165 yield extraction;
166 if (yield deps_1.default.file.exists(output)) {
167 const old = `${output}.old`;
168 yield deps_1.default.file.remove(old);
169 yield deps_1.default.file.rename(output, old);
170 }
171 yield deps_1.default.file.rename(tmp, output);
172 yield deps_1.default.file.touch(output);
173 yield this._createBin(manifest);
174 }
175 finally {
176 yield lock.remove('write');
177 }
178 yield this.reexecUpdate();
179 });
180 }
181 tidy() {
182 return tslib_1.__awaiter(this, void 0, void 0, function* () {
183 try {
184 if (!this.config.reexecBin)
185 return;
186 if (!this.config.reexecBin.includes(this.config.version))
187 return;
188 const { moment, file } = deps_1.default;
189 let root = this.clientRoot;
190 if (!(yield file.exists(root)))
191 return;
192 let files = yield file.ls(root);
193 let promises = files.map((f) => tslib_1.__awaiter(this, void 0, void 0, function* () {
194 if (['bin', this.config.version].includes(path.basename(f.path)))
195 return;
196 if (moment(f.stat.mtime).isBefore(moment().subtract(7, 'days'))) {
197 yield file.remove(f.path);
198 }
199 }));
200 for (let p of promises)
201 yield p;
202 }
203 catch (err) {
204 cli_ux_1.cli.warn(err);
205 }
206 });
207 }
208 extract(stream, dir, sha) {
209 const zlib = require('zlib');
210 const tar = require('tar-fs');
211 const crypto = require('crypto');
212 return new Promise((resolve, reject) => {
213 let shaValidated = false;
214 let extracted = false;
215 let check = () => {
216 if (shaValidated && extracted) {
217 resolve();
218 }
219 };
220 let fail = (err) => {
221 deps_1.default.file.remove(dir).then(() => reject(err));
222 };
223 let hasher = crypto.createHash('sha256');
224 stream.on('error', fail);
225 stream.on('data', d => hasher.update(d));
226 stream.on('end', () => {
227 let shasum = hasher.digest('hex');
228 if (sha === shasum) {
229 shaValidated = true;
230 check();
231 }
232 else {
233 reject(new Error(`SHA mismatch: expected ${shasum} to be ${sha}`));
234 }
235 });
236 let ignore = (_, header) => {
237 switch (header.type) {
238 case 'directory':
239 case 'file':
240 if (process.env.CLI_ENGINE_DEBUG_UPDATE_FILES)
241 debug(header.name);
242 return false;
243 case 'symlink':
244 return true;
245 default:
246 throw new Error(header.type);
247 }
248 };
249 let extract = tar.extract(dir, { ignore });
250 extract.on('error', fail);
251 extract.on('finish', () => {
252 extracted = true;
253 check();
254 });
255 let gunzip = zlib.createGunzip();
256 gunzip.on('error', fail);
257 stream.pipe(gunzip).pipe(extract);
258 });
259 }
260 base(manifest) {
261 return `${this.config.name}-v${manifest.version}-${this.config.platform}-${this.config.arch}`;
262 }
263 autoupdateNeeded() {
264 return tslib_1.__awaiter(this, void 0, void 0, function* () {
265 try {
266 const m = yield mtime(this.autoupdatefile);
267 return m.isBefore(deps_1.default.moment().subtract(5, 'hours'));
268 }
269 catch (err) {
270 if (err.code !== 'ENOENT')
271 cli_ux_1.cli.error(err.stack);
272 if (global.testing)
273 return false;
274 debug('autoupdate ENOENT');
275 return true;
276 }
277 });
278 }
279 get timestampEnvVar() {
280 // TODO: use function from @cli-engine/config
281 let bin = this.config.bin.replace('-', '_').toUpperCase();
282 return `${bin}_TIMESTAMPS`;
283 }
284 get skipAnalyticsEnvVar() {
285 let bin = this.config.bin.replace('-', '_').toUpperCase();
286 return `${bin}_SKIP_ANALYTICS`;
287 }
288 get autoupdateEnv() {
289 return Object.assign({}, process.env, {
290 [this.timestampEnvVar]: '1',
291 [this.skipAnalyticsEnvVar]: '1',
292 });
293 }
294 reexecUpdate() {
295 return tslib_1.__awaiter(this, void 0, void 0, function* () {
296 cli_ux_1.cli.action.stop();
297 return new Promise((_, reject) => {
298 debug('restarting CLI after update', this.clientBin);
299 spawn(this.clientBin, ['update'], {
300 stdio: 'inherit',
301 env: Object.assign({}, process.env, { CLI_ENGINE_HIDE_UPDATED_MESSAGE: '1' }),
302 })
303 .on('error', reject)
304 .on('close', (status) => {
305 try {
306 cli_ux_1.cli.exit(status);
307 }
308 catch (err) {
309 reject(err);
310 }
311 });
312 });
313 });
314 }
315 _createBin(manifest) {
316 return tslib_1.__awaiter(this, void 0, void 0, function* () {
317 let dst = this.clientBin;
318 if (this.config.windows) {
319 let body = `@echo off
320"%~dp0\\..\\${manifest.version}\\bin\\${this.config.bin}.cmd" %*
321`;
322 yield deps_1.default.file.outputFile(dst, body);
323 }
324 else {
325 let body = `#!/usr/bin/env bash
326set -e
327get_script_dir () {
328 SOURCE="\${BASH_SOURCE[0]}"
329 # While $SOURCE is a symlink, resolve it
330 while [ -h "$SOURCE" ]; do
331 DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )"
332 SOURCE="$( readlink "$SOURCE" )"
333 # If $SOURCE was a relative symlink (so no "/" as prefix, need to resolve it relative to the symlink base directory
334 [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
335 done
336 DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )"
337 echo "$DIR"
338}
339DIR=$(get_script_dir)
340HEROKU_CLI_REDIRECTED=1 "$DIR/../${manifest.version}/bin/${this.config.bin}" "$@"
341`;
342 yield deps_1.default.file.remove(dst);
343 yield deps_1.default.file.outputFile(dst, body);
344 yield deps_1.default.fs.chmod(dst, 0o755);
345 }
346 });
347 }
348 _catch(fn) {
349 return tslib_1.__awaiter(this, void 0, void 0, function* () {
350 try {
351 return yield Promise.resolve(fn());
352 }
353 catch (err) {
354 debug(err);
355 }
356 });
357 }
358}
359exports.Updater = Updater;