feat(cli): install packages after configuration

This commit is contained in:
Guz013
2023-08-23 10:54:15 -03:00
parent a830ec71bd
commit 64dc504e2a
9 changed files with 260 additions and 40 deletions

View File

@@ -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();
}
}

View File

@@ -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'],
}],
},

View File

@@ -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

View File

@@ -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() {

View File

@@ -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) {

View 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);
}
}
}

View File

@@ -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
}

View File

@@ -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
*/

View File

@@ -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;