@oclif/plugin-update 3.2.4 → 4.1.0

Sign up to get free protection for your applications and to get access to all the features.
package/dist/update.js ADDED
@@ -0,0 +1,362 @@
1
+ import { Config, ux } from '@oclif/core';
2
+ import chalk from 'chalk';
3
+ import fileSize from 'filesize';
4
+ import { HTTP } from 'http-call';
5
+ import throttle from 'lodash.throttle';
6
+ import { existsSync } from 'node:fs';
7
+ import { mkdir, readFile, readdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises';
8
+ import { basename, dirname, join } from 'node:path';
9
+ import { Extractor } from './tar.js';
10
+ import { ls, wait } from './util.js';
11
+ const filesize = (n) => {
12
+ const [num, suffix] = fileSize(n, { output: 'array' });
13
+ return Number.parseFloat(num).toFixed(1) + ` ${suffix}`;
14
+ };
15
+ export class Updater {
16
+ config;
17
+ clientBin;
18
+ clientRoot;
19
+ constructor(config) {
20
+ this.config = config;
21
+ this.clientRoot = config.scopedEnvVar('OCLIF_CLIENT_HOME') ?? join(config.dataDir, 'client');
22
+ this.clientBin = join(this.clientRoot, 'bin', config.windows ? `${config.bin}.cmd` : config.bin);
23
+ }
24
+ async fetchVersionIndex() {
25
+ ux.action.status = 'fetching version index';
26
+ const newIndexUrl = this.config.s3Url(s3VersionIndexKey(this.config));
27
+ try {
28
+ const { body } = await HTTP.get(newIndexUrl);
29
+ return typeof body === 'string' ? JSON.parse(body) : body;
30
+ }
31
+ catch {
32
+ throw new Error(`No version indices exist for ${this.config.name}.`);
33
+ }
34
+ }
35
+ async findLocalVersions() {
36
+ await ensureClientDir(this.clientRoot);
37
+ const dirOrFiles = await readdir(this.clientRoot);
38
+ return dirOrFiles
39
+ .filter((dirOrFile) => dirOrFile !== 'bin' && dirOrFile !== 'current')
40
+ .map((f) => join(this.clientRoot, f));
41
+ }
42
+ async runUpdate(options) {
43
+ const { autoUpdate, force = false, version } = options;
44
+ if (autoUpdate)
45
+ await debounce(this.config.cacheDir);
46
+ ux.action.start(`${this.config.name}: Updating CLI`);
47
+ if (notUpdatable(this.config)) {
48
+ ux.action.stop('not updatable');
49
+ return;
50
+ }
51
+ const [channel, current] = await Promise.all([
52
+ options.channel ?? determineChannel({ config: this.config, version }),
53
+ determineCurrentVersion(this.clientBin, this.config.version),
54
+ ]);
55
+ if (version) {
56
+ const localVersion = force ? null : await this.findLocalVersion(version);
57
+ if (alreadyOnVersion(current, localVersion || null)) {
58
+ ux.action.stop(this.config.scopedEnvVar('HIDE_UPDATED_MESSAGE') ? 'done' : `already on version ${current}`);
59
+ return;
60
+ }
61
+ await this.config.runHook('preupdate', { channel, version });
62
+ if (localVersion) {
63
+ await this.updateToExistingVersion(current, localVersion);
64
+ }
65
+ else {
66
+ const index = await this.fetchVersionIndex();
67
+ const url = index[version];
68
+ if (!url) {
69
+ throw new Error(`${version} not found in index:\n${Object.keys(index).join(', ')}`);
70
+ }
71
+ const manifest = await this.fetchVersionManifest(version, url);
72
+ const updated = manifest.sha ? `${manifest.version}-${manifest.sha}` : manifest.version;
73
+ await this.update(manifest, current, updated, force, channel);
74
+ }
75
+ await this.config.runHook('update', { channel, version });
76
+ ux.action.stop();
77
+ ux.log();
78
+ ux.log(`Updating to a specific version will not update the channel. If autoupdate is enabled, the CLI will eventually be updated back to ${channel}.`);
79
+ }
80
+ else {
81
+ const manifest = await fetchChannelManifest(channel, this.config);
82
+ const updated = manifest.sha ? `${manifest.version}-${manifest.sha}` : manifest.version;
83
+ if (!force && alreadyOnVersion(current, updated)) {
84
+ ux.action.stop(this.config.scopedEnvVar('HIDE_UPDATED_MESSAGE') ? 'done' : `already on version ${current}`);
85
+ }
86
+ else {
87
+ await this.config.runHook('preupdate', { channel, version: updated });
88
+ await this.update(manifest, current, updated, force, channel);
89
+ }
90
+ await this.config.runHook('update', { channel, version: updated });
91
+ ux.action.stop();
92
+ }
93
+ await this.touch();
94
+ await this.tidy();
95
+ ux.debug('done');
96
+ }
97
+ async createBin(version) {
98
+ const dst = this.clientBin;
99
+ const { bin, windows } = this.config;
100
+ const binPathEnvVar = this.config.scopedEnvVarKey('BINPATH');
101
+ const redirectedEnvVar = this.config.scopedEnvVarKey('REDIRECTED');
102
+ await mkdir(dirname(dst), { recursive: true });
103
+ if (windows) {
104
+ const body = `@echo off
105
+ setlocal enableextensions
106
+ set ${redirectedEnvVar}=1
107
+ set ${binPathEnvVar}=%~dp0${bin}
108
+ "%~dp0..\\${version}\\bin\\${bin}.cmd" %*
109
+ `;
110
+ await writeFile(dst, body);
111
+ }
112
+ else {
113
+ /* eslint-disable no-useless-escape */
114
+ const body = `#!/usr/bin/env bash
115
+ set -e
116
+ get_script_dir () {
117
+ SOURCE="\${BASH_SOURCE[0]}"
118
+ # While $SOURCE is a symlink, resolve it
119
+ while [ -h "$SOURCE" ]; do
120
+ DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )"
121
+ SOURCE="$( readlink "$SOURCE" )"
122
+ # If $SOURCE was a relative symlink (so no "/" as prefix, need to resolve it relative to the symlink base directory
123
+ [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
124
+ done
125
+ DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )"
126
+ echo "$DIR"
127
+ }
128
+ DIR=$(get_script_dir)
129
+ ${binPathEnvVar}="\$DIR/${bin}" ${redirectedEnvVar}=1 "$DIR/../${version}/bin/${bin}" "$@"
130
+ `;
131
+ /* eslint-enable no-useless-escape */
132
+ await writeFile(dst, body, { mode: 0o755 });
133
+ await rm(join(this.clientRoot, 'current'), { force: true, recursive: true });
134
+ await symlink(`./${version}`, join(this.clientRoot, 'current'));
135
+ }
136
+ }
137
+ async fetchVersionManifest(version, url) {
138
+ const parts = url.split('/');
139
+ const hashIndex = parts.indexOf(version) + 1;
140
+ const hash = parts[hashIndex];
141
+ const s3Key = s3VersionManifestKey({ config: this.config, hash, version });
142
+ return fetchManifest(s3Key, this.config);
143
+ }
144
+ async findLocalVersion(version) {
145
+ const versions = await this.findLocalVersions();
146
+ return versions.map((file) => basename(file)).find((file) => file.startsWith(version));
147
+ }
148
+ async refreshConfig(version) {
149
+ this.config = (await Config.load({ root: join(this.clientRoot, version) }));
150
+ }
151
+ // removes any unused CLIs
152
+ async tidy() {
153
+ ux.debug('tidy');
154
+ try {
155
+ const root = this.clientRoot;
156
+ if (!existsSync(root))
157
+ return;
158
+ const files = await ls(root);
159
+ const isNotSpecial = (fPath, version) => !['bin', 'current', version].includes(basename(fPath));
160
+ const isOld = (fStat) => {
161
+ const { mtime } = fStat;
162
+ mtime.setHours(mtime.getHours() + 42 * 24);
163
+ return mtime < new Date();
164
+ };
165
+ await Promise.all(files
166
+ .filter((f) => isNotSpecial(this.config.version, f.path) && isOld(f.stat))
167
+ .map((f) => rm(f.path, { force: true, recursive: true })));
168
+ }
169
+ catch (error) {
170
+ ux.warn(error);
171
+ }
172
+ }
173
+ async touch() {
174
+ // touch the client so it won't be tidied up right away
175
+ try {
176
+ const p = join(this.clientRoot, this.config.version);
177
+ ux.debug('touching client at', p);
178
+ if (!existsSync(p))
179
+ return;
180
+ return utimes(p, new Date(), new Date());
181
+ }
182
+ catch (error) {
183
+ ux.warn(error);
184
+ }
185
+ }
186
+ // eslint-disable-next-line max-params
187
+ async update(manifest, current, updated, force, channel) {
188
+ ux.action.start(`${this.config.name}: Updating CLI from ${chalk.green(current)} to ${chalk.green(updated)}${channel === 'stable' ? '' : ' (' + chalk.yellow(channel) + ')'}`);
189
+ await ensureClientDir(this.clientRoot);
190
+ const output = join(this.clientRoot, updated);
191
+ if (force || !existsSync(output))
192
+ await downloadAndExtract(output, manifest, channel, this.config);
193
+ await this.refreshConfig(updated);
194
+ await setChannel(channel, this.config.dataDir);
195
+ await this.createBin(updated);
196
+ }
197
+ async updateToExistingVersion(current, updated) {
198
+ ux.action.start(`${this.config.name}: Updating CLI from ${chalk.green(current)} to ${chalk.green(updated)}`);
199
+ await ensureClientDir(this.clientRoot);
200
+ await this.refreshConfig(updated);
201
+ await this.createBin(updated);
202
+ }
203
+ }
204
+ const alreadyOnVersion = (current, updated) => current === updated;
205
+ const ensureClientDir = async (clientRoot) => {
206
+ try {
207
+ await mkdir(clientRoot, { recursive: true });
208
+ }
209
+ catch (error) {
210
+ const { code } = error;
211
+ if (code === 'EEXIST') {
212
+ // for some reason the client directory is sometimes a file
213
+ // if so, this happens. Delete it and recreate
214
+ await rm(clientRoot, { force: true, recursive: true });
215
+ await mkdir(clientRoot, { recursive: true });
216
+ }
217
+ else {
218
+ throw error;
219
+ }
220
+ }
221
+ };
222
+ // eslint-disable-next-line unicorn/no-await-expression-member
223
+ const mtime = async (f) => (await stat(f)).mtime;
224
+ const notUpdatable = (config) => {
225
+ if (!config.binPath) {
226
+ const instructions = config.scopedEnvVar('UPDATE_INSTRUCTIONS');
227
+ if (instructions)
228
+ ux.warn(instructions);
229
+ return true;
230
+ }
231
+ return false;
232
+ };
233
+ const composeS3SubDir = (config) => {
234
+ let s3SubDir = config.pjson.oclif.update.s3.folder || '';
235
+ if (s3SubDir !== '' && s3SubDir.slice(-1) !== '/')
236
+ s3SubDir = `${s3SubDir}/`;
237
+ return s3SubDir;
238
+ };
239
+ const fetchManifest = async (s3Key, config) => {
240
+ ux.action.status = 'fetching manifest';
241
+ const url = config.s3Url(s3Key);
242
+ const { body } = await HTTP.get(url);
243
+ if (typeof body === 'string') {
244
+ return JSON.parse(body);
245
+ }
246
+ return body;
247
+ };
248
+ const s3VersionIndexKey = (config) => {
249
+ const { arch, bin } = config;
250
+ const s3SubDir = composeS3SubDir(config);
251
+ return join(s3SubDir, 'versions', `${bin}-${determinePlatform(config)}-${arch}-tar-gz.json`);
252
+ };
253
+ const determinePlatform = (config) => config.platform === 'wsl' ? 'linux' : config.platform;
254
+ const s3ChannelManifestKey = (channel, config) => {
255
+ const { arch, bin } = config;
256
+ const s3SubDir = composeS3SubDir(config);
257
+ return join(s3SubDir, 'channels', channel, `${bin}-${determinePlatform(config)}-${arch}-buildmanifest`);
258
+ };
259
+ const s3VersionManifestKey = ({ config, hash, version }) => {
260
+ const { arch, bin } = config;
261
+ const s3SubDir = composeS3SubDir(config);
262
+ return join(s3SubDir, 'versions', version, hash, `${bin}-v${version}-${hash}-${determinePlatform(config)}-${arch}-buildmanifest`);
263
+ };
264
+ // when autoupdating, wait until the CLI isn't active
265
+ const debounce = async (cacheDir) => {
266
+ let output = false;
267
+ const lastrunfile = join(cacheDir, 'lastrun');
268
+ const m = await mtime(lastrunfile);
269
+ m.setHours(m.getHours() + 1);
270
+ if (m > new Date()) {
271
+ const msg = `waiting until ${m.toISOString()} to update`;
272
+ if (output) {
273
+ ux.debug(msg);
274
+ }
275
+ else {
276
+ ux.log(msg);
277
+ output = true;
278
+ }
279
+ await wait(60 * 1000); // wait 1 minute
280
+ return debounce(cacheDir);
281
+ }
282
+ ux.log('time to update');
283
+ };
284
+ const setChannel = async (channel, dataDir) => writeFile(join(dataDir, 'channel'), channel, 'utf8');
285
+ const fetchChannelManifest = async (channel, config) => {
286
+ const s3Key = s3ChannelManifestKey(channel, config);
287
+ try {
288
+ return await fetchManifest(s3Key, config);
289
+ }
290
+ catch (error) {
291
+ const { statusCode } = error;
292
+ if (statusCode === 403)
293
+ throw new Error(`HTTP 403: Invalid channel ${channel}`);
294
+ throw error;
295
+ }
296
+ };
297
+ const downloadAndExtract = async (output, manifest, channel, config) => {
298
+ const { gz, sha256gz, version } = manifest;
299
+ const gzUrl = gz ??
300
+ config.s3Url(config.s3Key('versioned', {
301
+ arch: config.arch,
302
+ bin: config.bin,
303
+ channel,
304
+ ext: 'gz',
305
+ platform: determinePlatform(config),
306
+ version,
307
+ }));
308
+ const { response: stream } = await HTTP.stream(gzUrl);
309
+ stream.pause();
310
+ const baseDir = manifest.baseDir ??
311
+ config.s3Key('baseDir', {
312
+ arch: config.arch,
313
+ bin: config.bin,
314
+ channel,
315
+ platform: determinePlatform(config),
316
+ version,
317
+ });
318
+ const extraction = Extractor.extract(stream, baseDir, output, sha256gz);
319
+ if (ux.action.type === 'spinner') {
320
+ const total = Number.parseInt(stream.headers['content-length'], 10);
321
+ let current = 0;
322
+ const updateStatus = throttle((newStatus) => {
323
+ ux.action.status = newStatus;
324
+ }, 250, { leading: true, trailing: false });
325
+ stream.on('data', (data) => {
326
+ current += data.length;
327
+ updateStatus(`${filesize(current)}/${filesize(total)}`);
328
+ });
329
+ }
330
+ stream.resume();
331
+ await extraction;
332
+ };
333
+ const determineChannel = async ({ config, version }) => {
334
+ const channelPath = join(config.dataDir, 'channel');
335
+ // eslint-disable-next-line unicorn/no-await-expression-member
336
+ const channel = existsSync(channelPath) ? (await readFile(channelPath, 'utf8')).trim() : 'stable';
337
+ try {
338
+ const { body } = await HTTP.get(`${config.npmRegistry ?? 'https://registry.npmjs.org'}/${config.pjson.name}`);
339
+ const tags = body['dist-tags'];
340
+ const tag = Object.keys(tags).find((v) => tags[v] === version) ?? channel;
341
+ // convert from npm style tag defaults to OCLIF style
342
+ if (tag === 'latest')
343
+ return 'stable';
344
+ if (tag === 'latest-rc')
345
+ return 'stable-rc';
346
+ return tag;
347
+ }
348
+ catch {
349
+ return channel;
350
+ }
351
+ };
352
+ const determineCurrentVersion = async (clientBin, version) => {
353
+ try {
354
+ const currentVersion = await readFile(clientBin, 'utf8');
355
+ const matches = currentVersion.match(/\.\.[/\\|](.+)[/\\|]bin/);
356
+ return matches ? matches[1] : version;
357
+ }
358
+ catch (error) {
359
+ ux.warn(error);
360
+ }
361
+ return version;
362
+ };
@@ -1,9 +1,8 @@
1
- /// <reference types="node" />
2
- import * as fs from 'fs-extra';
1
+ /// <reference types="node" resolution-mode="require"/>
2
+ import { Stats } from 'node:fs';
3
3
  export declare function touch(p: string): Promise<void>;
4
4
  export declare function ls(dir: string): Promise<Array<{
5
5
  path: string;
6
- stat: fs.Stats;
6
+ stat: Stats;
7
7
  }>>;
8
- export declare function rm(dir: string): Promise<void>;
9
8
  export declare function wait(ms: number, unref?: boolean): Promise<void>;
package/dist/util.js ADDED
@@ -0,0 +1,22 @@
1
+ import { readdir, stat, utimes, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ export async function touch(p) {
4
+ try {
5
+ await utimes(p, new Date(), new Date());
6
+ }
7
+ catch {
8
+ await writeFile(p, '');
9
+ }
10
+ }
11
+ export async function ls(dir) {
12
+ const files = await readdir(dir);
13
+ const paths = files.map((f) => join(dir, f));
14
+ return Promise.all(paths.map((path) => stat(path).then((s) => ({ path, stat: s }))));
15
+ }
16
+ export function wait(ms, unref = false) {
17
+ return new Promise((resolve) => {
18
+ const t = setTimeout(() => resolve(), ms);
19
+ if (unref)
20
+ t.unref();
21
+ });
22
+ }