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, 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 |
|
153 | if (deps_1.default.filesize && cli_ux_1.cli.action.frames) {
|
154 |
|
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 |
|
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
|
326 | set -e
|
327 | get_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 | }
|
339 | DIR=$(get_script_dir)
|
340 | HEROKU_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 | }
|
359 | exports.Updater = Updater;
|