@oclif/plugin-plugins
Version:
plugins plugin for oclif
404 lines (403 loc) • 17 kB
JavaScript
import { Config, Errors, ux } from '@oclif/core';
import { bold } from 'ansis';
import makeDebug from 'debug';
import { spawn } from 'node:child_process';
import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { basename, dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { gt, valid, validRange } from 'semver';
import { NPM } from './npm.js';
import { uniqWith } from './util.js';
import { Yarn } from './yarn.js';
const initPJSON = {
dependencies: {},
oclif: { plugins: [], schema: 1 },
private: true,
};
async function fileExists(filePath) {
try {
await access(filePath);
return true;
}
catch {
return false;
}
}
function dedupePlugins(plugins) {
return uniqWith(plugins, (a, b) => a.name === b.name || (a.type === 'link' && b.type === 'link' && a.root === b.root));
}
function extractIssuesLocation(bugs, repository) {
if (bugs) {
return typeof bugs === 'string' ? bugs : bugs.url;
}
if (repository) {
return typeof repository === 'string' ? repository : repository.url.replace('git+', '').replace('.git', '');
}
}
function notifyUser(plugin, output) {
const containsWarnings = [...output.stdout, ...output.stderr].some((l) => l.includes('npm WARN'));
if (containsWarnings) {
ux.stderr(bold.yellow(`\nThese warnings can only be addressed by the owner(s) of ${plugin.name}.`));
if (plugin.pjson.bugs || plugin.pjson.repository) {
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`);
}
}
}
export default class Plugins {
config;
npm;
debug;
logLevel;
constructor(options) {
this.config = options.config;
this.debug = makeDebug('@oclif/plugin-plugins');
this.logLevel = options.logLevel ?? 'notice';
this.npm = new NPM({
config: this.config,
logLevel: this.logLevel,
});
}
async add(...plugins) {
const pjson = await this.pjson();
const mergedPlugins = [...(pjson.oclif.plugins || []), ...plugins];
await this.savePJSON({
...pjson,
oclif: {
...pjson.oclif,
plugins: dedupePlugins(mergedPlugins),
},
});
}
friendlyName(name) {
const { pluginPrefix, scope } = this.config.pjson.oclif;
if (!scope)
return name;
const match = name.match(`@${scope}/${pluginPrefix ?? 'plugin'}-(.+)`);
return match?.[1] ?? name;
}
async hasPlugin(name) {
const list = await this.list();
const friendlyName = this.friendlyName(name);
const unfriendlyName = this.unfriendlyName(name) ?? name;
return (list.find((p) => this.friendlyName(p.name) === friendlyName) ?? // friendly
list.find((p) => this.unfriendlyName(p.name) === unfriendlyName) ?? // unfriendly
list.find((p) => p.type === 'link' && resolve(p.root) === resolve(name)) ?? // link
false);
}
async install(name, { force = false, tag = 'latest' } = {}) {
await this.maybeCleanUp();
try {
this.debug(`installing plugin ${name}`);
const options = { cwd: this.config.dataDir, logLevel: this.logLevel, prod: true };
await this.ensurePJSON();
let plugin;
const args = force ? ['--force'] : [];
if (name.includes(':')) {
// url
const url = name;
const output = await this.npm.install([...args, url], options);
const { dependencies } = await this.pjson();
const { default: npa } = await import('npm-package-arg');
const normalizedUrl = npa(url);
const matches = Object.entries(dependencies ?? {}).find(([, u]) => {
const normalized = npa(u);
return (normalized.hosted?.type === normalizedUrl.hosted?.type &&
normalized.hosted?.user === normalizedUrl.hosted?.user &&
normalized.hosted?.project === normalizedUrl.hosted?.project);
});
const installedPluginName = matches?.[0];
if (!installedPluginName)
throw new Errors.CLIError(`Could not find plugin name for ${url}`);
const root = join(this.config.dataDir, 'node_modules', installedPluginName);
plugin = await Config.load({
devPlugins: false,
name: installedPluginName,
root,
userPlugins: false,
});
notifyUser(plugin, output);
this.isValidPlugin(plugin);
await this.add({ name: installedPluginName, type: 'user', url });
// Check that the prepare script produced all the expected files
// If it didn't, it might be because the plugin doesn't have a prepare
// script that compiles the plugin from source.
const safeToNotExist = new Set(['oclif.manifest.json', 'oclif.lock', 'npm-shrinkwrap.json']);
const files = (plugin.pjson.files ?? [])
.map((f) => join(root, f))
.filter((f) => !safeToNotExist.has(basename(f)));
this.debug(`checking for existence of files: ${files.join(', ')}`);
const results = Object.fromEntries(await Promise.all(files?.map(async (f) => [f, await fileExists(f)]) ?? []));
this.debug(results);
if (!Object.values(results).every(Boolean)) {
ux.warn(`This plugin from github may not work as expected because the prepare script did not produce all the expected files.`);
}
}
else {
// npm
const range = validRange(tag);
const unfriendly = this.unfriendlyName(name);
if (unfriendly && (await this.npmHasPackage(unfriendly))) {
name = unfriendly;
}
// validate that the package name exists in the npm registry before installing
await this.npmHasPackage(name, true);
const output = await this.npm.install([...args, `${name}@${tag}`], options);
this.debug(`loading plugin ${name}...`);
plugin = await Config.load({
devPlugins: false,
name,
root: join(this.config.dataDir, 'node_modules', name),
userPlugins: false,
});
this.debug(`finished loading plugin ${name} at root ${plugin.root}`);
notifyUser(plugin, output);
this.isValidPlugin(plugin);
await this.add({ name, tag: range ?? tag, type: 'user' });
}
await rm(join(this.config.dataDir, 'yarn.lock'), { force: true });
return plugin;
}
catch (error) {
this.debug('error installing plugin:', error);
await this.uninstall(name).catch((error) => this.debug(error));
if (String(error).includes('EACCES')) {
throw new Errors.CLIError(error, {
suggestions: [
`Plugin failed to install because of a permissions error.\nDoes your current user own the directory ${this.config.dataDir}?`,
],
});
}
throw error;
}
}
async link(p, { install }) {
const c = await Config.load(resolve(p));
this.isValidPlugin(c);
if (install) {
if (await fileExists(join(c.root, 'yarn.lock'))) {
this.debug('installing dependencies with yarn');
const yarn = new Yarn({ config: this.config, logLevel: this.logLevel });
await yarn.install([], {
cwd: c.root,
logLevel: this.logLevel,
});
}
else if (await fileExists(join(c.root, 'package-lock.json'))) {
this.debug('installing dependencies with npm');
await this.npm.install([], {
cwd: c.root,
logLevel: this.logLevel,
prod: false,
});
}
else if (await fileExists(join(c.root, 'pnpm-lock.yaml'))) {
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}.`);
}
else {
ux.warn(`No lockfile found in ${c.root}. The link succeeded, but you may need to install the dependencies in your project.`);
}
}
await this.add({ name: c.name, root: c.root, type: 'link' });
return c;
}
async list() {
const pjson = await this.pjson();
return pjson.oclif.plugins;
}
async maybeUnfriendlyName(name) {
await this.ensurePJSON();
const unfriendly = this.unfriendlyName(name);
this.debug(`checking registry for expanded package name ${unfriendly}`);
if (unfriendly && (await this.npmHasPackage(unfriendly))) {
return unfriendly;
}
this.debug(`expanded package name ${unfriendly} not found, using given package name ${name}`);
return name;
}
async pjson() {
const pjson = await this.readPJSON();
const plugins = pjson ? normalizePlugins(pjson.oclif.plugins) : [];
return {
...initPJSON,
...pjson,
oclif: {
...initPJSON.oclif,
...pjson?.oclif,
plugins,
},
};
}
async remove(name) {
const pjson = await this.pjson();
if (pjson.dependencies)
delete pjson.dependencies[name];
await this.savePJSON({
...pjson,
oclif: {
...pjson.oclif,
plugins: pjson.oclif.plugins.filter((p) => p.name !== name),
},
});
}
unfriendlyName(name) {
if (name.includes('@'))
return;
const { pluginPrefix, scope } = this.config.pjson.oclif;
if (!scope)
return;
return `@${scope}/${pluginPrefix ?? 'plugin'}-${name}`;
}
async uninstall(name) {
try {
const pjson = await this.pjson();
if ((pjson.oclif.plugins ?? []).some((p) => typeof p === 'object' && p.type === 'user' && p.name === name)) {
await this.npm.uninstall([name], {
cwd: this.config.dataDir,
logLevel: this.logLevel,
});
}
}
catch (error) {
ux.warn(error);
}
finally {
await this.remove(name);
}
}
async update() {
let plugins = (await this.list()).filter((p) => p.type === 'user');
if (plugins.length === 0)
return;
await this.maybeCleanUp();
// migrate deprecated plugins
const aliases = this.config.pjson.oclif.aliases || {};
for (const [name, to] of Object.entries(aliases)) {
const plugin = plugins.find((p) => p.name === name);
if (!plugin)
continue;
// eslint-disable-next-line no-await-in-loop
if (to)
await this.install(to);
// eslint-disable-next-line no-await-in-loop
await this.uninstall(name);
plugins = plugins.filter((p) => p.name !== name);
}
const urlPlugins = plugins.filter((p) => Boolean(p.url));
if (urlPlugins.length > 0) {
await this.npm.update(urlPlugins.map((p) => p.name), {
cwd: this.config.dataDir,
logLevel: this.logLevel,
});
}
const npmPlugins = plugins.filter((p) => !p.url);
const jitPlugins = this.config.pjson.oclif.jitPlugins ?? {};
const modifiedPlugins = [];
if (npmPlugins.length > 0) {
await this.npm.install(npmPlugins.map((p) => {
// a not valid tag indicates that it's a dist-tag like 'latest'
if (!valid(p.tag))
return `${p.name}@${p.tag}`;
if (p.tag && valid(p.tag) && jitPlugins[p.name] && gt(p.tag, jitPlugins[p.name])) {
// The user has installed a version of the JIT plugin that is newer than the one
// specified by the root plugin's JIT configuration. In this case, we want to
// keep the version installed by the user.
return `${p.name}@${p.tag}`;
}
const tag = jitPlugins[p.name] ?? p.tag;
modifiedPlugins.push({ ...p, tag });
return `${p.name}@${tag}`;
}), { cwd: this.config.dataDir, logLevel: this.logLevel, prod: true });
}
await this.add(...modifiedPlugins);
}
async ensurePJSON() {
if (!(await fileExists(this.pjsonPath))) {
this.debug(`creating ${this.pjsonPath} with pjson: ${JSON.stringify(initPJSON, null, 2)}`);
await this.savePJSON(initPJSON);
}
}
isValidPlugin(p) {
if (p.valid)
return true;
if (this.config.plugins.get('@oclif/plugin-legacy') ||
// @ts-expect-error because _base is private
p._base.includes('@oclif/plugin-legacy')) {
return true;
}
throw new Errors.CLIError('plugin is invalid', {
suggestions: [
'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.',
],
});
}
async maybeCleanUp() {
// If the yarn.lock exists, then we assume that the last install was done with an older major
// version of plugin-plugins that used yarn (v1). In this case, we want to remove the yarn.lock
// and node_modules to ensure a clean install or update.
if (await fileExists(join(this.config.dataDir, 'yarn.lock'))) {
try {
this.debug('Found yarn.lock! Removing yarn.lock and node_modules...');
await Promise.all([
rename(join(this.config.dataDir, 'node_modules'), join(this.config.dataDir, 'node_modules.old')),
rm(join(this.config.dataDir, 'yarn.lock'), { force: true }),
]);
// Spawn a new process so that node_modules can be deleted asynchronously.
const rmScript = join(dirname(fileURLToPath(import.meta.url)), 'rm.js');
this.debug(`spawning ${rmScript} to remove node_modules.old`);
spawn(process.argv[0], [rmScript, join(this.config.dataDir, 'node_modules.old')], {
detached: true,
stdio: 'ignore',
...(this.config.windows ? { shell: true } : {}),
}).unref();
}
catch (error) {
this.debug('Error cleaning up yarn.lock and node_modules:', error);
}
}
}
async npmHasPackage(name, throwOnNotFound = false) {
try {
await this.npm.view([name], {
cwd: this.config.dataDir,
logLevel: this.logLevel,
});
this.debug(`Found ${name} in the registry.`);
return true;
}
catch (error) {
this.debug(error);
if (throwOnNotFound)
throw new Errors.CLIError(`${name} does not exist in the registry.`);
return false;
}
}
get pjsonPath() {
return join(this.config.dataDir, 'package.json');
}
async readPJSON() {
try {
return JSON.parse(await readFile(this.pjsonPath, 'utf8'));
}
catch (error) {
this.debug(error);
const err = error;
if (err.code !== 'ENOENT')
process.emitWarning(err);
}
}
async savePJSON(pjson) {
await mkdir(dirname(this.pjsonPath), { recursive: true });
await writeFile(this.pjsonPath, JSON.stringify({ name: this.config.name, ...pjson }, null, 2));
}
}
// if the plugin is a simple string, convert it to an object
const normalizePlugins = (input) => {
const normalized = (input ?? []).map((p) => typeof p === 'string'
? {
name: p,
tag: 'latest',
type: 'user',
}
: p);
return dedupePlugins(normalized);
};