1 | import { Config, Errors, ux } from '@oclif/core';
|
2 | import { bold } from 'ansis';
|
3 | import makeDebug from 'debug';
|
4 | import { spawn } from 'node:child_process';
|
5 | import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
6 | import { basename, dirname, join, resolve } from 'node:path';
|
7 | import { fileURLToPath } from 'node:url';
|
8 | import { gt, valid, validRange } from 'semver';
|
9 | import { NPM } from './npm.js';
|
10 | import { uniqWith } from './util.js';
|
11 | import { Yarn } from './yarn.js';
|
12 | const initPJSON = {
|
13 | dependencies: {},
|
14 | oclif: { plugins: [], schema: 1 },
|
15 | private: true,
|
16 | };
|
17 | async function fileExists(filePath) {
|
18 | try {
|
19 | await access(filePath);
|
20 | return true;
|
21 | }
|
22 | catch {
|
23 | return false;
|
24 | }
|
25 | }
|
26 | function dedupePlugins(plugins) {
|
27 | return uniqWith(plugins, (a, b) => a.name === b.name || (a.type === 'link' && b.type === 'link' && a.root === b.root));
|
28 | }
|
29 | function 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 | }
|
37 | function 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 | }
|
46 | export 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) ??
|
83 | list.find((p) => this.unfriendlyName(p.name) === unfriendlyName) ??
|
84 | list.find((p) => p.type === 'link' && resolve(p.root) === resolve(name)) ??
|
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 |
|
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 |
|
122 |
|
123 |
|
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 |
|
137 | const range = validRange(tag);
|
138 | const unfriendly = this.unfriendlyName(name);
|
139 | if (unfriendly && (await this.npmHasPackage(unfriendly))) {
|
140 | name = unfriendly;
|
141 | }
|
142 |
|
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 |
|
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 |
|
279 | if (to)
|
280 | await this.install(to);
|
281 |
|
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 |
|
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 |
|
302 |
|
303 |
|
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 |
|
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 |
|
335 |
|
336 |
|
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 |
|
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 |
|
394 | const 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 | };
|