Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli): setup yargs for cli #42

Merged
merged 15 commits into from
Sep 8, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
931 changes: 769 additions & 162 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@
],
"dependencies": {
"bundle-require": "^4.0.1",
"chalk": "^5.3.0",
"yargs": "^17.7.2",
"zod": "^3.22.1"
},
"devDependencies": {
"@types/chalk": "^2.2.0",
"@nx/esbuild": "16.7.4",
"@nx/eslint-plugin": "16.7.4",
"@nx/js": "16.7.4",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"@nx/dependency-checks": [
"error",
{
"ignoredDependencies": ["vite", "@nx/vite"]
"ignoredDependencies": ["vite", "vitest", "@nx/vite"]
}
]
}
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
"name": "@quality-metrics/cli",
"version": "0.0.1",
"dependencies": {
"bundle-require": "^4.0.1"
"bundle-require": "^4.0.1",
"chalk": "^5.3.0",
"yargs": "^17.7.2",
"zod": "^3.22.1",
"@quality-metrics/models": ">0.0.1"
}
}
19 changes: 19 additions & 0 deletions packages/cli/src/bin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#! /usr/bin/env node
import { hideBin } from 'yargs/helpers';
import { yargsCli } from './lib/cli';
import { yargsGlobalOptionsDefinition } from './lib/options';
import { middlewares } from './lib/middlewares';
import { commands } from './lib/commands';

// bootstrap yargs; format arguments
yargsCli(
// hide first 2 args from process
hideBin(process.argv),
{
usageMessage: 'CPU CLI',
scriptName: 'cpu',
options: yargsGlobalOptionsDefinition(),
middlewares,
commands,
},
).argv;
16 changes: 5 additions & 11 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import { pathToFileURL } from 'url';
import { cli } from './lib/cli';

export { cli };

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
if (!process.argv[2]) {
throw new Error('Missing config file path');
}
cli(process.argv[2]).then(console.log).catch(console.error);
}
export { yargsCli } from './lib/cli';
export {
CommandBase,
commandBaseSchema,
} from './lib/implementation/base-command-config';
52 changes: 52 additions & 0 deletions packages/cli/src/lib/cli.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { yargsCli } from './cli';
import { join } from 'path';
import { yargsGlobalOptionsDefinition } from './options';
import { middlewares } from './middlewares';
import { CommandBase } from '../index';

const withDirName = (path: string) => join(__dirname, path);
BioPhoton marked this conversation as resolved.
Show resolved Hide resolved
const validConfigPath = withDirName('implementation/mock/cli-config.mock.js');

const options = yargsGlobalOptionsDefinition();
const demandCommand: [number, string] = [0, 'no command required'];

describe('cli', () => {
BioPhoton marked this conversation as resolved.
Show resolved Hide resolved
it('options should provide correct defaults', async () => {
const args: string[] = [];
const parsedArgv: CommandBase = yargsCli(args, {
options,
demandCommand,
}).argv;
expect(parsedArgv.configPath).toContain('code-pushup.config.js');
expect(parsedArgv.verbose).toBe(false);
expect(parsedArgv.interactive).toBe(true);
});

it('options should parse correctly', async () => {
const args: string[] = [
'--verbose',
'--no-interactive',
'--configPath',
validConfigPath,
];

const parsedArgv: CommandBase = yargsCli(args, {
options,
demandCommand,
}).argv;
expect(parsedArgv.configPath).toContain(validConfigPath);
expect(parsedArgv.verbose).toBe(true);
expect(parsedArgv.interactive).toBe(false);
});

it('middleware should use config correctly', async () => {
const args: string[] = ['--configPath', validConfigPath];
const parsedArgv: CommandBase = await yargsCli(args, {
demandCommand,
middlewares: middlewares,
}).argv;
expect(parsedArgv.configPath).toContain(validConfigPath);
expect(parsedArgv.persist.outputPath).toContain('cli-config-out.json');
});
});
78 changes: 70 additions & 8 deletions packages/cli/src/lib/cli.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,71 @@
import { bundleRequire } from 'bundle-require';

