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