UNPKG

17 kBJavaScriptView Raw
1import { Config, Errors, ux } from '@oclif/core';
2import { bold } from 'ansis';
3import makeDebug from 'debug';
4import { spawn } from 'node:child_process';
5import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
6import { basename, dirname, join, resolve } from 'node:path';
7import { fileURLToPath } from 'node:url';
8import { gt, valid, validRange } from 'semver';
9import { NPM } from './npm.js';
10import { uniqWith } from './util.js';
11import { Yarn } from './yarn.js';
12const initPJSON = {
13 dependencies: {},
14 oclif: { plugins: [], schema: 1 },
15 private: true,
16};
17async function fileExists(filePath) {
18 try {
19 await access(filePath);
20 return true;
21 }
22 catch {
23 return false;
24 }
25}
26function dedupePlugins(plugins) {
27 return uniqWith(plugins, (a, b) => a.name === b.name || (a.type === 'link' && b.type === 'link' && a.root === b.root));
28}
29function extractIssuesLocation(bugs, repository) {
30 if (bugs) {
31 return typeof bugs === 'string' ? bugs : bugs.url;
32 }
33 if (repository) {
34 return typeof repository === 'string' ? repository : repository.url.replace('git+', '').replace('.git', '');
35 }
36}
37function notifyUser(plugin, output) {
38 const containsWarnings = [...output.stdout, ...output.stderr].some((l) => l.includes('npm WARN'));
39 if (containsWarnings) {
40 ux.stderr(bold.yellow(`\nThese warnings can only be addressed by the owner(s) of ${plugin.name}.`));
41 if (plugin.pjson.bugs || plugin.pjson.repository) {
42 ux.stderr(`We suggest that you create an issue at ${extractIssuesLocation(plugin.pjson.bugs, plugin.pjson.repository)} and ask the plugin owners to address them.\n`);
43 }
44 }
45}
46export default class Plugins {
47 config;
48 npm;
49 debug;
50 logLevel;
51 constructor(options) {
52 this.config = options.config;
53 this.debug = makeDebug('@oclif/plugin-plugins');
54 this.logLevel = options.logLevel ?? 'notice';
55 this.npm = new NPM({
56 config: this.config,
57 logLevel: this.logLevel,
58 });
59 }
60 async add(...plugins) {
61 const pjson = await this.pjson();
62 const mergedPlugins = [...(pjson.oclif.plugins || []), ...plugins];
63 await this.savePJSON({
64 ...pjson,
65 oclif: {
66 ...pjson.oclif,
67 plugins: dedupePlugins(mergedPlugins),
68 },
69 });
70 }
71 friendlyName(name) {
72 const { pluginPrefix, scope } = this.config.pjson.oclif;
73 if (!scope)
74 return name;
75 const match = name.match(`@${scope}/${pluginPrefix ?? 'plugin'}-(.+)`);
76 return match?.[1] ?? name;
77 }
78 async hasPlugin(name) {
79 const list = await this.list();
80 const friendlyName = this.friendlyName(name);
81 const unfriendlyName = this.unfriendlyName(name) ?? name;
82 return (list.find((p) => this.friendlyName(p.name) === friendlyName) ?? // friendly
83 list.find((p) => this.unfriendlyName(p.name) === unfriendlyName) ?? // unfriendly
84 list.find((p) => p.type === 'link' && resolve(p.root) === resolve(name)) ?? // link
85 false);
86 }
87 async install(name, { force = false, tag = 'latest' } = {}) {
88 await this.maybeCleanUp();
89 try {
90 this.debug(`installing plugin ${name}`);
91 const options = { cwd: this.config.dataDir, logLevel: this.logLevel, prod: true };
92 await this.ensurePJSON();
93 let plugin;
94 const args = force ? ['--force'] : [];
95 if (name.includes(':')) {
96 // url
97 const url = name;
98 const output = await this.npm.install([...args, url], options);
99 const { dependencies } = await this.pjson();
100 const { default: npa } = await import('npm-package-arg');
101 const normalizedUrl = npa(url);
102 const matches = Object.entries(dependencies ?? {}).find(([, u]) => {
103 const normalized = npa(u);
104 return (normalized.hosted?.type === normalizedUrl.hosted?.type &&
105 normalized.hosted?.user === normalizedUrl.hosted?.user &&
106 normalized.hosted?.project === normalizedUrl.hosted?.project);
107 });
108 const installedPluginName = matches?.[0];
109 if (!installedPluginName)
110 throw new Errors.CLIError(`Could not find plugin name for ${url}`);
111 const root = join(this.config.dataDir, 'node_modules', installedPluginName);
112 plugin = await Config.load({
113 devPlugins: false,
114 name: installedPluginName,
115 root,
116 userPlugins: false,
117 });
118 notifyUser(plugin, output);
119 this.isValidPlugin(plugin);
120 await this.add({ name: installedPluginName, type: 'user', url });
121 // Check that the prepare script produced all the expected files
122 // If it didn't, it might be because the plugin doesn't have a prepare
123 // script that compiles the plugin from source.
124 const safeToNotExist = new Set(['oclif.manifest.json', 'oclif.lock', 'npm-shrinkwrap.json']);
125 const files = (plugin.pjson.files ?? [])
126 .map((f) => join(root, f))
127 .filter((f) => !safeToNotExist.has(basename(f)));
128 this.debug(`checking for existence of files: ${files.join(', ')}`);
129 const results = Object.fromEntries(await Promise.all(files?.map(async (f) => [f, await fileExists(f)]) ?? []));
130 this.debug(results);
131 if (!Object.values(results).every(Boolean)) {
132 ux.warn(`This plugin from github may not work as expected because the prepare script did not produce all the expected files.`);
133 }
134 }
135 else {
136 // npm
137 const range = validRange(tag);
138 const unfriendly = this.unfriendlyName(name);
139 if (unfriendly && (await this.npmHasPackage(unfriendly))) {
140 name = unfriendly;
141 }
142 // validate that the package name exists in the npm registry before installing
143 await this.npmHasPackage(name, true);
144 const output = await this.npm.install([...args, `${name}@${tag}`], options);
145 this.debug(`loading plugin ${name}...`);
146 plugin = await Config.load({
147 devPlugins: false,
148 name,
149 root: join(this.config.dataDir, 'node_modules', name),
150 userPlugins: false,
151 });
152 this.debug(`finished loading plugin ${name} at root ${plugin.root}`);
153 notifyUser(plugin, output);
154 this.isValidPlugin(plugin);
155 await this.add({ name, tag: range ?? tag, type: 'user' });
156 }
157 await rm(join(this.config.dataDir, 'yarn.lock'), { force: true });
158 return plugin;
159 }
160 catch (error) {
161 this.debug('error installing plugin:', error);
162 await this.uninstall(name).catch((error) => this.debug(error));
163 if (String(error).includes('EACCES')) {
164 throw new Errors.CLIError(error, {
165 suggestions: [
166 `Plugin failed to install because of a permissions error.\nDoes your current user own the directory ${this.config.dataDir}?`,
167 ],
168 });
169 }
170 throw error;
171 }
172 }
173 async link(p, { install }) {
174 const c = await Config.load(resolve(p));
175 this.isValidPlugin(c);
176 if (install) {
177 if (await fileExists(join(c.root, 'yarn.lock'))) {
178 this.debug('installing dependencies with yarn');
179 const yarn = new Yarn({ config: this.config, logLevel: this.logLevel });
180 await yarn.install([], {
181 cwd: c.root,
182 logLevel: this.logLevel,
183 });
184 }
185 else if (await fileExists(join(c.root, 'package-lock.json'))) {
186 this.debug('installing dependencies with npm');
187 await this.npm.install([], {
188 cwd: c.root,
189 logLevel: this.logLevel,
190 prod: false,
191 });
192 }
193 else if (await fileExists(join(c.root, 'pnpm-lock.yaml'))) {
194 ux.warn(`pnpm is not supported for installing after a link. The link succeeded, but you may need to run 'pnpm install' in ${c.root}.`);
195 }
196 else {
197 ux.warn(`No lockfile found in ${c.root}. The link succeeded, but you may need to install the dependencies in your project.`);
198 }
199 }
200 await this.add({ name: c.name, root: c.root, type: 'link' });
201 return c;
202 }
203 async list() {
204 const pjson = await this.pjson();
205 return pjson.oclif.plugins;
206 }
207 async maybeUnfriendlyName(name) {
208 await this.ensurePJSON();
209 const unfriendly = this.unfriendlyName(name);
210 this.debug(`checking registry for expanded package name ${unfriendly}`);
211 if (unfriendly && (await this.npmHasPackage(unfriendly))) {
212 return unfriendly;
213 }
214 this.debug(`expanded package name ${unfriendly} not found, using given package name ${name}`);
215 return name;
216 }
217 async pjson() {
218 const pjson = await this.readPJSON();
219 const plugins = pjson ? normalizePlugins(pjson.oclif.plugins) : [];
220 return {
221 ...initPJSON,
222 ...pjson,
223 oclif: {
224 ...initPJSON.oclif,
225 ...pjson?.oclif,
226 plugins,
227 },
228 };
229 }
230 async remove(name) {
231 const pjson = await this.pjson();
232 if (pjson.dependencies)
233 delete pjson.dependencies[name];
234 await this.savePJSON({
235 ...pjson,
236 oclif: {
237 ...pjson.oclif,
238 plugins: pjson.oclif.plugins.filter((p) => p.name !== name),
239 },
240 });
241 }
242 unfriendlyName(name) {
243 if (name.includes('@'))
244 return;
245 const { pluginPrefix, scope } = this.config.pjson.oclif;
246 if (!scope)
247 return;
248 return `@${scope}/${pluginPrefix ?? 'plugin'}-${name}`;
249 }
250 async uninstall(name) {
251 try {
252 const pjson = await this.pjson();
253 if ((pjson.oclif.plugins ?? []).some((p) => typeof p === 'object' && p.type === 'user' && p.name === name)) {
254 await this.npm.uninstall([name], {
255 cwd: this.config.dataDir,
256 logLevel: this.logLevel,
257 });
258 }
259 }
260 catch (error) {
261 ux.warn(error);
262 }
263 finally {
264 await this.remove(name);
265 }
266 }
267 async update() {
268 let plugins = (await this.list()).filter((p) => p.type === 'user');
269 if (plugins.length === 0)
270 return;
271 await this.maybeCleanUp();
272 // migrate deprecated plugins
273 const aliases = this.config.pjson.oclif.aliases || {};
274 for (const [name, to] of Object.entries(aliases)) {
275 const plugin = plugins.find((p) => p.name === name);
276 if (!plugin)
277 continue;
278 // eslint-disable-next-line no-await-in-loop
279 if (to)
280 await this.install(to);
281 // eslint-disable-next-line no-await-in-loop
282 await this.uninstall(name);
283 plugins = plugins.filter((p) => p.name !== name);
284 }
285 const urlPlugins = plugins.filter((p) => Boolean(p.url));
286 if (urlPlugins.length > 0) {
287 await this.npm.update(urlPlugins.map((p) => p.name), {
288 cwd: this.config.dataDir,
289 logLevel: this.logLevel,
290 });
291 }
292 const npmPlugins = plugins.filter((p) => !p.url);
293 const jitPlugins = this.config.pjson.oclif.jitPlugins ?? {};
294 const modifiedPlugins = [];
295 if (npmPlugins.length > 0) {
296 await this.npm.install(npmPlugins.map((p) => {
297 // a not valid tag indicates that it's a dist-tag like 'latest'
298 if (!valid(p.tag))
299 return `${p.name}@${p.tag}`;
300 if (p.tag && valid(p.tag) && jitPlugins[p.name] && gt(p.tag, jitPlugins[p.name])) {
301 // The user has installed a version of the JIT plugin that is newer than the one
302 // specified by the root plugin's JIT configuration. In this case, we want to
303 // keep the version installed by the user.
304 return `${p.name}@${p.tag}`;
305 }
306 const tag = jitPlugins[p.name] ?? p.tag;
307 modifiedPlugins.push({ ...p, tag });
308 return `${p.name}@${tag}`;
309 }), { cwd: this.config.dataDir, logLevel: this.logLevel, prod: true });
310 }
311 await this.add(...modifiedPlugins);
312 }
313 async ensurePJSON() {
314 if (!(await fileExists(this.pjsonPath))) {
315 this.debug(`creating ${this.pjsonPath} with pjson: ${JSON.stringify(initPJSON, null, 2)}`);
316 await this.savePJSON(initPJSON);
317 }
318 }
319 isValidPlugin(p) {
320 if (p.valid)
321 return true;
322 if (this.config.plugins.get('@oclif/plugin-legacy') ||
323 // @ts-expect-error because _base is private
324 p._base.includes('@oclif/plugin-legacy')) {
325 return true;
326 }
327 throw new Errors.CLIError('plugin is invalid', {
328 suggestions: [
329 'Plugin failed to install because it does not appear to be a valid CLI plugin.\nIf you are sure it is, contact the CLI developer noting this error.',
330 ],
331 });
332 }
333 async maybeCleanUp() {
334 // If the yarn.lock exists, then we assume that the last install was done with an older major
335 // version of plugin-plugins that used yarn (v1). In this case, we want to remove the yarn.lock
336 // and node_modules to ensure a clean install or update.
337 if (await fileExists(join(this.config.dataDir, 'yarn.lock'))) {
338 try {
339 this.debug('Found yarn.lock! Removing yarn.lock and node_modules...');
340 await Promise.all([
341 rename(join(this.config.dataDir, 'node_modules'), join(this.config.dataDir, 'node_modules.old')),
342 rm(join(this.config.dataDir, 'yarn.lock'), { force: true }),
343 ]);
344 // Spawn a new process so that node_modules can be deleted asynchronously.
345 const rmScript = join(dirname(fileURLToPath(import.meta.url)), 'rm.js');
346 this.debug(`spawning ${rmScript} to remove node_modules.old`);
347 spawn(process.argv[0], [rmScript, join(this.config.dataDir, 'node_modules.old')], {
348 detached: true,
349 stdio: 'ignore',
350 ...(this.config.windows ? { shell: true } : {}),
351 }).unref();
352 }
353 catch (error) {
354 this.debug('Error cleaning up yarn.lock and node_modules:', error);
355 }
356 }
357 }
358 async npmHasPackage(name, throwOnNotFound = false) {
359 try {
360 await this.npm.view([name], {
361 cwd: this.config.dataDir,
362 logLevel: this.logLevel,
363 });
364 this.debug(`Found ${name} in the registry.`);
365 return true;
366 }
367 catch (error) {
368 this.debug(error);
369 if (throwOnNotFound)
370 throw new Errors.CLIError(`${name} does not exist in the registry.`);
371 return false;
372 }
373 }
374 get pjsonPath() {
375 return join(this.config.dataDir, 'package.json');
376 }
377 async readPJSON() {
378 try {
379 return JSON.parse(await readFile(this.pjsonPath, 'utf8'));
380 }
381 catch (error) {
382 this.debug(error);
383 const err = error;
384 if (err.code !== 'ENOENT')
385 process.emitWarning(err);
386 }
387 }
388 async savePJSON(pjson) {
389 await mkdir(dirname(this.pjsonPath), { recursive: true });
390 await writeFile(this.pjsonPath, JSON.stringify({ name: this.config.name, ...pjson }, null, 2));
391 }
392}
393// if the plugin is a simple string, convert it to an object
394const normalizePlugins = (input) => {
395 const normalized = (input ?? []).map((p) => typeof p === 'string'
396 ? {
397 name: p,
398 tag: 'latest',
399 type: 'user',
400 }
401 : p);
402 return dedupePlugins(normalized);
403};