diff --git a/src/checks/collection.ts b/src/checks/collection.ts new file mode 100644 index 0000000..43d89a8 --- /dev/null +++ b/src/checks/collection.ts @@ -0,0 +1,95 @@ +import type { CheckConfig } from '../types.ts'; + +/** + * Manages a collection of registered checks. + * + * @since TBD + */ +export class CheckCollection { + private checks: Map = new Map(); + + /** + * Registers a check configuration. + * + * @since TBD + * + * @param {CheckConfig} config - The check configuration to add. + * + * @returns {void} + */ + add(config: CheckConfig): void { + this.checks.set(config.slug, config); + } + + /** + * Retrieves a check configuration by its slug. + * + * @since TBD + * + * @param {string} slug - The slug of the check to retrieve. + * + * @returns {CheckConfig | undefined} The check configuration, or undefined if not found. + */ + get(slug: string): CheckConfig | undefined { + return this.checks.get(slug); + } + + /** + * Checks whether a check with the given slug is registered. + * + * @since TBD + * + * @param {string} slug - The slug to check for. + * + * @returns {boolean} True if a check with the given slug is registered, false otherwise. + */ + has(slug: string): boolean { + return this.checks.has(slug); + } + + /** + * Removes a check by its slug. + * + * @since TBD + * + * @param {string} slug - The slug of the check to remove. + * + * @returns {void} + */ + remove(slug: string): void { + this.checks.delete(slug); + } + + /** + * Returns all registered checks as an array. + * + * @since TBD + * + * @returns {CheckConfig[]} An array of all registered check configurations. + */ + getAll(): CheckConfig[] { + return Array.from(this.checks.values()); + } + + /** + * Returns the number of registered checks. + * + * @since TBD + * + * @returns {number} The number of registered checks. + */ + get size(): number { + return this.checks.size; + } + + /** + * Allows iterating over all registered checks. + * + * @since TBD + * + * @returns {Iterator} An iterator over all registered check configurations. + */ + [Symbol.iterator](): Iterator { + return this.checks.values(); + } +} diff --git a/src/checks/types.ts b/src/checks/types.ts new file mode 100644 index 0000000..7a010bb --- /dev/null +++ b/src/checks/types.ts @@ -0,0 +1,12 @@ +import type { CheckConfig, CheckResult } from '../types.ts'; + +export interface CheckContext { + config: CheckConfig; + workingDir: string; + isDev: boolean; +} + +export interface CheckModule { + configure?: (config: CheckConfig) => void; + execute: (context: CheckContext) => Promise; +} diff --git a/src/cli.ts b/src/cli.ts index 165d4fc..6294bce 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,8 +1,10 @@ import { createApp } from './app.ts'; +import { registerCheckCommand } from './commands/check.ts'; import { registerHelpCommand } from './commands/help.ts'; const program = createApp(); +registerCheckCommand(program); registerHelpCommand(program); program.parseAsync(process.argv).catch((err) => { diff --git a/src/commands/check.ts b/src/commands/check.ts new file mode 100644 index 0000000..dd8ef88 --- /dev/null +++ b/src/commands/check.ts @@ -0,0 +1,336 @@ +import type { Command } from 'commander'; +import chalk from 'chalk'; +import { getConfig } from '../config.ts'; + +declare const BUILTIN_CHECK_SLUGS: string[]; +import { runCommand } from '../utils/process.ts'; +import * as output from '../utils/output.ts'; +import type { CheckConfig, CheckResult } from '../types.ts'; + +/** + * Runs all configured checks and returns an exit code. + * + * @since TBD + * + * @param {object} options - The options object. + * @param {boolean} [options.dev] - Whether to use dev failure methods. + * @param {string} [options.root] - The root directory for running commands. + * + * @returns {Promise} The exit code: 0 for success, 1 if any error-level check failed. + */ +export async function runChecks(options: { + dev?: boolean; + root?: string; +}): Promise { + const config = getConfig(); + const checks = config.getChecks(); + const cwd = options.root ?? config.getWorkingDir(); + + if (checks.size === 0) { + output.writeln('📣 The .puprc does not have any checks configured.'); + output.writeln(`💡 If you would like to use the defaults, simply remove the ${chalk.yellow('"checks"')} property in ${chalk.yellow('.puprc')}.`); + output.writeln(''); + output.writeln(`If you would like to use one of the default checks, add one or more of the following to the ${chalk.yellow('"checks"')} property in your ${chalk.yellow('.puprc')}:`); + output.writeln(' "tbd": {}'); + output.writeln(' "version-conflict": {}'); + output.writeln(''); + output.writeln('If you would like to create your own check, take a look at the pup docs to learn how:'); + output.writeln(' https://github.com/stellarwp/pup'); + return 0; + } + + const failures: string[] = []; + + for (const [slug, checkConfig] of checks) { + const failMethod = options.dev + ? checkConfig.fail_method_dev + : checkConfig.fail_method; + const bailOnFailure = failMethod === 'error'; + + output.setPrefix(slug); + + let result: CheckResult; + + if (checkConfig.type === 'pup' || !checkConfig.type) { + result = await runBuiltinCheck(slug, checkConfig, cwd, config); + } else if (checkConfig.type === 'command' && checkConfig.command) { + result = await runShellCheck(checkConfig.command, cwd); + } else if ( + (checkConfig.type === 'simple' || checkConfig.type === 'class') && + checkConfig.file + ) { + result = await runModuleCheck(checkConfig.file, checkConfig, cwd); + } else { + output.warning(`Unknown check type: ${checkConfig.type}`); + output.setPrefix(''); + continue; + } + + if (result.output) { + for (const line of result.output.split('\n')) { + output.log(line); + } + } + + output.setPrefix(''); + + if (!result.success) { + failures.push(slug); + + if (bailOnFailure) { + output.writeln(chalk.yellow(`${slug}'s fail_method in ${chalk.cyan('.puprc')} is set to "${chalk.red('error')}". Exiting...`)); + return 1; + } + } + } + + if (failures.length > 0) { + output.error(`The following checks failed:\n* ${failures.join('\n* ')}`); + } + + return 0; +} + +/** + * Dispatches a built-in pup check by slug. + * + * @since TBD + * + * @param {string} slug - The identifier for the built-in check. + * @param {CheckConfig} _checkConfig - The configuration for this check. + * @param {string} _cwd - The current working directory. + * @param {ReturnType} _config - The resolved pup configuration. + * + * @returns {Promise} The result of the check. + */ +async function runBuiltinCheck( + slug: string, + _checkConfig: CheckConfig, + _cwd: string, + _config: ReturnType +): Promise { + switch (slug) { + default: + return { success: false, output: `Unknown built-in check: ${slug}` }; + } +} + +/** + * Runs a shell command check. + * + * @since TBD + * + * @param {string} command - The shell command to execute. + * @param {string} cwd - The current working directory. + * + * @returns {Promise} The result of the shell check. + */ +async function runShellCheck( + command: string, + cwd: string +): Promise { + const result = await runCommand(command, { cwd, silent: true }); + return { + success: result.exitCode === 0, + output: result.stdout || result.stderr || (result.exitCode === 0 ? 'Success!' : 'Failed.'), + }; +} + +/** + * Dynamically imports and executes a JS/TS module check. + * + * @since TBD + * + * @param {string} file - The path to the module file relative to the working directory. + * @param {CheckConfig} checkConfig - The configuration for this check. + * @param {string} cwd - The current working directory. + * + * @returns {Promise} The result of the module check. + */ +async function runModuleCheck( + file: string, + checkConfig: CheckConfig, + cwd: string +): Promise { + try { + const modulePath = new URL(`file://${cwd}/${file}`).href; + const mod = (await import(modulePath)) as { + configure?: (config: CheckConfig) => void; + execute: (context: { + config: CheckConfig; + workingDir: string; + }) => Promise; + }; + + if (mod.configure) { + mod.configure(checkConfig); + } + + return await mod.execute({ config: checkConfig, workingDir: cwd }); + } catch (err) { + return { + success: false, + output: `Failed to load check module ${file}: ${err}`, + }; + } +} + +/** + * Parses unknown CLI arguments into a key-value map. + * + * @since TBD + * + * @param {string[]} args - The raw argument array from Commander (unknown options). + * + * @returns {Record} A map of argument names to values. + */ +function parseExtraArgs(args: string[]): Record { + const result: Record = {}; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (!arg.startsWith('--')) continue; + + const key = arg.replace(/^--/, ''); + const nextArg = args[i + 1]; + + if (nextArg && !nextArg.startsWith('--')) { + result[key] = nextArg; + i++; + } else { + result[key] = 'true'; + } + } + + return result; +} + +/** + * Runs a single check by slug, handling prefix, output, and exit code. + * + * @since TBD + * + * @param {string} slug - The check slug to run. + * @param {object} options - The options object. + * @param {boolean} [options.dev] - Whether to use dev failure methods. + * @param {string} [options.root] - The root directory for running commands. + * @param {Record} extraArgs - Additional CLI arguments to merge into check args. + * + * @returns {Promise} The exit code: 0 for success, 1 if the check failed. + */ +async function runSingleCheck( + slug: string, + options: { dev?: boolean; root?: string }, + extraArgs: Record = {} +): Promise { + const config = getConfig(); + const checks = config.getChecks(); + const cwd = options.root ?? config.getWorkingDir(); + const isBuiltin = (BUILTIN_CHECK_SLUGS as string[]).includes(slug); + const checkConfig = checks.get(slug) ?? (isBuiltin ? {} as CheckConfig : undefined); + + if (!checkConfig) { + output.error(`Check "${slug}" is not configured.`); + return 1; + } + + if (Object.keys(extraArgs).length > 0) { + checkConfig.args = { ...checkConfig.args, ...extraArgs }; + } + + let result: CheckResult; + + if (checkConfig.type === 'pup' || !checkConfig.type) { + result = await runBuiltinCheck(slug, checkConfig, cwd, config); + } else if (checkConfig.type === 'command' && checkConfig.command) { + result = await runShellCheck(checkConfig.command, cwd); + } else if ( + (checkConfig.type === 'simple' || checkConfig.type === 'class') && + checkConfig.file + ) { + result = await runModuleCheck(checkConfig.file, checkConfig, cwd); + } else { + output.warning(`Unknown check type: ${checkConfig.type}`); + return 1; + } + + if (result.output) { + for (const line of result.output.split('\n')) { + output.log(line); + } + } + + return result.success ? 0 : 1; +} + +/** + * Registers a single `check:{slug}` subcommand on the program. + * + * @since TBD + * + * @param {Command} program - The Commander.js program instance. + * @param {string} slug - The check slug to register. + * + * @returns {void} + */ +function registerCheckSubcommand(program: Command, slug: string): void { + program + .command(`check:${slug}`) + .description(`Run the ${slug} check.`) + .option('--dev', 'Run with dev failure methods.') + .option('--root ', 'Set the root directory for running commands.') + .allowUnknownOption() + .action(async (options: { dev?: boolean; root?: string }, command: Command) => { + const extraArgs = parseExtraArgs(command.args); + const exitCode = await runSingleCheck(slug, options, extraArgs); + if (exitCode !== 0) { + process.exit(exitCode); + } + }); +} + +/** + * Registers the `check` command and individual `check:{slug}` subcommands + * for built-in checks and any custom checks configured in `.puprc`. + * + * @since TBD + * + * @param {Command} program - The Commander.js program instance. + * + * @returns {void} + */ +export function registerCheckCommand(program: Command): void { + program + .command('check') + .description('Run checks against the codebase.') + .option('--dev', 'Run with dev failure methods.') + .option('--root ', 'Set the root directory for running commands.') + .action(async (options: { dev?: boolean; root?: string }) => { + const exitCode = await runChecks(options); + if (exitCode !== 0) { + process.exit(exitCode); + } + }); + + const registered = new Set(); + + // Register built-in checks determined at compile time from src/commands/checks/. + for (const slug of BUILTIN_CHECK_SLUGS) { + registerCheckSubcommand(program, slug); + registered.add(slug); + } + + // Register any custom checks from .puprc that aren't already registered. + try { + const config = getConfig(); + for (const [slug] of config.getChecks()) { + if (registered.has(slug)) continue; + registerCheckSubcommand(program, slug); + registered.add(slug); + } + } catch { + // Config may not be loadable (e.g., no .puprc). Custom subcommands will + // not be registered, but built-in checks and the main `check` command + // still work. + } +} diff --git a/src/config.ts b/src/config.ts index 0ddaa78..28247e2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -284,7 +284,7 @@ export class Config { * * @returns {VersionFile[]} The parsed list of version file objects. * - * @throws {Error} If a version file entry is missing required properties or the file does not exist. + * @throws {Error} If a version file entry is missing required properties. */ private parseVersionFiles(): VersionFile[] { const versions = this.#config.paths?.versions; diff --git a/src/utils/output.ts b/src/utils/output.ts index 319083f..30fb78f 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -37,7 +37,7 @@ export function getPrefix(): string { */ function formatMessage(message: string): string { if (prefix) { - return `[${prefix}] ${message}`; + return `${chalk.blue(`[${prefix}]`)} ${message}`; } return message; } diff --git a/tests/commands/check.test.ts b/tests/commands/check.test.ts new file mode 100644 index 0000000..89e2094 --- /dev/null +++ b/tests/commands/check.test.ts @@ -0,0 +1,159 @@ +import path from 'node:path'; +import fs from 'fs-extra'; +import { + runPup, + writePuprc, + getPuprc, + createTempProject, + cleanupTempProjects, +} from '../helpers/setup.js'; + +describe('check command', () => { + let projectDir: string; + + beforeEach(() => { + projectDir = createTempProject(); + writePuprc(getPuprc(), projectDir); + }); + + afterEach(() => { + cleanupTempProjects(); + }); + + it('should show guidance when checks is an empty object', async () => { + writePuprc(getPuprc({ checks: {} }), projectDir); + + const result = await runPup('check', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('The .puprc does not have any checks configured.'); + expect(result.output).toContain('"tbd": {}'); + expect(result.output).toContain('"version-conflict": {}'); + }); +}); + +describe('custom checks', () => { + afterEach(() => { + cleanupTempProjects(); + }); + + it('should run a passing command-type check', async () => { + const projectDir = createTempProject(); + writePuprc(getPuprc({ + checks: { + 'my-check': { + type: 'command', + command: 'echo "custom check passed"', + }, + }, + }), projectDir); + + const result = await runPup('check', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('[my-check]'); + expect(result.output).toContain('custom check passed'); + }); + + it('should run a failing command-type check with warn', async () => { + const projectDir = createTempProject(); + writePuprc(getPuprc({ + checks: { + 'my-check': { + type: 'command', + command: 'exit 1', + fail_method: 'warn', + }, + }, + }), projectDir); + + const result = await runPup('check', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('[my-check]'); + expect(result.output).toContain('The following checks failed:'); + expect(result.output).toContain('my-check'); + }); + + it('should bail on a failing command-type check with error', async () => { + const projectDir = createTempProject(); + writePuprc(getPuprc({ + checks: { + 'my-check': { + type: 'command', + command: 'exit 1', + fail_method: 'error', + }, + }, + }), projectDir); + + const result = await runPup('check', { cwd: projectDir }); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain('[my-check]'); + expect(result.output).toContain("my-check's fail_method in .puprc is set to"); + }); + + it('should register custom check as subcommand', async () => { + const projectDir = createTempProject(); + writePuprc(getPuprc({ + checks: { + 'my-check': { + type: 'command', + command: 'echo "ran via subcommand"', + }, + }, + }), projectDir); + + const result = await runPup('check:my-check', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('ran via subcommand'); + expect(result.output).not.toContain('[my-check]'); + }); + + it('should run a simple module check', async () => { + const projectDir = createTempProject(); + + const checkScript = path.join(projectDir, 'my-simple-check.mjs'); + fs.writeFileSync(checkScript, ` +export async function execute({ config, workingDir }) { + return { success: true, output: 'simple module check passed' }; +} +`); + + writePuprc(getPuprc({ + checks: { + 'simple-check': { + type: 'simple', + file: 'my-simple-check.mjs', + }, + }, + }), projectDir); + + const result = await runPup('check', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('[simple-check]'); + expect(result.output).toContain('simple module check passed'); + }); + + it('should pass CLI args through to a subcommand check', async () => { + const projectDir = createTempProject(); + + const checkScript = path.join(projectDir, 'args-check.mjs'); + fs.writeFileSync(checkScript, ` +export async function execute({ config }) { + const version = config.args['some-arg'] || 'none'; + return { success: true, output: 'some-arg=' + version }; +} +`); + + writePuprc(getPuprc({ + checks: { + 'args-check': { + type: 'simple', + file: 'args-check.mjs', + }, + }, + }), projectDir); + + const result = await runPup('check:args-check --some-arg 5.0.1', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('some-arg=5.0.1'); + }); +}); diff --git a/tsdown.config.ts b/tsdown.config.ts index bd9bfca..a24e9f5 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,4 +1,12 @@ import { defineConfig } from 'tsdown'; +import fs from 'node:fs'; + +const builtinCheckSlugs = fs.existsSync('src/commands/checks') + ? fs + .readdirSync('src/commands/checks') + .filter((f) => f.endsWith('.ts')) + .map((f) => f.replace(/\.ts$/, '')) + : []; export default defineConfig({ entry: ['src/cli.ts'], @@ -8,6 +16,9 @@ export default defineConfig({ fixedExtension: false, sourcemap: true, dts: false, + define: { + BUILTIN_CHECK_SLUGS: JSON.stringify(builtinCheckSlugs), + }, outputOptions: { banner: '#!/usr/bin/env node', },