@oclif/plugin-update 3.2.4 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
+ }