From 64dc504e2aded8f03d377719173143329102fe13 Mon Sep 17 00:00:00 2001 From: Guz013 <43732358+Guz013@users.noreply.github.com> Date: Wed, 23 Aug 2023 10:54:15 -0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20=E2=9C=A8=20install=20packages=20a?= =?UTF-8?q?fter=20configuration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/src/cli.js | 41 +++++- packages/cli/src/configs.js | 6 +- packages/cli/src/configsFile.js | 8 - packages/cli/src/lib/astUtils.js | 10 -- packages/cli/src/lib/notNull.js | 6 - packages/cli/src/packageInstaller.js | 210 +++++++++++++++++++++++++++ packages/cli/src/types.d.ts | 7 + packages/cli/src/workspace.js | 1 - packages/config/src/configs/jsdoc.js | 11 -- 9 files changed, 260 insertions(+), 40 deletions(-) create mode 100644 packages/cli/src/packageInstaller.js diff --git a/packages/cli/src/cli.js b/packages/cli/src/cli.js index 63ca5ff..990923b 100644 --- a/packages/cli/src/cli.js +++ b/packages/cli/src/cli.js @@ -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 ') - .option('--merge-to-root') .option('--dir ', 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(); + } } diff --git a/packages/cli/src/configs.js b/packages/cli/src/configs.js index 44d948a..7f40b1b 100644 --- a/packages/cli/src/configs.js +++ b/packages/cli/src/configs.js @@ -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'], }], }, diff --git a/packages/cli/src/configsFile.js b/packages/cli/src/configsFile.js index e800c26..152a05f 100644 --- a/packages/cli/src/configsFile.js +++ b/packages/cli/src/configsFile.js @@ -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} 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 diff --git a/packages/cli/src/lib/astUtils.js b/packages/cli/src/lib/astUtils.js index a008f43..824d48d 100644 --- a/packages/cli/src/lib/astUtils.js +++ b/packages/cli/src/lib/astUtils.js @@ -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 * convertDefaultSpecifier: () => ThisType * }} 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} This helper with the converted default specifier */ convertDefaultSpecifier() { diff --git a/packages/cli/src/lib/notNull.js b/packages/cli/src/lib/notNull.js index cca1b49..b58aa98 100644 --- a/packages/cli/src/lib/notNull.js +++ b/packages/cli/src/lib/notNull.js @@ -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} 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) { diff --git a/packages/cli/src/packageInstaller.js b/packages/cli/src/packageInstaller.js new file mode 100644 index 0000000..4d9ca9b --- /dev/null +++ b/packages/cli/src/packageInstaller.js @@ -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) | undefined} */ + checker; + + /** + * @param {string} command What command to use to install + * @param {(path: string, packages: string[]) => string | Promise} [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} + */ + 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} + */ + 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} PackagesMap + * @type {PackagesMap} + */ + packagesMap; + + /** + * @typedef {{ + * name: import('./types').PackageManagerName + * description: string + * handler: import('./types').PackageManagerHandler + * }} PackageManager + * @type {PackageManager} + */ + packageManager; + + /** + * @type {Record} + */ + 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); + } + } + +} diff --git a/packages/cli/src/types.d.ts b/packages/cli/src/types.d.ts index eced0d0..e784a55 100644 --- a/packages/cli/src/types.d.ts +++ b/packages/cli/src/types.d.ts @@ -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 +} diff --git a/packages/cli/src/workspace.js b/packages/cli/src/workspace.js index 3a1e1e9..f4fd0e0 100644 --- a/packages/cli/src/workspace.js +++ b/packages/cli/src/workspace.js @@ -8,7 +8,6 @@ import picomatch from 'picomatch'; /** * @template T - * * @param {Promise} promise - The async function to try running * @returns {Promise} - Returns the result of the async function, or null if it errors */ diff --git a/packages/config/src/configs/jsdoc.js b/packages/config/src/configs/jsdoc.js index 9ffe6e2..f3169f6 100644 --- a/packages/config/src/configs/jsdoc.js +++ b/packages/config/src/configs/jsdoc.js @@ -2,7 +2,6 @@ import jsdoc from 'eslint-plugin-jsdoc'; /** * JSDoc rules overrides - * * @type {Readonly} */ 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;