feat(cli): refactoring and detection of configs

This commit is contained in:
Guz013
2023-08-01 16:20:17 -03:00
parent 48b70de8d9
commit f7b6faff09
4 changed files with 362 additions and 233 deletions

324
packages/cli/src/cli.js Executable file
View File

@@ -0,0 +1,324 @@
#!node
import fs from 'node:fs/promises';
import path, { join } from 'node:path';
import { existsSync } from 'node:fs';
import { createSpinner } from 'nanospinner';
import glob from 'picomatch';
import YAML from 'yaml';
import Debugger from './debugger.js';
import c from 'picocolors';
/**
* @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
*/
async function tryRun(promise) {
try {
return await promise;
}
catch (err) {
return null;
}
}
/**
* @param {string} directory - The directory to find .gitignore and .eslintignore
* @returns {Promise<string[]>} - List of ignore glob patterns
*/
async function getIgnoredFiles(directory) {
const gitIgnore = (await tryRun(fs.readFile(join(directory, '.gitignore'), 'utf8')) ?? '')
.split('\n')
.filter(p => p && !p.startsWith('#'))
.map(p => join(directory, '**', p));
const eslintIgnore = (await tryRun(fs.readFile(join(directory, '.eslintignore'), 'utf8')) ?? '')
.split('\n')
.filter(p => p && !p.startsWith('#'))
.map(p => join(directory, '**', p));
return [...eslintIgnore, ...gitIgnore];
}
/**
* @param {string} directory - The directory to work in.
* @returns {Promise<string>} - The package name founded.
*/
async function getPackageName(directory) {
if (existsSync(join(directory, 'package.json'))) {
const file = await fs.readFile(join(directory, 'package.json'), 'utf8');
/** @type {{name?: string}} */
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const obj = JSON.parse(file);
if (obj.name) return obj.name;
}
return path.normalize(directory).split('/').at(-1) ?? directory;
}
/**
* @param {string} directory - The directory path to work on
* @param {{files: string[], directories: string[]}} paths - The file list to be filtered
* @param {string[]} [packages] - The packages to be filtered
* @returns {Promise<import('./types').Package>} - The package object
*/
async function getRootPackage(directory, paths, packages = []) {
const ignorePatterns = [
...packages.map(p =>
`${join(directory, p, '**/*')}`,
)];
console.log(ignorePatterns);
return {
name: `${await getPackageName(directory)} [ROOT]`,
files: paths.files.filter(f =>
!glob.isMatch(f, ignorePatterns),
) ?? [],
directories: paths.directories.filter(d =>
!glob.isMatch(d, ignorePatterns),
) ?? [],
path: directory,
};
}
export default class Cli {
/** @type {string} */
dir = process.cwd();
/** @type {boolean} */
debug = false;
/** @type {import('./types').Config[]} */
configs;
/**
* @param {{
* configs: import('./types').Config[]
* packages?: string[],
* directory?: string,
* debug?: boolean,
* }} options - Cli options
*/
constructor(options) {
this.configs = options?.configs;
this.packages = options.packages;
this.dir = path.normalize(options.directory ?? this.dir);
this.debug = options.debug ?? this.debug;
this.#debugger = new Debugger(this.debug);
}
#debugger = new Debugger(this.debug);
/** @type {{files: string[], directories: string[]} | undefined} */
#paths;
/**
* @param {string} [directory] - The directory to work on
* @param {string[]} [ignores] - Glob patterns to ignore
* @returns {Promise<{files: string[], directories: string[]}>} - List of all files in the directory
*/
async getPaths(directory = this.dir, ignores = []) {
ignores.push(
...[
'.git',
'.dist',
'.DS_Store',
'node_modules',
].map((f) => join(directory, f)),
...await getIgnoredFiles(directory),
);
const paths = (await fs.readdir(directory))
.map((f) => path.normalize(join(directory, f)))
.filter((p) => !glob.isMatch(p, ignores));
/** @type {string[]} */
const files = [];
/** @type {string[]} */
const directories = [];
for (const path of paths) {
if ((await fs.lstat(path)).isDirectory()) {
const subPaths = await this.getPaths(path, ignores);
directories.push(path, ...subPaths.directories);
files.push(...subPaths.files);
}
else {
files.push(path);
}
}
return { files, directories };
}
/** @type {string[] | undefined} */
packages;
/**
* @returns {Promise<string[]>} - List of packages on a directory;
*/
async getPackages() {
/** @type {string[]} */
let packages = [];
const pnpmWorkspace =
existsSync(join(this.dir, 'pnpm-workspace.yaml'))
? 'pnpm-workspace.yaml'
: existsSync(join(this.dir, 'pnpm-workspace.yml'))
? 'pnpm-workspace.yml'
: null;
if (pnpmWorkspace) {
const fileYaml = await fs.readFile(join(this.dir, pnpmWorkspace), 'utf8');
/** @type {{packages?: string[]}} */
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const fileObj = YAML.parse(fileYaml);
packages.push(...(fileObj?.packages ?? []));
}
else if (existsSync(join(this.dir, 'package.json'))) {
const packageJson = await fs.readFile(join(this.dir, 'package.json'), 'utf8');
/** @type {{workspaces?: string[]}} */
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const packageObj = JSON.parse(packageJson);
packages.push(...(packageObj?.workspaces ?? []));
}
return packages;
}
/** @type {import('./types').Workspace | undefined} */
#workspace;
/**
* @returns {Promise<import('./types').Workspace>}
* The workspace structure and packages founded
*/
async getWorkspace() {
console.log(this.packages);
const rootPackage = await getRootPackage(this.dir, this.#paths ?? { files: [], directories: [] }, this.packages);
/** @type {string[]} */
const packagesPaths = this.#paths?.directories.filter(d =>
glob.isMatch(d, this.packages?.map(p => join(this.dir, p)) ?? ''),
) ?? [];
/** @type {import('./types').Package[]} */
const packages = [];
for (const pkgPath of packagesPaths) {
packages.push({
name: await getPackageName(pkgPath),
files: this.#paths?.files.filter(f => glob.isMatch(f, join(pkgPath, '**/*'))) ?? [],
directories: this.#paths?.directories.filter(f => glob.isMatch(f, join(pkgPath, '**/*'))) ?? [],
path: pkgPath,
});
}
return {
packages: [
rootPackage,
...packages,
],
};
}
/**
* @param {import('./types').Package} pkg - The package to detect configs
* @returns {import('./types').Package['config']} - Detected configs record
*/
detectConfig(pkg) {
const spinner = createSpinner(`Configuring ${c.bold(c.blue(pkg.name))}`);
spinner.start();
/** @type {import('./types').Package['config']} */
const pkgConfig = {};
for (const config of this.configs) {
pkgConfig[config.name] = this.detectOptions(
pkg,
config.options,
config.type === 'single',
spinner,
);
spinner.update({ text: `Configuring ${c.bold(c.blue(pkg.name))}${c.dim(`: config ${config.name}`)}` });
}
spinner.success({ text: `Configuring ${c.bold(c.blue(pkg.name))}` });
return pkgConfig;
}
/**
* @param {import('./types').Package} pkg - Package to detect from
* @param {import('./types').Config['options']} options - Options to be passed
* @param {boolean} single - Whether to only detect one option
* @param {import('nanospinner').Spinner} spinner - Spinner to update
* @returns {string[]} - The detected options
*/
detectOptions(pkg, options, single, spinner) {
/** @type {string[]} */
const detectedOptions = [];
for (const option of options) {
spinner.update({
text: `Configuring ${c.bold(c.blue(pkg.name))}${c.dim(`: option ${c.bold(option.name)}`)}`,
});
if (option.detect === true) {
detectedOptions.push(option.name);
spinner.update({
text: `Configuring ${c.bold(c.blue(pkg.name))}${c.dim(`: option ${c.bold(option.name)} ${c.green('✓')}`)}`,
});
continue;
}
else if (!option.detect) continue;
const match = glob(option.detect.map(p => join(pkg.path, p)));
const files = pkg.files.filter(f => match ? match(f) : false);
const directories = pkg.directories.filter(f => match ? match(f) : false);
if (files.length > 0 || directories.length > 0) {
detectedOptions.push(option.name);
spinner.update({
text: `Configuring ${c.bold(c.blue(pkg.name))}${c.dim(`: option ${c.bold(option.name)} ${c.green('✔')}`)}`,
});
if (single) break;
}
else {
spinner.update({
text: `Configuring ${c.bold(c.blue(pkg.name))}${c.dim(`: option ${c.bold(option.name)} ${c.red('✖')}`)}`,
});
}
}
return detectedOptions;
}
async run() {
this.packages ||= await this.getPackages();
this.#paths = await this.getPaths();
this.#workspace = await this.getWorkspace();
this.#workspace.packages = this.#workspace.packages.map(
pkg => {
pkg.config = this.detectConfig(pkg); return pkg;
},
);
console.log(JSON.stringify(this.#workspace.packages, null, 2));
}
}

View File

@@ -0,0 +1,22 @@
/** @type {import('./types').Config[]} */
export default [
{
name: 'framework',
type: 'single',
options: [
{
name: 'svelte',
packages: { '@eslit/svelte': 'svelte' },
rules: ['svelte.default'],
detect: ['**/*.svelte', 'svelte.config.{js,ts,cjs,cts}'],
},
{
name: 'vue',
packages: { '@eslint/vue': 'vue' },
rules: ['vue.default'],
detect: ['nuxt.config.{js,ts,cjs,cts}', '**/*.vue'],
},
],
},
];

231
packages/cli/src/index.js Executable file → Normal file
View File

@@ -1,228 +1,5 @@
#!node
import fs from 'node:fs/promises';
import path, { join } from 'node:path';
import { existsSync } from 'node:fs';
import YAML from 'yaml';
import glob from 'picomatch';
import Cli from './cli.js';
import configs from './configs.js';
/**
* @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
*/
async function tryRun(promise) {
try {
return await promise;
}
catch (err) {
return null;
}
}
/**
* @param {string} directory - The directory to find .gitignore and .eslintignore
* @returns {Promise<string[]>} - List of ignore glob patterns
*/
async function getIgnoredFiles(directory) {
const gitIgnore = (await tryRun(fs.readFile(join(directory, '.gitignore'), 'utf8')) ?? '')
.split('\n')
.filter(p => p && !p.startsWith('#'))
.map(p => join(directory, '**', p));
const eslintIgnore = (await tryRun(fs.readFile(join(directory, '.eslintignore'), 'utf8')) ?? '')
.split('\n')
.filter(p => p && !p.startsWith('#'))
.map(p => join(directory, '**', p));
return [...eslintIgnore, ...gitIgnore];
}
/**
* @param {string} directory - The directory to work in.
* @returns {Promise<string>} - The package name founded.
*/
async function getPackageName(directory) {
if (existsSync(join(directory, 'package.json'))) {
const file = await fs.readFile(join(directory, 'package.json'), 'utf8');
/** @type {{name?: string}} */
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const obj = JSON.parse(file);
if (obj.name) return obj.name;
}
return path.normalize(directory).split('/').at(-1) ?? directory;
}
/**
* @param {string} directory - The directory path to work on
* @param {{files: string[], directories: string[]}} paths - The file list to be filtered
* @param {string[]} [packages] - The packages to be filtered
* @returns {Promise<import('./types').Package>} - The package object
*/
async function getRootPackage(directory, paths, packages = []) {
const ignorePatterns = [
...packages.map(p =>
`${join(directory, p, '**/*')}`,
)];
console.log(ignorePatterns);
return {
name: `${await getPackageName(directory)} [ROOT]`,
files: paths.files.filter(f =>
!glob.isMatch(f, ignorePatterns),
) ?? [],
directories: paths.directories.filter(d =>
!glob.isMatch(d, ignorePatterns),
) ?? [],
};
}
class Cli {
/** @type {string} */
dir = process.cwd();
/**
* @param {string} [directory] - The directory to the cli work on
* @param {string[]} [packages] - List of packages paths in the workspace
*/
constructor(
directory,
packages,
) {
this.dir ||= path.normalize(directory ?? this.dir);
this.packages ||= packages;
}
/** @type {{files: string[], directories: string[]} | undefined} */
#paths;
/**
* @param {string} [directory] - The directory to work on
* @param {string[]} [ignores] - Glob patterns to ignore
* @returns {Promise<{files: string[], directories: string[]}>} - List of all files in the directory
*/
async getPaths(directory = this.dir, ignores = []) {
ignores.push(
...[
'.git',
'.dist',
'.DS_Store',
'node_modules',
].map((f) => join(directory, f)),
...await getIgnoredFiles(directory),
);
const paths = (await fs.readdir(directory))
.map((f) => path.normalize(join(directory, f)))
.filter((p) => !glob.isMatch(p, ignores));
/** @type {string[]} */
const files = [];
/** @type {string[]} */
const directories = [];
for (const path of paths) {
if ((await fs.lstat(path)).isDirectory()) {
const subPaths = await this.getPaths(path, ignores);
directories.push(path, ...subPaths.directories);
files.push(...subPaths.files);
}
else {
files.push(path);
}
}
return { files, directories };
}
/** @type {string[] | undefined} */
packages;
/**
* @returns {Promise<string[]>} - List of packages on a directory;
*/
async getPackages() {
/** @type {string[]} */
let packages = [];
const pnpmWorkspace =
existsSync(join(this.dir, 'pnpm-workspace.yaml'))
? 'pnpm-workspace.yaml'
: existsSync(join(this.dir, 'pnpm-workspace.yml'))
? 'pnpm-workspace.yml'
: null;
if (pnpmWorkspace) {
const fileYaml = await fs.readFile(join(this.dir, pnpmWorkspace), 'utf8');
/** @type {{packages?: string[]}} */
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const fileObj = YAML.parse(fileYaml);
packages.push(...(fileObj?.packages ?? []));
}
else if (existsSync(join(this.dir, 'package.json'))) {
const packageJson = await fs.readFile(join(this.dir, 'package.json'), 'utf8');
/** @type {{workspaces?: string[]}} */
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const packageObj = JSON.parse(packageJson);
packages.push(...(packageObj?.workspaces ?? []));
}
return packages;
}
/** @type {import('./types').Workspace | undefined} */
#workspace;
/**
* @returns {Promise<import('./types').Workspace>}
* The workspace structure and packages founded
*/
async getWorkspace() {
console.log(this.packages);
const rootPackage = await getRootPackage(this.dir, this.#paths ?? { files: [], directories: [] }, this.packages);
/** @type {string[]} */
const packagesPaths = this.#paths?.directories.filter(d =>
glob.isMatch(d, this.packages?.map(p => join(this.dir, p)) ?? ''),
) ?? [];
/** @type {import('./types').Package[]} */
const packages = [];
for (const pkgPath of packagesPaths) {
packages.push({
name: await getPackageName(pkgPath),
files: this.#paths?.files.filter(f => glob.isMatch(f, join(pkgPath, '**/*'))) ?? [],
directories: this.#paths?.directories.filter(f => glob.isMatch(f, join(pkgPath, '**/*'))) ?? [],
});
}
return {
packages: [
rootPackage,
...packages,
],
};
}
async run() {
this.packages ||= await this.getPackages();
this.#paths = await this.getPaths();
this.#workspace = await this.getWorkspace();
console.log(this.dir);
console.log(this.#workspace.packages);
}
}
await new Cli().run();
const cli = new Cli({ configs });
await cli.run();

View File

@@ -1,10 +1,14 @@
interface Options {
environment: {
node: boolean
deno: boolean
browser: boolean
}
export interface Config {
name: string
type: 'single' | 'multiple'
manual?: boolean
options: {
name: string
packages: Record<string, string | string[] | [string, string][]>
rules: string[]
detect?: string[] | true
}[]
}
export interface Workspace {
@@ -13,6 +17,8 @@ export interface Workspace {
export interface Package {
name: string
path: string
files: string[]
directories: string[]
config?: Record<string, string[]>
}