feat(cli): ✨ install packages after configuration
This commit is contained in:
@@ -10,6 +10,8 @@ import prompts from 'prompts';
|
||||
import ConfigsFile from './configsFile.js';
|
||||
import * as cardinal from 'cardinal';
|
||||
import ansi from 'sisteransi';
|
||||
import PackageInstaller from './packageInstaller.js';
|
||||
import notNull from './lib/notNull.js';
|
||||
|
||||
const stdout = process.stdout;
|
||||
|
||||
@@ -28,8 +30,9 @@ export default class Cli {
|
||||
constructor(args) {
|
||||
this.#program
|
||||
.option('--packages <string...>')
|
||||
.option('--merge-to-root')
|
||||
.option('--dir <path>', undefined)
|
||||
.option('--merge-to-root')
|
||||
.option('--install-pkgs')
|
||||
.parse();
|
||||
|
||||
this.args = {
|
||||
@@ -46,6 +49,8 @@ export default class Cli {
|
||||
|
||||
async run() {
|
||||
|
||||
process.chdir(this.args.dir);
|
||||
|
||||
const spinner = createSpinner('Detecting workspace configuration');
|
||||
|
||||
const processor = new ConfigsProcessor({ configs });
|
||||
@@ -110,6 +115,40 @@ export default class Cli {
|
||||
|
||||
}
|
||||
|
||||
const packagesMap = new Map(packages.map(p => [p.path, [...notNull(p.configFile).imports.keys()]]));
|
||||
const installer = new PackageInstaller(packagesMap, packages.find(p => p.root === true)?.path ?? this.args.dir);
|
||||
|
||||
/** @type {boolean | 'changePackage'} */
|
||||
let installPkgs = this.args.installPkgs !== undefined ? true :
|
||||
/** @type {{install: boolean | 'changePackage'}} */
|
||||
(await prompts({
|
||||
name: 'install',
|
||||
message:
|
||||
`Would you like to ESLit to install the npm packages with ${c.green(installer.packageManager.name)}?`,
|
||||
choices: [
|
||||
{ title: 'Yes, install all packages', value: true, description: installer.packageManager.description },
|
||||
{ title: 'No, I will install them manually', value: false },
|
||||
{ title: 'Change package manager', value: 'changePackage' },
|
||||
],
|
||||
type: 'select',
|
||||
})).install;
|
||||
|
||||
if (installPkgs === 'changePackage') {
|
||||
/** @type {{manager: import('./types').PackageManagerName}} */
|
||||
const prompt = await prompts({
|
||||
name: 'manager',
|
||||
message: 'What package manager do you want ESLit to use?',
|
||||
choices: Object.values(installer.packageManagers).map(m => {
|
||||
return { title: m.name, description: m.description, value: m.name };
|
||||
}),
|
||||
type: 'select',
|
||||
});
|
||||
installer.packageManager = installer.packageManagers[prompt.manager];
|
||||
installPkgs = true;
|
||||
}
|
||||
|
||||
if (installPkgs) await installer.install();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -8,13 +8,13 @@ export default [
|
||||
options: [
|
||||
{
|
||||
name: 'svelte',
|
||||
packages: { '@eslit/svelte': 'svelte' },
|
||||
packages: { 'svelte': 'svelte' },
|
||||
configs: ['svelte.recommended'],
|
||||
detect: ['**/*.svelte', 'svelte.config.{js,ts,cjs,cts}'],
|
||||
},
|
||||
{
|
||||
name: 'vue',
|
||||
packages: { '@eslit/vue': ['vue', ['hello', 'world']], '@eslit/svelte': ['hello'] },
|
||||
packages: { 'vue': ['vue', ['hello', 'world']], 'svelte': ['hello'] },
|
||||
configs: ['vue.recommended'],
|
||||
detect: ['nuxt.config.{js,ts,cjs,cts}', '**/*.vue'],
|
||||
},
|
||||
@@ -26,7 +26,7 @@ export default [
|
||||
manual: true,
|
||||
options: [{
|
||||
name: 'yes',
|
||||
packages: { '@eslit/config': 'config', '@eslit/vue': ['test1'] },
|
||||
packages: { 'eslint': 'config', 'svelte': ['test1'] },
|
||||
configs: ['config.strict'],
|
||||
}],
|
||||
},
|
||||
|
||||
@@ -144,7 +144,6 @@ export default class ConfigsWriter {
|
||||
|
||||
/**
|
||||
* @typedef {import('estree').Program} Program
|
||||
*
|
||||
* @typedef {(
|
||||
* import('./lib/astUtils.js').ExpressionOrIdentifier |
|
||||
* import('estree').ObjectExpression |
|
||||
@@ -155,7 +154,6 @@ export default class ConfigsWriter {
|
||||
/**
|
||||
* @param {Program} ast The program ast to be manipulated
|
||||
* @returns {Promise<Program>} The final ast with the recreated default export
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async addDefaultExport(ast) {
|
||||
@@ -203,7 +201,6 @@ export default class ConfigsWriter {
|
||||
/**
|
||||
* @param {import('./types').ConfigFile['rules']} rules The rules to be used to create the object
|
||||
* @returns {import('estree').ObjectExpression} The object containing the spread rules
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
createRulesObject(rules) {
|
||||
@@ -233,13 +230,10 @@ export default class ConfigsWriter {
|
||||
|
||||
/**
|
||||
* Adds elements to the default export node, without adding duplicates
|
||||
*
|
||||
* @typedef {import('estree').ArrayExpression} ArrayExpression
|
||||
*
|
||||
* @param {Program} ast The program ast to be manipulated
|
||||
* @param {ConfigArrayElement[]} elements The elements to be added to the array
|
||||
* @returns {Program} The final ast with the recreated default export
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
addElementsToExport(ast, elements) {
|
||||
@@ -273,8 +267,6 @@ export default class ConfigsWriter {
|
||||
/** @type {import('estree').ImportDeclaration[]} */
|
||||
const importDeclarations = [];
|
||||
|
||||
console.log(imports);
|
||||
|
||||
for (const [pkgName, specifiers] of imports) {
|
||||
/** @type {import('estree').ImportDeclaration | undefined} */
|
||||
// @ts-expect-error type error, the specifier has to be ImportDeclaration to be founded
|
||||
|
||||
@@ -8,19 +8,12 @@ import * as recast from 'recast';
|
||||
* import('estree').NewExpression
|
||||
* )} ExpressionOrIdentifier
|
||||
* This type only includes the expressions used in the cli's config type
|
||||
*
|
||||
* @typedef {import('estree').VariableDeclaration} VariableDeclaration
|
||||
*
|
||||
* @typedef {import('estree').Identifier['name']} IdentifierName
|
||||
*
|
||||
* @typedef {VariableDeclaration['kind']} VariableKind
|
||||
*
|
||||
* @typedef {import('estree').VariableDeclarator['init']} VariableInit
|
||||
*
|
||||
* @typedef {import('estree').SpreadElement} SpreadElement
|
||||
*
|
||||
* @typedef {import('estree').Expression} Expression
|
||||
*
|
||||
* @typedef {import('estree').ArrayExpression} ArrayExpression
|
||||
*/
|
||||
|
||||
@@ -97,7 +90,6 @@ export function toSpreadElement(expression) {
|
||||
* addSpecifier: (specifier: string, alias?: string) => ThisType<ImportDeclarationHelper>
|
||||
* convertDefaultSpecifier: () => ThisType<ImportDeclarationHelper>
|
||||
* }} ImportDeclarationHelper
|
||||
*
|
||||
* @param {string} source The package name or source path to be imported
|
||||
* @param {string} [defaultImported] The default specifier imported
|
||||
* @param {import('estree').ImportDeclaration} [body] The body of the import declaration to start with
|
||||
@@ -119,12 +111,10 @@ export function createImportDeclaration(source, defaultImported, body) {
|
||||
},
|
||||
/**
|
||||
* Converts a default specifier to a specifier with a alias.
|
||||
*
|
||||
* @example
|
||||
* import eslit from 'eslit';
|
||||
* // Is converted to
|
||||
* import { default as eslit } from 'eslit';
|
||||
*
|
||||
* @returns {ThisType<ImportDeclarationHelper>} This helper with the converted default specifier
|
||||
*/
|
||||
convertDefaultSpecifier() {
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
/**
|
||||
* JSDoc types lack a non-null assertion.
|
||||
*
|
||||
* @template T
|
||||
*
|
||||
* @param {T} value The value which to assert against null or undefined
|
||||
* @returns {NonNullable<T>} The said value
|
||||
*
|
||||
* @throws {TypeError} If the value is unexpectedly null or undefined
|
||||
*
|
||||
* @author Jimmy Wärting - https://github.com/jimmywarting
|
||||
*
|
||||
* @see https://github.com/Microsoft/TypeScript/issues/23405#issuecomment-873331031
|
||||
*
|
||||
* @see https://github.com/Microsoft/TypeScript/issues/23405#issuecomment-1249287966
|
||||
*/
|
||||
export default function notNull(value) {
|
||||
|
||||
210
packages/cli/src/packageInstaller.js
Normal file
210
packages/cli/src/packageInstaller.js
Normal file
@@ -0,0 +1,210 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { exec } from 'node:child_process';
|
||||
import { createSpinner } from 'nanospinner';
|
||||
import c from 'picocolors';
|
||||
import * as recast from 'recast';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
|
||||
/**
|
||||
* @type {import('./types').PackageManagerHandler}
|
||||
*/
|
||||
class CommandHandler {
|
||||
|
||||
/** @type {string} */
|
||||
command;
|
||||
|
||||
/** @type {((path: string, packages: string[]) => string | Promise<string>) | undefined} */
|
||||
checker;
|
||||
|
||||
/**
|
||||
* @param {string} command What command to use to install
|
||||
* @param {(path: string, packages: string[]) => string | Promise<string>} [checker] Checks if a argument should be passed
|
||||
*/
|
||||
constructor(command, checker) {
|
||||
this.command = command;
|
||||
this.checker = checker;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} path The path to run the command
|
||||
* @param {string[]} packages The packages to be added on the command
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async install(path, packages) {
|
||||
|
||||
if (this.checker)
|
||||
this.command += await this.checker(path, packages);
|
||||
|
||||
return new Promise((res) => {
|
||||
const spinner = createSpinner(`Installing packages with ${c.green(this.command)} ${c.dim(packages.join(' '))}`).start();
|
||||
try {
|
||||
const child = exec(`${this.command} ${packages.join(' ')}`, { cwd: path });
|
||||
child.stdout?.on('data', (chunk) => spinner.update({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
text: `Installing packages with ${c.green(this.command)} ${c.dim(packages.join(' '))}\n ${c.dim(chunk)}`,
|
||||
}));
|
||||
child.stdout?.on('close', () => {
|
||||
spinner.success({
|
||||
text: `Installed packages with ${c.green(this.command)}`,
|
||||
}); res();
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
res(console.error(`Error while installing the packages with ${this.command} ${c.dim(packages.join(' '))} on ${path}: ${error}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import('./types').PackageManagerHandler}
|
||||
*/
|
||||
class DenoHandler {
|
||||
|
||||
/**
|
||||
* @param {string} path The path to run the command
|
||||
* @param {string[]} packages The packages to be added on the command
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async install(path, packages) {
|
||||
const configPath = join(path, 'eslint.config.js');
|
||||
|
||||
if (!existsSync(configPath)) return;
|
||||
|
||||
const configFile = await readFile(configPath, 'utf8');
|
||||
/** @type {{program: import('estree').Program}}*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const { program: ast } = recast.parse(configFile, { parser: (await import('recast/parsers/babel.js')) });
|
||||
|
||||
ast.body.map((node) => {
|
||||
if (node.type !== 'ImportDeclaration') return node;
|
||||
|
||||
if (packages.includes(node.source.value?.toString() ?? '')) {
|
||||
node.source.value = `npm:${node.source.value}`;
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
await writeFile(configPath, recast.prettyPrint(ast).code, 'utf-8');
|
||||
|
||||
console.log(c.green('Added npm: specifier to dependencies'));
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default class PackageInstaller {
|
||||
|
||||
/**
|
||||
* @typedef {Map<string, string[]>} PackagesMap
|
||||
* @type {PackagesMap}
|
||||
*/
|
||||
packagesMap;
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* name: import('./types').PackageManagerName
|
||||
* description: string
|
||||
* handler: import('./types').PackageManagerHandler
|
||||
* }} PackageManager
|
||||
* @type {PackageManager}
|
||||
*/
|
||||
packageManager;
|
||||
|
||||
/**
|
||||
* @type {Record<import('./types').PackageManagerName, PackageManager>}
|
||||
*/
|
||||
packageManagers = {
|
||||
deno: {
|
||||
name: 'deno',
|
||||
description: 'Adds npm: specifiers to the eslint.config.js file',
|
||||
handler: new DenoHandler(),
|
||||
},
|
||||
bun: {
|
||||
name: 'bun',
|
||||
description: 'Uses bun install',
|
||||
handler: new CommandHandler('bun install'),
|
||||
},
|
||||
pnpm: {
|
||||
name: 'pnpm',
|
||||
description: 'Uses pnpm install',
|
||||
handler: new CommandHandler('pnpm install --save-dev', (path) => {
|
||||
if (existsSync(join(path, 'pnpm-workspace.yaml')) && existsSync(join(path, 'package.json')))
|
||||
return ' -w';
|
||||
else return '';
|
||||
}),
|
||||
},
|
||||
yarn: {
|
||||
name: 'yarn',
|
||||
description: 'Uses yarn add',
|
||||
handler: new CommandHandler('yarn add --dev'),
|
||||
},
|
||||
npm: {
|
||||
name: 'npm',
|
||||
description: 'Uses npm install',
|
||||
handler: new CommandHandler('npm install --save-dev'),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {PackagesMap} packagesMap The map of directories and packages to be installed
|
||||
* @param {string} root Root directory path
|
||||
*/
|
||||
constructor(packagesMap, root) {
|
||||
this.packagesMap = packagesMap;
|
||||
this.packageManager = this.detectPackageManager(root);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} root Root directory path
|
||||
* @returns {PackageManager} The package manager detected;
|
||||
* @private
|
||||
*/
|
||||
detectPackageManager(root) {
|
||||
/** @type {(...path: string[]) => boolean} */
|
||||
const exists = (...path) => existsSync(join(root, ...path));
|
||||
|
||||
switch (true) {
|
||||
case exists('deno.json'):
|
||||
case exists('deno.jsonc'):
|
||||
return this.packageManagers.deno;
|
||||
|
||||
case exists('bun.lockb'):
|
||||
return this.packageManagers.bun;
|
||||
|
||||
case exists('pnpm-lock.yaml'):
|
||||
return this.packageManagers.pnpm;
|
||||
|
||||
case exists('yarn.lock'):
|
||||
return this.packageManagers.yarn;
|
||||
|
||||
case exists('package-lock.json'):
|
||||
return this.packageManagers.npm;
|
||||
|
||||
case exists('package.json'):
|
||||
/** @type {{packageManager?: string}} */
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, no-case-declarations
|
||||
const { packageManager } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
||||
if (!packageManager) return this.packageManagers.npm;
|
||||
|
||||
if (packageManager.includes('pnpm')) return this.packageManagers.pnpm;
|
||||
if (packageManager.includes('yarn')) return this.packageManagers.yarn;
|
||||
if (packageManager.includes('npm')) return this.packageManagers.npm;
|
||||
|
||||
else return this.packageManagers.npm;
|
||||
|
||||
default: return this.packageManagers.npm;
|
||||
}
|
||||
}
|
||||
|
||||
async install() {
|
||||
for (const [path, packages] of this.packagesMap) {
|
||||
await this.packageManager.handler.install(path, packages);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
7
packages/cli/src/types.d.ts
vendored
7
packages/cli/src/types.d.ts
vendored
@@ -1,8 +1,11 @@
|
||||
import type { OptionValues } from 'commander';
|
||||
|
||||
export type PackageManagerName = 'npm' | 'pnpm' | 'yarn' | 'bun' | 'deno';
|
||||
|
||||
export type CliArgs = {
|
||||
packages?: string[]
|
||||
mergeToRoot?: boolean
|
||||
installPkgs?: boolean | PackageManagerName
|
||||
dir: string
|
||||
} & OptionValues;
|
||||
|
||||
@@ -52,3 +55,7 @@ export interface ConfigFile {
|
||||
rules: string[]
|
||||
content?: string
|
||||
}
|
||||
|
||||
export interface PackageManagerHandler {
|
||||
install(path: string, packages: string[]): Promise<void> | void
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import picomatch from 'picomatch';
|
||||
|
||||
/**
|
||||
* @template T
|
||||
*
|
||||
* @param {Promise<T>} promise - The async function to try running
|
||||
* @returns {Promise<T | null>} - Returns the result of the async function, or null if it errors
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,6 @@ import jsdoc from 'eslint-plugin-jsdoc';
|
||||
|
||||
/**
|
||||
* JSDoc rules overrides
|
||||
*
|
||||
* @type {Readonly<import('eslint').Linter.FlatConfig>}
|
||||
*/
|
||||
const config = {
|
||||
@@ -11,16 +10,6 @@ const config = {
|
||||
rules: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
...jsdoc.configs['recommended-typescript-flavor-error'].rules,
|
||||
|
||||
'jsdoc/tag-lines': ['error', 'always', {
|
||||
count: 1,
|
||||
applyToEndTag: false,
|
||||
startLines: 1,
|
||||
endLines: 0,
|
||||
tags: {
|
||||
param: { lines: 'never' },
|
||||
},
|
||||
}],
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
|
||||
Reference in New Issue
Block a user