export async function cli(configPath: string) {
const { mod } = await bundleRequire({
filepath: configPath,
format: 'esm',
});
return mod.default || mod;
import yargs, {
Argv,
CommandModule,
MiddlewareFunction,
Options,
ParserConfigurationOptions,
} from 'yargs';
import chalk from 'chalk';
import { CoreConfig } from '@quality-metrics/models';

/**
* returns configurable yargs CLI for code-pushup
*
* @example
* yargsCli(hideBin(process.argv))
* // bootstrap CLI; format arguments
* .argv;
*/
export function yargsCli(
argv: string[],
cfg: {
usageMessage?: string;
scriptName?: string;
commands?: CommandModule[];
demandCommand?: [number, string];
options?: { [key: string]: Options };
middlewares?: {
middlewareFunction: MiddlewareFunction;
applyBeforeValidation?: boolean;
}[];
},
): Argv<CoreConfig> {
const { usageMessage, scriptName } = cfg;
let { commands, options, middlewares, demandCommand } = cfg;
demandCommand = Array.isArray(demandCommand)
? demandCommand
: [1, 'Minimum 1 command!'];
commands = Array.isArray(commands) ? commands : [];
middlewares = Array.isArray(middlewares) ? middlewares : [];
options = options || {};
const cli = yargs(argv);

// setup yargs
cli
.parserConfiguration({
'strip-dashed': true,
} satisfies Partial<ParserConfigurationOptions>)
.options(options)
.demandCommand(...demandCommand);

// usage message
if (usageMessage) {
cli.usage(chalk.bold(usageMessage));
}

// script name
if (scriptName) {
cli.scriptName(scriptName);
}

// add middlewares
middlewares.forEach(({ middlewareFunction, applyBeforeValidation }) =>
cli.middleware(middlewareFunction, applyBeforeValidation),
);

// add commands
commands.forEach(commandObj => cli.command(commandObj));

// return CLI object
return cli as unknown as Argv<CoreConfig>;
}
3 changes: 3 additions & 0 deletions packages/cli/src/lib/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { CommandModule } from 'yargs';

export const commands: CommandModule[] = [];
BioPhoton marked this conversation as resolved.
Show resolved Hide resolved
11 changes: 11 additions & 0 deletions packages/cli/src/lib/implementation/base-command-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {
globalCliArgsSchema,
refineCoreConfig,
unrefinedCoreConfigSchema,
} from '@quality-metrics/models';
import { z } from 'zod';

export const commandBaseSchema = refineCoreConfig(
globalCliArgsSchema.merge(unrefinedCoreConfigSchema),
);
export type CommandBase = z.infer<typeof commandBaseSchema>;
44 changes: 44 additions & 0 deletions packages/cli/src/lib/implementation/config-middleware.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { join } from 'path';
import { configMiddleware, ConfigParseError } from './config-middleware';
import { expect } from 'vitest';

const withDirName = (path: string) => join(__dirname, path);

describe('applyConfigMiddleware', () => {
it('should load valid config `read-config.mock.mjs`', async () => {
const configPathMjs = withDirName('mock/config-middleware-config.mock.mjs');
const config = await configMiddleware({ configPath: configPathMjs });
expect(config.configPath).toContain('.mjs');
expect(config.persist.outputPath).toContain('mjs-');
});

it('should load valid config `read-config.mock.cjs`', async () => {
const configPathCjs = withDirName('mock/config-middleware-config.mock.cjs');
const config = await configMiddleware({ configPath: configPathCjs });
expect(config.configPath).toContain('.cjs');
expect(config.persist.outputPath).toContain('cjs-');
});

it('should load valid config `read-config.mock.js`', async () => {
const configPathJs = withDirName('mock/config-middleware-config.mock.js');
const config = await configMiddleware({ configPath: configPathJs });
expect(config.configPath).toContain('.js');
expect(config.persist.outputPath).toContain('js-');
});
BioPhoton marked this conversation as resolved.
Show resolved Hide resolved

it('should throw with invalid configPath', async () => {
const configPath = 'wrong/path/to/config';
let error: Error = new Error();
await configMiddleware({ configPath }).catch(e => (error = e));
expect(error?.message).toContain(new ConfigParseError(configPath).message);
});

it('should provide default configPath', async () => {
const defaultConfigPath = 'code-pushup.config.js';
let error: Error = new Error();
await configMiddleware({ configPath: undefined }).catch(e => (error = e));
expect(error?.message).toContain(
new ConfigParseError(defaultConfigPath).message,
);
});
});
32 changes: 32 additions & 0 deletions packages/cli/src/lib/implementation/config-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { existsSync } from 'node:fs';
import { bundleRequire } from 'bundle-require';
import { CommandBase, commandBaseSchema } from '../../index';
import { GlobalCliArgs, globalCliArgsSchema } from '@quality-metrics/models';

export class ConfigParseError extends Error {
constructor(configPath: string) {
super(`Config file ${configPath} does not exist`);
}
}

export async function configMiddleware<T = unknown>(
processArgs: T,
): Promise<CommandBase> {
const globalCfg: GlobalCliArgs = globalCliArgsSchema.parse(processArgs);
const { configPath } = globalCfg;
if (!existsSync(configPath)) {
throw new ConfigParseError(configPath);
}

const { mod } = await bundleRequire({
filepath: globalCfg.configPath,
format: 'esm',
});
const exportedConfig = mod.default || mod;

return commandBaseSchema.parse({
...globalCfg,
...exportedConfig,
...processArgs,
});
}
24 changes: 24 additions & 0 deletions packages/cli/src/lib/implementation/mock/cli-config.mock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
module.exports = {
persist: { outputPath: 'cli-config-out.json' },
categories: [],
plugins: [
{
audits: [],
runner: {
command: 'bash',
args: [
'-c',
`echo '${JSON.stringify({
audits: [],
})}' > cli-config-out.json`,
],
outputPath: 'cli-config-out.json',
},
meta: {
slug: 'execute-plugin',
name: 'execute plugin',
type: 'static-analysis',
BioPhoton marked this conversation as resolved.
Show resolved Hide resolved
},
},
],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
persist: { outputPath: 'cjs-out.json' },
plugins: [],
categories: [],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
persist: { outputPath: 'js-out.json' },
plugins: [],
categories: [],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default {
persist: { outputPath: 'mjs-out.json' },
plugins: [],
categories: [],
};
7 changes: 7 additions & 0 deletions packages/cli/src/lib/middlewares.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { configMiddleware } from './implementation/config-middleware';
import { MiddlewareFunction } from 'yargs';

export const middlewares: {
middlewareFunction: MiddlewareFunction;
applyBeforeValidation?: boolean;
}[] = [{ middlewareFunction: configMiddleware }];
22 changes: 22 additions & 0 deletions packages/cli/src/lib/options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Options } from 'yargs';

export function yargsGlobalOptionsDefinition(): Record<string, Options> {
return {
interactive: {
describe: 'When false disables interactive input prompts for options.',
type: 'boolean',
default: true,
},
verbose: {
describe:
'When true creates more verbose output. This is helpful when debugging.',
type: 'boolean',
default: false,
},
configPath: {
describe: 'Path the the config file. e.g. code-pushup.config.js',
BioPhoton marked this conversation as resolved.
Show resolved Hide resolved
type: 'string',
default: 'code-pushup.config.js',
},
};
}
7 changes: 6 additions & 1 deletion packages/models/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@
"files": ["*.json"],
"parser": "jsonc-eslint-parser",
"rules": {
"@nx/dependency-checks": ["error"]
"@nx/dependency-checks": [
"error",
{
"ignoredDependencies": ["vite", "vitest", "@nx/vite"]
}
]
}
}
]
Expand Down
7 changes: 7 additions & 0 deletions packages/models/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "@quality-metrics/models",
"version": "0.0.1",
"dependencies": {
"zod": "^3.22.1"
}
}
Loading