diff --git a/README.md b/README.md index b816073f5e..df7623d810 100644 --- a/README.md +++ b/README.md @@ -95,17 +95,19 @@ It's handy to install globally too: ```bash npm i -g @feltcoop/gro -gro # should print some stuff - defers to the project's locally installed version of Gro +gro # prints available tasks - defers to the project's locally installed version of Gro ``` ## usage ```bash -gro # list all available tasks with the pattern `*.task.ts` +gro # print all available tasks with the pattern `*.task.ts` +gro --help # print more info about each available task gro some/dir # list all tasks inside `src/some/dir` gro some/file # run `src/some/file.task.ts` gro some/file.task.ts # same as above gro test # run `src/test.task.ts` if it exists, falling back to Gro's builtin +gro test --help # print info about the "test" task; works for every task ``` Gro has a number of builtin tasks that you can run with the CLI. diff --git a/changelog.md b/changelog.md index d370fdc565..9da4cb6745 100644 --- a/changelog.md +++ b/changelog.md @@ -9,7 +9,7 @@ - add schema information to task args ([#306](https://github.com/feltcoop/gro/pull/306)) - add `--help` flag to `gro` and `gro taskname`, along with `gro help` alias - ([#306](https://github.com/feltcoop/gro/pull/306)) + ([#306](https://github.com/feltcoop/gro/pull/306), [#307](https://github.com/feltcoop/gro/pull/307)) - combine imports in generated schema types ([#304](https://github.com/feltcoop/gro/pull/304)) - add CLI opt-outs to `gro check` for `no-typecheck`, `no-test`, `no-gen`, `no-format`, & `no-lint` diff --git a/src/docs/tasks.md b/src/docs/tasks.md index cd812071f9..a65098fad9 100644 --- a/src/docs/tasks.md +++ b/src/docs/tasks.md @@ -14,6 +14,7 @@ What is a `Task`? See [`tasks.md`](./task.md). - [dev](../dev.task.ts) - start dev server - [format](../format.task.ts) - format source files - [gen](../gen.task.ts) - run code generation scripts +- [help](../help.task.ts) - alias for `gro` with no task name provided - [lint](../lint.task.ts) - run eslint on the source files - [publish](../publish.task.ts) - bump version, publish to npm, and git push - [serve](../serve.task.ts) - start static file server diff --git a/src/help.task.ts b/src/help.task.ts new file mode 100644 index 0000000000..e129426dc2 --- /dev/null +++ b/src/help.task.ts @@ -0,0 +1,10 @@ +import {spawn} from '@feltcoop/felt/util/process.js'; + +import {type Task} from './task/task.js'; + +export const task: Task = { + summary: 'alias for `gro` with no task name provided', + run: async (): Promise => { + await spawn('npx', ['gro']); + }, +}; diff --git a/src/task/invokeTask.ts b/src/task/invokeTask.ts index 72efa5a66b..e2bd92a2b8 100644 --- a/src/task/invokeTask.ts +++ b/src/task/invokeTask.ts @@ -1,21 +1,18 @@ import {cyan, red, gray} from 'kleur/colors'; -import {SystemLogger, type Logger, printLogLabel} from '@feltcoop/felt/util/log.js'; +import {SystemLogger, printLogLabel} from '@feltcoop/felt/util/log.js'; import {EventEmitter} from 'events'; import {createStopwatch, Timings} from '@feltcoop/felt/util/timings.js'; import {printMs, printTimings} from '@feltcoop/felt/util/print.js'; -import {plural} from '@feltcoop/felt/util/string.js'; import {spawn} from '@feltcoop/felt/util/process.js'; import {serializeArgs, type Args} from '../task/task.js'; import {runTask} from './runTask.js'; import {resolveRawInputPath, getPossibleSourceIds} from '../fs/inputPath.js'; -import {TASK_FILE_SUFFIX, isTaskPath, toTaskName} from './task.js'; +import {TASK_FILE_SUFFIX, isTaskPath} from './task.js'; import { paths, groPaths, - sourceIdToBasePath, replaceRootDir, - pathsFromId, isGroId, toImportId, isThisProjectGro, @@ -27,6 +24,7 @@ import {loadTaskModule} from './taskModule.js'; import {loadGroPackageJson} from '../utils/packageJson.js'; import {SYSTEM_BUILD_NAME} from '../build/buildConfigDefaults.js'; import {type Filesystem} from '../fs/filesystem.js'; +import {logAvailableTasks, logErrorReasons} from './logTask.js'; /* @@ -120,6 +118,7 @@ export const invokeTask = async ( ); const timingToRunTask = timings.start('run task'); const dev = process.env.NODE_ENV !== 'production'; // TODO should this use `fromEnv`? '$app/env'? + // If we're in dev mode but the task is only for production, run it in a new process. if (dev && task.mod.task.production) { const result = await spawn('npx', ['gro', taskName, ...serializeArgs(args)], { env: {...process.env, NODE_ENV: 'production'}, @@ -135,6 +134,7 @@ export const invokeTask = async ( throw Error('Spawned task failed'); } } else { + // Run the task in the current process. const result = await runTask(fs, task, args, events, invokeTask); timingToRunTask(); if (result.ok) { @@ -154,10 +154,14 @@ export const invokeTask = async ( // eslint-disable-next-line no-lonely-if if (isThisProjectGro) { // Is the Gro directory the same as the cwd? Log the matching files. - logAvailableTasks(log, printPath(pathData.id), findModulesResult.sourceIdsByInputPath); + await logAvailableTasks( + log, + printPath(pathData.id), + findModulesResult.sourceIdsByInputPath, + ); } else if (isGroId(pathData.id)) { // Does the Gro directory contain the matching files? Log them. - logAvailableTasks( + await logAvailableTasks( log, printPathOrGroPath(pathData.id), findModulesResult.sourceIdsByInputPath, @@ -177,14 +181,19 @@ export const invokeTask = async ( const groPathData = groDirFindModulesResult.sourceIdPathDataByInputPath.get(groDirInputPath)!; // First log the Gro matches. - logAvailableTasks( + await logAvailableTasks( log, printPathOrGroPath(groPathData.id), groDirFindModulesResult.sourceIdsByInputPath, ); } // Then log the current working directory matches. - logAvailableTasks(log, printPath(pathData.id), findModulesResult.sourceIdsByInputPath); + await logAvailableTasks( + log, + printPath(pathData.id), + findModulesResult.sourceIdsByInputPath, + !groDirFindModulesResult.ok, + ); } } } else if (findModulesResult.type === 'inputDirectoriesWithNoFiles') { @@ -209,7 +218,7 @@ export const invokeTask = async ( const groPathData = groDirFindModulesResult.sourceIdPathDataByInputPath.get(groDirInputPath)!; // Log the Gro matches. - logAvailableTasks( + await logAvailableTasks( log, printPathOrGroPath(groPathData.id), groDirFindModulesResult.sourceIdsByInputPath, @@ -231,28 +240,6 @@ export const invokeTask = async ( log.info(`🕒 ${printMs(totalTiming())}`); }; -const logAvailableTasks = ( - log: Logger, - dirLabel: string, - sourceIdsByInputPath: Map, -): void => { - const sourceIds = Array.from(sourceIdsByInputPath.values()).flat(); - if (sourceIds.length) { - log.info(`${sourceIds.length} task${plural(sourceIds.length)} in ${dirLabel}:`); - for (const sourceId of sourceIds) { - log.info('\t' + cyan(toTaskName(sourceIdToBasePath(sourceId, pathsFromId(sourceId))))); - } - } else { - log.info(`No tasks found in ${dirLabel}.`); - } -}; - -const logErrorReasons = (log: Logger, reasons: string[]): void => { - for (const reason of reasons) { - log.error(reason); - } -}; - // This is a best-effort heuristic that quickly detects if // we should compile a project's TypeScript when invoking a task. // Properly detecting this is too expensive and would slow task startup time significantly. diff --git a/src/task/logTask.ts b/src/task/logTask.ts new file mode 100644 index 0000000000..40a6dcd17b --- /dev/null +++ b/src/task/logTask.ts @@ -0,0 +1,99 @@ +import {cyan, gray, green} from 'kleur/colors'; +import {Logger} from '@feltcoop/felt/util/log.js'; +import {plural} from '@feltcoop/felt/util/string.js'; +import {printValue} from '@feltcoop/felt/util/print.js'; + +import {type ArgSchema, type ArgsSchema} from './task.js'; +import {loadModules} from '../fs/modules.js'; +import {loadTaskModule, type TaskModuleMeta} from './taskModule.js'; + +export const logAvailableTasks = async ( + log: Logger, + dirLabel: string, + sourceIdsByInputPath: Map, + printIntro = true, +): Promise => { + const sourceIds = Array.from(sourceIdsByInputPath.values()).flat(); + if (sourceIds.length) { + // Load all of the tasks so we can print their summary, and args for the `--help` flag. + const loadModulesResult = await loadModules(sourceIdsByInputPath, true, loadTaskModule); + if (!loadModulesResult.ok) { + logErrorReasons(log, loadModulesResult.reasons); + process.exit(1); + } + const printed: string[] = [ + `${printIntro ? '\n\n' : ''}${sourceIds.length} task${plural( + sourceIds.length, + )} in ${dirLabel}:\n`, + ]; + if (printIntro) { + printed.unshift( + `\n\n${gray('Run a task:')} gro [name]`, + `\n${gray('View help:')} gro [name] --help`, + ); + } + const longestTaskName = toMaxLength(loadModulesResult.modules, (m) => m.name); + for (const meta of loadModulesResult.modules) { + printed.push('\n' + cyan(pad(meta.name, longestTaskName)), ' ', meta.mod.task.summary || ''); + } + log[printIntro ? 'info' : 'plain'](printed.join('') + '\n'); + } else { + log.info(`No tasks found in ${dirLabel}.`); + } +}; + +export const logErrorReasons = (log: Logger, reasons: string[]): void => { + for (const reason of reasons) { + log.error(reason); + } +}; + +const ARGS_PROPERTY_NAME = '[...args]'; + +// TODO format output in a table +export const logTaskHelp = (log: Logger, meta: TaskModuleMeta): void => { + const { + name, + mod: {task}, + } = meta; + const printed: string[] = []; + printed.push(cyan(name), 'help', '\n' + task.summary || '(no summary available)'); + if (task.args) { + const properties = toArgProperties(task.args); + const longestTaskName = Math.max( + ARGS_PROPERTY_NAME.length, + toMaxLength(properties, (p) => p.name), + ); + const longestType = toMaxLength(properties, (p) => p.schema.type); + const longestDefault = toMaxLength(properties, (p) => p.schema.default + ''); + for (const property of properties) { + const name = property.name === '_' ? ARGS_PROPERTY_NAME : property.name; + printed.push( + `\n${green(pad(name, longestTaskName))} `, + gray(pad(property.schema.type, longestType)) + ' ', + pad(printValue(property.schema.default) as string, longestDefault) + ' ', + property.schema.description || '(no description available)', + ); + } + } + log.info(...printed); +}; + +interface ArgSchemaProperty { + name: string; + schema: ArgSchema; +} + +const toArgProperties = (schema: ArgsSchema): ArgSchemaProperty[] => { + const properties: ArgSchemaProperty[] = []; + for (const name in schema.properties) { + if ('no-' + name in schema.properties) continue; + properties.push({name, schema: schema.properties[name]}); + } + return properties; +}; + +// quick n dirty padding logic +const pad = (s: string, n: number): string => s + ' '.repeat(n - s.length); +const toMaxLength = (items: T[], toString: (item: T) => string) => + items.reduce((max, m) => Math.max(toString(m).length, max), 0); diff --git a/src/task/runTask.ts b/src/task/runTask.ts index 7dd29f8e64..76a891e564 100644 --- a/src/task/runTask.ts +++ b/src/task/runTask.ts @@ -1,12 +1,12 @@ import {type EventEmitter} from 'events'; -import {cyan, gray, green, red} from 'kleur/colors'; -import {type Logger, printLogLabel, SystemLogger} from '@feltcoop/felt/util/log.js'; -import {printValue} from '@feltcoop/felt/util/print.js'; +import {cyan, red} from 'kleur/colors'; +import {printLogLabel, SystemLogger} from '@feltcoop/felt/util/log.js'; import {type TaskModuleMeta} from './taskModule.js'; -import {TaskError, type Args, type ArgSchema, type ArgsSchema} from './task.js'; +import {TaskError, type Args} from './task.js'; import {type invokeTask as InvokeTaskFunction} from './invokeTask.js'; import {type Filesystem} from '../fs/filesystem.js'; +import {logTaskHelp} from './logTask.js'; export type RunTaskResult = | { @@ -62,41 +62,3 @@ export const runTask = async ( } return {ok: true, output}; }; - -// TODO format output in a table -const logTaskHelp = (log: Logger, meta: TaskModuleMeta) => { - const { - name, - mod: {task}, - } = meta; - const strs: string[] = ['help', '\n', cyan(name), '\n', task.summary || '(no summary available)']; - if (!task.args) { - strs.push('\n', '(no args schema available)'); - } else { - for (const property of toArgProperties(task.args)) { - const name = property.name === '_' ? '[...args]' : property.name; - strs.push( - '\n', - green(name), - gray(property.schema.type), - printValue(property.schema.default) as string, - property.schema.description || '(no description available)', - ); - } - } - log.info(...strs); -}; - -interface ArgSchemaProperty { - name: string; - schema: ArgSchema; -} - -const toArgProperties = (schema: ArgsSchema): ArgSchemaProperty[] => { - const properties: ArgSchemaProperty[] = []; - for (const name in schema.properties) { - if ('no-' + name in schema.properties) continue; - properties.push({name, schema: schema.properties[name]}); - } - return properties; -}; diff --git a/src/task/task.ts b/src/task/task.ts index ea80a678e9..ad98d28fde 100644 --- a/src/task/task.ts +++ b/src/task/task.ts @@ -50,6 +50,7 @@ export class TaskError extends Error {} // The raw CLI ares are handled by `mri` - https://github.com/lukeed/mri export interface Args { _: string[]; + help?: boolean; [key: string]: unknown; // can assign anything to `args` in tasks }