From 778484b29620a8b4b4478749363dd0108d1e0177 Mon Sep 17 00:00:00 2001 From: barbapapazes Date: Sat, 6 Jul 2024 09:41:34 +0200 Subject: [PATCH 1/3] feat: add plugins system --- playground/cli.ts | 2 ++ playground/commands/debug.ts | 15 +++++++++++-- playground/hello.ts | 2 ++ playground/plugins/log.ts | 16 ++++++++++++++ src/command.ts | 18 ++++++++++++++++ src/index.ts | 1 + src/plugin.ts | 41 ++++++++++++++++++++++++++++++++++++ src/types.ts | 9 ++++++++ 8 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 playground/plugins/log.ts create mode 100644 src/plugin.ts diff --git a/playground/cli.ts b/playground/cli.ts index 159211b..2dcbb4b 100644 --- a/playground/cli.ts +++ b/playground/cli.ts @@ -1,4 +1,5 @@ import { defineCommand, runMain } from "../src"; +import logPlugin from "./plugins/log"; const main = defineCommand({ meta: { @@ -12,6 +13,7 @@ const main = defineCommand({ cleanup() { console.log("Cleanup"); }, + plugins: [logPlugin], subCommands: { build: () => import("./commands/build").then((r) => r.default), deploy: () => import("./commands/deploy").then((r) => r.default), diff --git a/playground/commands/debug.ts b/playground/commands/debug.ts index 3f90251..ee3611a 100644 --- a/playground/commands/debug.ts +++ b/playground/commands/debug.ts @@ -1,11 +1,21 @@ import consola from "consola"; -import { defineCommand } from "../../src"; +import { defineCittyPlugin, defineCommand } from "../../src"; + +const buildPlugin = defineCittyPlugin({ + name: "build", + setup() { + consola.info("Setting up build plugin"); + }, + cleanup() { + consola.info("Cleaning up build plugin"); + }, +}); export default defineCommand({ meta: { name: "debug", description: "Debug the project", - hidden: true + hidden: true, }, args: { verbose: { @@ -18,6 +28,7 @@ export default defineCommand({ description: "Only debug a specific function", }, }, + plugins: [buildPlugin], run({ args }) { consola.log("Debug"); consola.log("Parsed args:", args); diff --git a/playground/hello.ts b/playground/hello.ts index f49be7f..38b7d11 100644 --- a/playground/hello.ts +++ b/playground/hello.ts @@ -1,5 +1,6 @@ import consola from "consola"; import { defineCommand, createMain } from "../src"; +import logPlugin from "./plugins/log"; const command = defineCommand({ meta: { @@ -30,6 +31,7 @@ const command = defineCommand({ required: false, }, }, + plugins: [logPlugin], run({ args }) { consola.log(args); const msg = [ diff --git a/playground/plugins/log.ts b/playground/plugins/log.ts new file mode 100644 index 0000000..9765499 --- /dev/null +++ b/playground/plugins/log.ts @@ -0,0 +1,16 @@ +import consola from "consola"; +import { defineCittyPlugin } from "../../src"; + +export default defineCittyPlugin(() => { + consola.success("Log plugin loaded"); + + return { + name: "log", + setup() { + consola.info("Setting up log plugin"); + }, + cleanup() { + consola.info("Cleaning up log plugin"); + }, + }; +}); diff --git a/src/command.ts b/src/command.ts index 1abfe44..2d83743 100644 --- a/src/command.ts +++ b/src/command.ts @@ -1,6 +1,7 @@ import type { CommandContext, CommandDef, ArgsDef } from "./types"; import { CLIError, resolveValue } from "./_utils"; import { parseArgs } from "./args"; +import { resolvePlugins } from "./plugin"; export function defineCommand( def: CommandDef, @@ -28,6 +29,16 @@ export async function runCommand( cmd, }; + const plugins = await resolvePlugins(cmd.plugins ?? []); + + // Apply setup hooks from plugins + for (const plugin of plugins) { + if (typeof plugin.setup === "function") { + // TODO: Pass plugin context? + await plugin.setup(); + } + } + // Setup hook if (typeof cmd.setup === "function") { await cmd.setup(context); @@ -68,6 +79,13 @@ export async function runCommand( if (typeof cmd.cleanup === "function") { await cmd.cleanup(context); } + // Apply cleanup hooks from plugins + for (const plugin of plugins) { + if (typeof plugin.cleanup === "function") { + // TODO: Pass plugin context? + await plugin.cleanup(); + } + } } return { result }; } diff --git a/src/index.ts b/src/index.ts index 7c0cfd9..7e63f1f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,3 +6,4 @@ export { defineCommand, runCommand } from "./command"; export { parseArgs } from "./args"; export { renderUsage, showUsage } from "./usage"; export { runMain, createMain } from "./main"; +export { defineCittyPlugin } from "./plugin"; diff --git a/src/plugin.ts b/src/plugin.ts new file mode 100644 index 0000000..48eb398 --- /dev/null +++ b/src/plugin.ts @@ -0,0 +1,41 @@ +import { CittyPlugin, Resolvable } from "./types"; + +/** + * Define a Citty plugin. + * + * Can be a function that returns a plugin object or a plugin object. + * + * @example + * ```ts + * import { defineCittyPlugin } from "citty"; + * + * export const myPlugin = defineCittyPlugin({ + * name: "my-plugin", + * async setup() { + * console.log("Setting up my plugin"); + * }, + * async cleanup() { + * console.log("Cleaning up my plugin"); + * }, + * }); + * ``` + */ +export const defineCittyPlugin = ( + plugin: Resolvable, +): Resolvable => { + return plugin; +}; + +/** + * Resolve a Citty plugin since it can be a function that returns a plugin object. + */ +export const resolvePlugin = async (plugin: Resolvable) => { + return typeof plugin === "function" ? await plugin() : plugin; +}; + +/** + * Resolve an array of Citty plugins. + */ +export const resolvePlugins = (plugins: Resolvable[]) => { + return Promise.all(plugins.map((plugin) => resolvePlugin(plugin))); +}; diff --git a/src/types.ts b/src/types.ts index a622922..7e8aa70 100644 --- a/src/types.ts +++ b/src/types.ts @@ -94,6 +94,7 @@ export type CommandDef = { setup?: (context: CommandContext) => any | Promise; cleanup?: (context: CommandContext) => any | Promise; run?: (context: CommandContext) => any | Promise; + plugins?: Resolvable[]; }; export type CommandContext = { @@ -104,6 +105,14 @@ export type CommandContext = { data?: any; }; +// ----- Plugin ----- + +export type CittyPlugin = { + name: string; + setup(): Promise | void; + cleanup(): Promise | void; +}; + // ----- Utils ----- export type Awaitable = () => T | Promise; From 3b8f6212f16e37e38dcc537625b9fab65e9baa29 Mon Sep 17 00:00:00 2001 From: barbapapazes Date: Sun, 7 Jul 2024 23:00:54 +0200 Subject: [PATCH 2/3] test: plugin --- src/plugin.ts | 6 +- src/types.ts | 4 +- test/plugin.test.ts | 236 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 test/plugin.test.ts diff --git a/src/plugin.ts b/src/plugin.ts index 48eb398..8b9170f 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -20,11 +20,11 @@ import { CittyPlugin, Resolvable } from "./types"; * }); * ``` */ -export const defineCittyPlugin = ( +export function defineCittyPlugin( plugin: Resolvable, -): Resolvable => { +): Resolvable { return plugin; -}; +} /** * Resolve a Citty plugin since it can be a function that returns a plugin object. diff --git a/src/types.ts b/src/types.ts index 7e8aa70..c07b778 100644 --- a/src/types.ts +++ b/src/types.ts @@ -109,8 +109,8 @@ export type CommandContext = { export type CittyPlugin = { name: string; - setup(): Promise | void; - cleanup(): Promise | void; + setup?(): Promise | void; + cleanup?(): Promise | void; }; // ----- Utils ----- diff --git a/test/plugin.test.ts b/test/plugin.test.ts new file mode 100644 index 0000000..9c51fb9 --- /dev/null +++ b/test/plugin.test.ts @@ -0,0 +1,236 @@ +import { describe, it, expect, vi } from "vitest"; +import { CommandDef, defineCommand, runCommand } from "../src"; +import { + defineCittyPlugin, + resolvePlugin, + resolvePlugins, +} from "../src/plugin"; + +const _resolve = async (plugin: any) => { + return typeof plugin === "function" ? await plugin() : plugin; +}; + +const pluginFactory = (name: string) => { + return { + name, + setup() {}, + cleanup() {}, + }; +}; + +const mockedPluginFactory = (name: string) => { + return defineCittyPlugin({ + name, + setup: vi.fn(() => {}), + cleanup: vi.fn(() => {}), + }); +}; + +describe("plugin", () => { + it("can be defined using an object", async () => { + const pluginObject = { + name: "my-plugin", + setup() {}, + cleanup() {}, + }; + + const plugin = defineCittyPlugin(pluginObject); + + const resolvedPlugin = await _resolve(plugin); + + expect(resolvedPlugin.name).toBe("my-plugin"); + expect(resolvedPlugin.setup).toBeInstanceOf(Function); + expect(resolvedPlugin.cleanup).toBeInstanceOf(Function); + }); + + it("can be defined using a function", async () => { + // eslint-disable-next-line unicorn/consistent-function-scoping + const pluginFunction = () => ({ + name: "my-plugin", + setup() {}, + cleanup() {}, + }); + + const plugin = defineCittyPlugin(pluginFunction); + + const resolvedPlugin = await _resolve(plugin); + + expect(resolvedPlugin.name).toBe("my-plugin"); + expect(resolvedPlugin.setup).toBeInstanceOf(Function); + expect(resolvedPlugin.cleanup).toBeInstanceOf(Function); + }); + + describe("resolvePlugin", () => { + it("should be resolved similarly for both object and function definition", async () => { + const pluginObject = { + name: "my-plugin", + setup() {}, + cleanup() {}, + }; + + const objectPlugin = defineCittyPlugin(pluginObject); + const functionPlugin = defineCittyPlugin(() => pluginObject); + + const resolvedObjectPlugin = await resolvePlugin(objectPlugin); + const resolvedFunctionPlugin = await resolvePlugin(functionPlugin); + + expect(resolvedObjectPlugin).toEqual(resolvedFunctionPlugin); + }); + }); + + describe("resolvePlugins", () => { + it("can resolve an empty array of plugins", async () => { + const resolvedPlugins = await resolvePlugins([]); + + expect(resolvedPlugins).toHaveLength(0); + }); + + it("can resolve an array of plugins", async () => { + const plugin1 = pluginFactory("plugin1"); + const plugin2 = pluginFactory("plugin2"); + + const resolvedPlugins = await resolvePlugins([plugin1, plugin2]); + + expect(resolvedPlugins).toHaveLength(2); + expect(resolvedPlugins[0].name).toBe("plugin1"); + expect(resolvedPlugins[1].name).toBe("plugin2"); + }); + + it("can resolve an array of mixed plugins", async () => { + const plugin1 = defineCittyPlugin({ name: "plugin1" }); + const plugin2 = defineCittyPlugin(() => ({ name: "plugin2" })); + + const resolvedPlugins = await resolvePlugins([plugin1, plugin2]); + + expect(resolvedPlugins).toHaveLength(2); + expect(resolvedPlugins[0].name).toBe("plugin1"); + expect(resolvedPlugins[1].name).toBe("plugin2"); + }); + }); +}); + +describe("command", () => { + it("should run setup hooks from plugins", async () => { + const plugin = mockedPluginFactory("my-plugin"); + + const command = defineCommand({ + plugins: [plugin], + }); + + await runCommand(command, { rawArgs: [] }); + + const resolvedPlugin = await resolvePlugin(plugin); + + expect(resolvedPlugin.setup).toHaveBeenCalledOnce(); + }); + + it("should run cleanup hooks from plugins", async () => { + const plugin = mockedPluginFactory("my-plugin"); + + const command = defineCommand({ + plugins: [plugin], + }); + + await runCommand(command, { rawArgs: [] }); + + const resolvedPlugin = await resolvePlugin(plugin); + + expect(resolvedPlugin.cleanup).toHaveBeenCalledOnce(); + }); + + it.todo("should run setup, run, and cleanup hooks from command in order"); + + it("should run hooks for each plugin", async () => { + const plugin1 = mockedPluginFactory("plugin1"); + const plugin2 = mockedPluginFactory("plugin2"); + + const command = defineCommand({ + plugins: [plugin1, plugin2], + }); + + await runCommand(command, { rawArgs: [] }); + + const resolvedPlugin1 = await resolvePlugin(plugin1); + const resolvedPlugin2 = await resolvePlugin(plugin2); + + expect(resolvedPlugin1.setup).toHaveBeenCalledOnce(); + expect(resolvedPlugin1.cleanup).toHaveBeenCalledOnce(); + expect(resolvedPlugin2.setup).toHaveBeenCalledOnce(); + expect(resolvedPlugin2.cleanup).toHaveBeenCalledOnce(); + }); + + it.todo("should run hooks for each plugin in order"); + + it("should run plugin hooks from parent command in sub command", async () => { + const plugin = mockedPluginFactory("my-plugin"); + + const command = defineCommand({ + plugins: [plugin], + subCommands: { + sub: defineCommand({ + run: vi.fn(() => {}), + }), + }, + }); + + await runCommand(command, { rawArgs: ["sub"] }); + + const resolvedPlugin = await resolvePlugin(plugin); + + // TODO: move to separate test + expect((command as any).subCommands.sub.run).toHaveBeenCalledOnce(); + + expect(resolvedPlugin.setup).toHaveBeenCalledOnce(); + expect(resolvedPlugin.cleanup).toHaveBeenCalledOnce(); + }); + + it("should run plugin hooks from sub command", async () => { + const plugin = mockedPluginFactory("my-plugin"); + + const command = defineCommand({ + subCommands: { + sub: defineCommand({ + plugins: [plugin], + run: vi.fn(() => {}), + }), + }, + }); + + await runCommand(command, { rawArgs: ["sub"] }); + + const resolvedPlugin = await resolvePlugin(plugin); + + expect((command as any).subCommands.sub.run).toHaveBeenCalledOnce(); + + expect(resolvedPlugin.setup).toHaveBeenCalledOnce(); + expect(resolvedPlugin.cleanup).toHaveBeenCalledOnce(); + }); + + it("should run plugins hooks from parent and sub command", async () => { + const plugin1 = mockedPluginFactory("plugin1"); + + const plugin2 = mockedPluginFactory("plugin2"); + + const command = defineCommand({ + plugins: [plugin1], + subCommands: { + sub: defineCommand({ + plugins: [plugin2], + run: vi.fn(() => {}), + }), + }, + }); + + await runCommand(command, { rawArgs: ["sub"] }); + + const resolvedPlugin1 = await resolvePlugin(plugin1); + const resolvedPlugin2 = await resolvePlugin(plugin2); + + expect((command as any).subCommands.sub.run).toHaveBeenCalledOnce(); + + expect(resolvedPlugin1.setup).toHaveBeenCalledOnce(); + expect(resolvedPlugin1.cleanup).toHaveBeenCalledOnce(); + expect(resolvedPlugin2.setup).toHaveBeenCalledOnce(); + expect(resolvedPlugin2.cleanup).toHaveBeenCalledOnce(); + }); +}); From b8fb0fa6f6d69d648d4e45876ce42f378831f4e2 Mon Sep 17 00:00:00 2001 From: barbapapazes Date: Sun, 7 Jul 2024 23:08:06 +0200 Subject: [PATCH 3/3] chore: lint --- test/plugin.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 9c51fb9..9dc6f33 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { CommandDef, defineCommand, runCommand } from "../src"; +import { defineCommand, runCommand } from "../src"; import { defineCittyPlugin, resolvePlugin,