@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/README.md +14 -10
- package/dist/commands/update.d.ts +20 -0
- package/dist/commands/update.js +83 -0
- package/dist/hooks/init.js +67 -0
- package/dist/tar.d.ts +6 -0
- package/{lib → dist}/tar.js +34 -30
- package/dist/update.d.ts +26 -0
- package/dist/update.js +362 -0
- package/{lib → dist}/util.d.ts +3 -4
- package/dist/util.js +22 -0
- package/oclif.lock +7063 -0
- package/oclif.manifest.json +59 -46
- package/package.json +50 -38
- package/lib/commands/update.d.ts +0 -20
- package/lib/commands/update.js +0 -87
- package/lib/hooks/init.js +0 -67
- package/lib/tar.d.ts +0 -2
- package/lib/update.d.ts +0 -44
- package/lib/update.js +0 -388
- package/lib/util.js +0 -37
- /package/{lib → dist}/hooks/init.d.ts +0 -0
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
|
+
};
|
package/{lib → dist}/util.d.ts
RENAMED
@@ -1,9 +1,8 @@
|
|
1
|
-
/// <reference types="node" />
|
2
|
-
import
|
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:
|
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
|
+
}
|