diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..707ef801c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,25 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +Yarn 4 + Lerna workspaces organize sources under `packages/*`: `cli` hosts command entrypoints and generator templates, `cli-core` handles DI/prompt infrastructure, `cli-mcp` exposes MCP servers and tools, while `cli-plugin-*` packages bundle framework-specific blueprints plus their own tests. `cli-testing` provides shared harnesses, templates live in `packages/cli/templates`, artifacts land in `dist/`, VuePress + TSDoc files stay in `docs/`, and `tools/*` holds automation scripts (TypeScript references, ESLint, Vitest installers). + +## Build, Test, and Development Commands + +- `yarn build`: runs `monorepo build --verbose` across every workspace. +- `yarn build:references`: refreshes TS project references after renaming or adding packages. +- `yarn test`: executes the Vitest suite once; add `--coverage` for V8 reports. +- `yarn lint` / `yarn lint:fix`: applies the flat ESLint config + Prettier formatting used in CI. +- `yarn docs:serve` / `yarn docs:build`: runs `lerna run build && tsdoc` then serves or builds the VuePress site. + +## Coding Style & Naming Conventions + +Author TypeScript ES modules with 2-space indentation, double quotes, and trailing commas per the shared Prettier rules. Favor named exports and keep command providers in `packages/cli/src/commands/**/NameCmd.ts`, suffixing classes with `Cmd`. Tests and utilities mirror their source paths, ending in `.spec.ts` or `.integration.spec.ts`. Plugin packages follow `cli-plugin-` naming; templates underneath use kebab-case folder names. Keep import blocks sorted (enforced by `eslint-plugin-simple-import-sort`) and reserve default exports for entry aggregators such as `packages/cli/src/index.ts`. + +## Testing Guidelines + +Vitest powers both unit and integration coverage. Co-locate fast tests next to implementation files, while scenario-heavy suites live under `packages/*/test/**`. Use `yarn vitest packages/cli/src/commands/init/InitCmd.spec.ts` to focus a file, prefer `.integration.spec.ts` when exercising generators end-to-end, and ship PRs with `yarn test --coverage` so CI can enforce thresholds. Mock filesystem access via helpers in `cli-testing` instead of touching the real disk. + +## Commit & Pull Request Guidelines + +Commit messages must satisfy Conventional Commits (see `commitlint.config.js`); scope by workspace, e.g., `feat(cli-plugin-prisma): expand seed template`. Keep subjects under 200 characters and describe behavior, not implementation minutiae. Before opening a pull request, run `yarn build`, `yarn test`, and `yarn lint`, update docs/templates when behavior changes, and link the relevant issue. Summaries should highlight user-facing changes, test evidence, and screenshots or logs when generator output shifts. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1b0d7e3bd..e1abcaf1d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,10 +1,13 @@ -# Contributing +# Contributing + ## Introduction First, thank you for considering contributing to Ts.ED! It is people like you that make the open source community such a great community! 😊 We welcome any type of contribution, not just code. You can help with: +- Repository-specific build, testing, and PR expectations are summarized in the [Repository Guidelines](AGENTS.md); please skim them before opening a pull request. + - QA: file bug reports, the more details you can give the better (e.g. screenshots with the console open) - Marketing: writing blog posts, how to's, printing stickers, ... - Community: presenting the project at meetups, organizing a dedicated meetup for the local community, ... @@ -23,6 +26,7 @@ Code review process The bigger the pull request, the longer it will take to review and merge. Try to break down large pull requests in smaller chunks that are easier to review and merge. It is also always helpful to have some context for your pull request. What was the purpose? Why does it matter to you? --- + ### WARNING Ts.ED project uses [conventional commits](https://www.conventionalcommits.org/en/v1.0.0-beta.4/) as format commit message. @@ -30,8 +34,7 @@ Ts.ED project uses [conventional commits](https://www.conventionalcommits.org/en Release note and tagging version are based on the message commits. If you don't follow the format, our CI won't be able to increment the version correctly and your feature won't be released on NPM. -To write your commit message, see [convention page here](https://www.conventionalcommits.org/en/v1.0.0-beta.4/) ---- +## To write your commit message, see [convention page here](https://www.conventionalcommits.org/en/v1.0.0-beta.4/) ## Financial contributions @@ -42,6 +45,7 @@ We also welcome financial contributions in full transparency on our open collect If you have any questions, create an [issue](https://github.com/tsedio/tsed/issues) (protip: do a quick search first to see if someone else didn't ask the same question before!). You can also reach us at https://gitter.im/Tsed-io/community. ## How to work on Ts.ED + ### Setup Clone your fork of the repository @@ -51,6 +55,7 @@ $ git clone https://github.com/YOUR_USERNAME/tsed-cli.git ``` Install npm dependencies with yarn (not with NPM!): + ```bash yarn ``` @@ -59,7 +64,7 @@ Compile TypeScript: ```bash yarn build -// or +// or npm run build ``` @@ -101,6 +106,7 @@ git commit -m "feat(domain): Your message" ``` Then: + ```bash npm run test git fetch @@ -142,18 +148,17 @@ yarn vuepress:serve - Feel free to ask for help from other members of the Ts.ED team. ## Credits + ### Contributors - ### Backers Thank you to all our backers! 🙏 [[Become a backer](https://opencollective.com/tsed#backer)] - ### Sponsors Support this project by becoming a sponsor. Your logo will show up here with a link to your website. [[Become a sponsor](https://opencollective.com/tsed#sponsor)] diff --git a/commitlint.config.js b/commitlint.config.js index a4a781ae9..51991efbc 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -1,6 +1,7 @@ export default { extends: ["@commitlint/config-conventional"], rules: { - "header-max-length": [2, "always", 200] + "header-max-length": [2, "always", 200], + "body-max-line-length": [2, "always", 300] } }; diff --git a/package.json b/package.json index 97f22f838..dc689a9d2 100644 --- a/package.json +++ b/package.json @@ -44,13 +44,13 @@ }, "homepage": "https://github.com/tsedio/tsed-cli", "dependencies": { - "@tsed/core": ">=8.18.0", - "@tsed/di": ">=8.18.0", + "@tsed/core": ">=8.21.0", + "@tsed/di": ">=8.21.0", "@tsed/logger": ">=8.0.3", "@tsed/logger-std": ">=8.0.3", - "@tsed/normalize-path": ">=8.18.0", - "@tsed/openspec": ">=8.18.0", - "@tsed/schema": ">=8.18.0", + "@tsed/normalize-path": ">=8.21.0", + "@tsed/openspec": ">=8.21.0", + "@tsed/schema": ">=8.21.0", "axios": "^1.7.7", "chalk": "^5.3.0", "commander": "^12.1.0", diff --git a/packages/cli-core/src/decorators/command.ts b/packages/cli-core/src/decorators/command.ts index 0ce64b39d..52694d103 100644 --- a/packages/cli-core/src/decorators/command.ts +++ b/packages/cli-core/src/decorators/command.ts @@ -1,8 +1,8 @@ import {command} from "../fn/command.js"; -import type {CommandParameters} from "../interfaces/CommandParameters.js"; +import type {BaseCommandOptions} from "../interfaces/CommandOptions.js"; -export function Command(options: CommandParameters): ClassDecorator { +export function Command(options: BaseCommandOptions): ClassDecorator { return (token) => { - command(token, options); + command({...options, token}); }; } diff --git a/packages/cli-core/src/decorators/index.ts b/packages/cli-core/src/decorators/index.ts index 8c69362e8..dcbd99bc9 100644 --- a/packages/cli-core/src/decorators/index.ts +++ b/packages/cli-core/src/decorators/index.ts @@ -1,5 +1 @@ export * from "./command.js"; -export * from "./onAdd.js"; -export * from "./onExec.js"; -export * from "./onPostInstall.js"; -export * from "./onPrompt.js"; diff --git a/packages/cli-core/src/decorators/on.ts b/packages/cli-core/src/decorators/on.ts deleted file mode 100644 index e05dbdcd7..000000000 --- a/packages/cli-core/src/decorators/on.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {StoreMerge} from "@tsed/core"; - -export function On(hookName: string, name: string): MethodDecorator { - return (target, propertyKey) => { - StoreMerge(hookName, { - [name]: [propertyKey] - })(target); - }; -} diff --git a/packages/cli-core/src/decorators/onAdd.spec.ts b/packages/cli-core/src/decorators/onAdd.spec.ts deleted file mode 100644 index 8ea1c0bb3..000000000 --- a/packages/cli-core/src/decorators/onAdd.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {Store} from "@tsed/core"; - -import {CommandStoreKeys} from "../domains/CommandStoreKeys.js"; -import {OnAdd} from "./onAdd.js"; - -class Test { - @OnAdd("@tsed/cli-plugin") - test() {} -} - -describe("@OnAdd", () => { - it("should store metadata", () => { - const result = Store.from(Test).get(CommandStoreKeys.ADD); - expect(result).toEqual({ - "@tsed/cli-plugin": ["test"] - }); - }); -}); diff --git a/packages/cli-core/src/decorators/onAdd.ts b/packages/cli-core/src/decorators/onAdd.ts deleted file mode 100644 index 3eb693b7b..000000000 --- a/packages/cli-core/src/decorators/onAdd.ts +++ /dev/null @@ -1,6 +0,0 @@ -import {CommandStoreKeys} from "../domains/CommandStoreKeys.js"; -import {On} from "./on.js"; - -export function OnAdd(cliPlugin: string): MethodDecorator { - return On(CommandStoreKeys.ADD, cliPlugin); -} diff --git a/packages/cli-core/src/decorators/onExec.spec.ts b/packages/cli-core/src/decorators/onExec.spec.ts deleted file mode 100644 index 861571fc3..000000000 --- a/packages/cli-core/src/decorators/onExec.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {Store} from "@tsed/core"; - -import {CommandStoreKeys} from "../domains/CommandStoreKeys.js"; -import {OnExec} from "./onExec.js"; - -class Test { - @OnExec("cmd") - test() {} -} - -describe("@OnExec", () => { - it("should store metadata", () => { - const result = Store.from(Test).get(CommandStoreKeys.EXEC_HOOKS); - - expect(result).toEqual({ - cmd: ["test"] - }); - }); -}); diff --git a/packages/cli-core/src/decorators/onExec.ts b/packages/cli-core/src/decorators/onExec.ts deleted file mode 100644 index 460c68810..000000000 --- a/packages/cli-core/src/decorators/onExec.ts +++ /dev/null @@ -1,6 +0,0 @@ -import {CommandStoreKeys} from "../domains/CommandStoreKeys.js"; -import {On} from "./on.js"; - -export function OnExec(cmdName: string): MethodDecorator { - return On(CommandStoreKeys.EXEC_HOOKS, cmdName); -} diff --git a/packages/cli-core/src/decorators/onPostInstall.spec.ts b/packages/cli-core/src/decorators/onPostInstall.spec.ts deleted file mode 100644 index a170d501b..000000000 --- a/packages/cli-core/src/decorators/onPostInstall.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {Store} from "@tsed/core"; - -import {CommandStoreKeys} from "../domains/CommandStoreKeys.js"; -import {OnPostInstall} from "./onPostInstall.js"; - -class Test { - @OnPostInstall("cmd") - test() {} -} - -describe("@OnPostIntall", () => { - it("should store metadata", () => { - const result = Store.from(Test).get(CommandStoreKeys.POST_INSTALL_HOOKS); - - expect(result).toEqual({ - cmd: ["test"] - }); - }); -}); diff --git a/packages/cli-core/src/decorators/onPostInstall.ts b/packages/cli-core/src/decorators/onPostInstall.ts deleted file mode 100644 index a30facf7f..000000000 --- a/packages/cli-core/src/decorators/onPostInstall.ts +++ /dev/null @@ -1,6 +0,0 @@ -import {CommandStoreKeys} from "../domains/CommandStoreKeys.js"; -import {On} from "./on.js"; - -export function OnPostInstall(cmdName: string): MethodDecorator { - return On(CommandStoreKeys.POST_INSTALL_HOOKS, cmdName); -} diff --git a/packages/cli-core/src/decorators/onPrompt.spec.ts b/packages/cli-core/src/decorators/onPrompt.spec.ts deleted file mode 100644 index b510e4933..000000000 --- a/packages/cli-core/src/decorators/onPrompt.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {Store} from "@tsed/core"; - -import {CommandStoreKeys} from "../domains/CommandStoreKeys.js"; -import {OnPrompt} from "./onPrompt.js"; - -class Test { - @OnPrompt("cmd") - test() {} -} - -describe("@OnPrompt", () => { - it("should store metadata", () => { - const result = Store.from(Test).get(CommandStoreKeys.PROMPT_HOOKS); - - expect(result).toEqual({ - cmd: ["test"] - }); - }); -}); diff --git a/packages/cli-core/src/decorators/onPrompt.ts b/packages/cli-core/src/decorators/onPrompt.ts deleted file mode 100644 index c2a656307..000000000 --- a/packages/cli-core/src/decorators/onPrompt.ts +++ /dev/null @@ -1,6 +0,0 @@ -import {CommandStoreKeys} from "../domains/CommandStoreKeys.js"; -import {On} from "./on.js"; - -export function OnPrompt(cmdName: string): MethodDecorator { - return On(CommandStoreKeys.PROMPT_HOOKS, cmdName); -} diff --git a/packages/cli-core/src/domains/CommandStoreKeys.ts b/packages/cli-core/src/domains/CommandStoreKeys.ts deleted file mode 100644 index 7a0171bc0..000000000 --- a/packages/cli-core/src/domains/CommandStoreKeys.ts +++ /dev/null @@ -1,7 +0,0 @@ -export enum CommandStoreKeys { - COMMAND = "command", - ADD = "$onAdd", - EXEC_HOOKS = "$onExec", - POST_INSTALL_HOOKS = "$onPostInstall", - PROMPT_HOOKS = "$onPrompt" -} diff --git a/packages/cli-core/src/fn/command.spec.ts b/packages/cli-core/src/fn/command.spec.ts new file mode 100644 index 000000000..6e4680e17 --- /dev/null +++ b/packages/cli-core/src/fn/command.spec.ts @@ -0,0 +1,75 @@ +import {injectable} from "@tsed/di"; + +import type {CommandOptions} from "../interfaces/CommandOptions.js"; +import type {CommandProvider} from "../interfaces/CommandProvider.js"; +import {command} from "./command.js"; + +vi.mock("@tsed/di", () => ({ + injectable: vi.fn() +})); + +describe("command()", () => { + const createBuilder = () => + ({ + type: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + factory: vi.fn().mockReturnThis() + }) as any; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should register a functional command via factory", () => { + const builder = createBuilder(); + vi.mocked(injectable).mockReturnValue(builder); + + const handler: CommandProvider["$exec"] = vi.fn(); + const prompt: CommandProvider["$prompt"] = vi.fn(); + const options: CommandOptions = { + name: "test", + description: "description", + handler, + prompt + }; + + command(options); + + expect(injectable).toHaveBeenCalledWith(Symbol.for("COMMAND_test")); + expect(builder.type).toHaveBeenCalledWith("command"); + expect(builder.set).toHaveBeenCalledWith("command", options); + expect(builder.factory).toHaveBeenCalledTimes(1); + + const registeredFactory = builder.factory.mock.calls[0][0]; + const result = registeredFactory(); + + expect(result).toMatchObject({ + ...options, + $exec: handler, + $prompt: prompt + }); + expect(result.handler).toBe(handler); + }); + + it("should register a class-based command with provided token", () => { + const builder = createBuilder(); + vi.mocked(injectable).mockReturnValue(builder); + + class TestCommand implements CommandProvider { + $exec(): any {} + } + + const options: CommandOptions = { + name: "test-class", + description: "description", + token: TestCommand + }; + + command(options); + + expect(injectable).toHaveBeenCalledWith(TestCommand); + expect(builder.type).toHaveBeenCalledWith("command"); + expect(builder.set).toHaveBeenCalledWith("command", options); + expect(builder.factory).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli-core/src/fn/command.ts b/packages/cli-core/src/fn/command.ts index 89d68a012..1bfccae2a 100644 --- a/packages/cli-core/src/fn/command.ts +++ b/packages/cli-core/src/fn/command.ts @@ -1,8 +1,59 @@ -import {injectable, type TokenProvider} from "@tsed/di"; +import type {Type} from "@tsed/core"; +import {type FactoryTokenProvider, injectable} from "@tsed/di"; +import {JsonSchema} from "@tsed/schema"; -import {CommandStoreKeys} from "../domains/CommandStoreKeys.js"; -import type {CommandParameters} from "../interfaces/CommandParameters.js"; +import type {CommandOptions} from "../interfaces/CommandOptions.js"; +import type {CommandProvider} from "../interfaces/index.js"; -export function command(token: TokenProvider, options: CommandParameters): ReturnType { - return injectable(token).type("command").set(CommandStoreKeys.COMMAND, options); +type SchemaChoice = { + label: string; + value: string; + checked?: ((ctx: any) => boolean) | boolean; + items?: SchemaChoice[]; +}; + +declare module "@tsed/schema" { + interface JsonSchema { + prompt(label: string): this; + + opt(value: string): this; + + when(fn: (ctx: any) => boolean): this; + + choices(value: SchemaChoice[]): this; + } +} + +JsonSchema.add("prompt", function prompt(label: string) { + this.customKey("x-label", label); + return this; +}) + .add("when", function when(fn: (ctx: any) => boolean) { + this.customKey("x-when", fn); + return this; + }) + .add("opt", function opt(v: string) { + this.customKey("x-opt", v); + return this; + }) + .add("choices", function choices(choices: SchemaChoice[]) { + this.customKey("x-choices", choices); + return this; + }); + +export function command(options: CommandOptions) { + if (!options.token) { + return injectable>>(Symbol.for(`COMMAND_${options.name}`) as any) + .type("command") + .set("command", options) + .factory(() => { + return { + ...options, + $prompt: options.prompt, + $exec: options.handler + }; + }); + } + + return injectable>>(options.token).type("command").set("command", options); } diff --git a/packages/cli-core/src/interfaces/CommandMetadata.ts b/packages/cli-core/src/interfaces/CommandMetadata.ts index 930920724..8f72c4910 100644 --- a/packages/cli-core/src/interfaces/CommandMetadata.ts +++ b/packages/cli-core/src/interfaces/CommandMetadata.ts @@ -1,24 +1,24 @@ -import type {CommandArg, CommandOptions, CommandParameters} from "./CommandParameters.js"; - -export interface CommandMetadata extends CommandParameters { - /** - * CommandProvider arguments - */ - args: { - [key: string]: CommandArg; - }; - /** - * CommandProvider options - */ - options: { - [key: string]: CommandOptions; - }; - - allowUnknownOption?: boolean; +import type {BaseCommandOptions, CommandArg, CommandOpts} from "./CommandOptions.js"; +export interface CommandMetadata extends Omit, "args" | "options" | "allowUnknownOption"> { enableFeatures: string[]; - disableReadUpPkg: boolean; - bindLogger: boolean; + + getOptions(): { + /** + * CommandProvider arguments + */ + args: { + [key: string]: CommandArg; + }; + /** + * CommandProvider options + */ + options: { + [key: string]: CommandOpts; + }; + + allowUnknownOption?: boolean; + }; } diff --git a/packages/cli-core/src/interfaces/CommandParameters.ts b/packages/cli-core/src/interfaces/CommandOptions.ts similarity index 58% rename from packages/cli-core/src/interfaces/CommandParameters.ts rename to packages/cli-core/src/interfaces/CommandOptions.ts index 4762b9fe3..8327f1734 100644 --- a/packages/cli-core/src/interfaces/CommandParameters.ts +++ b/packages/cli-core/src/interfaces/CommandOptions.ts @@ -1,4 +1,10 @@ import {Type} from "@tsed/core"; +import type {TokenProvider} from "@tsed/di"; +import type {JsonSchema} from "@tsed/schema"; +import type {Answers} from "inquirer"; + +import type {CommandProvider, QuestionOptions} from "./CommandProvider.js"; +import type {Tasks} from "./Tasks.js"; export interface CommandArg { /** @@ -23,7 +29,7 @@ export interface CommandArg { required?: boolean; } -export interface CommandOptions { +export interface CommandOpts { /** * Description of the commander.option() */ @@ -51,7 +57,7 @@ export interface CommandOptions { customParser?: (value: any) => any; } -export interface CommandParameters { +export interface BaseCommandOptions { /** * name commands */ @@ -68,11 +74,14 @@ export interface CommandParameters { args?: { [key: string]: CommandArg; }; + + inputSchema?: JsonSchema | (() => JsonSchema); + /** * CommandProvider options */ options?: { - [key: string]: CommandOptions; + [key: string]: CommandOpts; }; allowUnknownOption?: boolean; @@ -81,5 +90,20 @@ export interface CommandParameters { disableReadUpPkg?: boolean; + bindLogger?: boolean; +} + +interface FunctionalCommandOptions extends BaseCommandOptions { + prompt?(initialOptions: Partial): QuestionOptions | Promise>; + handler: (data: Input) => Tasks | Promise | any | Promise; + [key: string]: any; } + +export interface ClassCommandOptions extends BaseCommandOptions { + token: TokenProvider; + + [key: string]: any; +} + +export type CommandOptions = ClassCommandOptions | FunctionalCommandOptions; diff --git a/packages/cli-core/src/interfaces/CommandProvider.ts b/packages/cli-core/src/interfaces/CommandProvider.ts index 6ccffdcbe..c45382261 100644 --- a/packages/cli-core/src/interfaces/CommandProvider.ts +++ b/packages/cli-core/src/interfaces/CommandProvider.ts @@ -14,12 +14,6 @@ declare module "inquirer" { export type QuestionOptions = QuestionCollection; export interface CommandProvider { - /** - * Run a function before the main prompt. Useful for pre-loading data from the file system - * @param initialOptions - */ - $beforePrompt?(initialOptions: Partial): Partial; - /** * Hook to create the main prompt for the command * See https://github.com/enquirer/enquirer for more detail on question configuration. @@ -33,12 +27,6 @@ export interface CommandProvider { */ $mapContext?(ctx: Partial): Ctx; - /** - * Run something before the exec hook - * @param ctx - */ - $beforeExec?(ctx: Ctx): Promise; - /** * Run a command * @param ctx diff --git a/packages/cli-core/src/interfaces/index.ts b/packages/cli-core/src/interfaces/index.ts index 2e02eec0d..97ff8e2a8 100644 --- a/packages/cli-core/src/interfaces/index.ts +++ b/packages/cli-core/src/interfaces/index.ts @@ -1,4 +1,5 @@ import {Type} from "@tsed/core"; +import type {FactoryTokenProvider, TokenProvider} from "@tsed/di"; import type {CommandProvider} from "./CommandProvider.js"; import type {PackageJson} from "./PackageJson.js"; @@ -6,7 +7,7 @@ import type {PackageJson} from "./PackageJson.js"; export * from "./CliDefaultOptions.js"; export * from "./CommandData.js"; export * from "./CommandMetadata.js"; -export * from "./CommandParameters.js"; +export * from "./CommandOptions.js"; export * from "./CommandProvider.js"; export * from "./PackageJson.js"; export * from "./ProjectPreferences.js"; @@ -18,7 +19,7 @@ declare global { /** * Load given commands */ - commands: Type[]; + commands: TokenProvider[]; /** * Init Cli with defined argv */ diff --git a/packages/cli-core/src/packageManagers/PackageManagersModule.ts b/packages/cli-core/src/packageManagers/PackageManagersModule.ts index 3a2410998..3f921b5d6 100644 --- a/packages/cli-core/src/packageManagers/PackageManagersModule.ts +++ b/packages/cli-core/src/packageManagers/PackageManagersModule.ts @@ -128,7 +128,6 @@ export class PackageManagersModule { } this.projectPackageJson.setPreference("packageManager", selectedPackageManager.name); - console.log("==", name, selectedPackageManager); return selectedPackageManager; } diff --git a/packages/cli-core/src/services/CliDockerComposeYaml.spec.ts b/packages/cli-core/src/services/CliDockerComposeYaml.spec.ts new file mode 100644 index 000000000..439c96c9b --- /dev/null +++ b/packages/cli-core/src/services/CliDockerComposeYaml.spec.ts @@ -0,0 +1,162 @@ +// @ts-ignore +import {CliPlatformTest} from "@tsed/cli-testing"; + +import {CliDockerComposeYaml} from "./CliDockerComposeYaml.js"; +import {CliFs} from "./CliFs.js"; +import {CliYaml} from "./CliYaml.js"; +import {ProjectPackageJson} from "./ProjectPackageJson.js"; + +describe("CliDockerComposeYaml", () => { + beforeEach(() => CliPlatformTest.create()); + afterEach(() => CliPlatformTest.reset()); + + async function createService(deps: Partial> = {}) { + const cliYaml = deps.cliYaml || { + read: vi.fn().mockResolvedValue({}), + write: vi.fn().mockResolvedValue(undefined) + }; + const fs = deps.fs || { + exists: vi.fn().mockReturnValue(true), + findUpFile: vi.fn().mockReturnValue(undefined) + }; + const projectPkg = deps.projectPkg || ({dir: "/project"} as ProjectPackageJson); + + const service = await CliPlatformTest.invoke(CliDockerComposeYaml, [ + { + token: CliYaml, + use: cliYaml + }, + { + token: CliFs, + use: fs + }, + { + token: ProjectPackageJson, + use: projectPkg + } + ]); + + return {service, cliYaml, fs, projectPkg}; + } + + describe("read()", () => { + it("should read docker-compose.yml from project root when it exists", async () => { + const {service, cliYaml, fs} = await createService(); + + const result = await service.read(); + + expect(fs.exists).toHaveBeenCalledWith("docker-compose.yml"); + expect(cliYaml.read).toHaveBeenCalledWith("docker-compose.yml"); + expect(result).toEqual({}); + }); + + it("should return an empty object when no docker-compose file is found", async () => { + const fs = { + exists: vi.fn().mockReturnValue(false), + findUpFile: vi.fn().mockReturnValue(undefined) + }; + const {service, cliYaml} = await createService({fs}); + + const result = await service.read(); + + expect(fs.findUpFile).toHaveBeenCalled(); + expect(cliYaml.read).not.toHaveBeenCalled(); + expect(result).toEqual({}); + }); + }); + + describe("write()", () => { + it("should write to the discovered docker-compose file", async () => { + const cliYaml = { + read: vi.fn(), + write: vi.fn().mockResolvedValue(undefined) + }; + const fs = { + exists: vi.fn(), + findUpFile: vi.fn().mockReturnValue("/repo/docker-compose.yml") + }; + const {service} = await createService({cliYaml, fs}); + const payload = {services: {}}; + + await service.write(payload); + + expect(cliYaml.write).toHaveBeenCalledWith("/repo/docker-compose.yml", payload); + }); + + it("should fallback to project dir when docker-compose file does not exist yet", async () => { + const cliYaml = { + read: vi.fn(), + write: vi.fn().mockResolvedValue(undefined) + }; + const fs = { + exists: vi.fn(), + findUpFile: vi.fn().mockReturnValue(undefined) + }; + const projectPkg = {dir: "/repo"} as ProjectPackageJson; + const {service} = await createService({cliYaml, fs, projectPkg}); + const payload = {services: {}}; + + await service.write(payload); + + expect(cliYaml.write).toHaveBeenCalledWith("/repo/docker-compose.yml", payload); + }); + }); + + describe("addDatabaseService()", () => { + it("should append a postgres service and persist the file", async () => { + const cliYaml = { + read: vi.fn().mockResolvedValue({services: {}}), + write: vi.fn().mockResolvedValue(undefined) + }; + const fs = { + exists: vi.fn().mockReturnValue(true), + findUpFile: vi.fn() + }; + const {service} = await createService({cliYaml, fs}); + + await service.addDatabaseService("OrdersDb", "postgres"); + + expect(cliYaml.write).toHaveBeenCalledTimes(1); + const [, dockerCompose] = cliYaml.write.mock.calls[0]; + expect(dockerCompose).toEqual({ + services: { + orders_db: { + image: "postgres:9.6.1", + ports: ["5432:5432"], + volumes: ["./pgdata:/var/lib/postgresql/data"], + environment: { + POSTGRES_USER: "test", + POSTGRES_PASSWORD: "test", + POSTGRES_DB: "test" + } + } + } + }); + }); + + it("should append a mongodb service definition when requested", async () => { + const cliYaml = { + read: vi.fn().mockResolvedValue({services: {}}), + write: vi.fn().mockResolvedValue(undefined) + }; + const fs = { + exists: vi.fn().mockReturnValue(true), + findUpFile: vi.fn() + }; + const {service} = await createService({cliYaml, fs}); + + await service.addDatabaseService("Analytics", "mongodb"); + + expect(cliYaml.write).toHaveBeenCalledTimes(1); + const [, dockerCompose] = cliYaml.write.mock.calls[0]; + expect(dockerCompose).toEqual({ + services: { + analytics: { + image: "mongo:5.0.8", + ports: ["27017:27017"] + } + } + }); + }); + }); +}); diff --git a/packages/cli-core/src/services/CliExeca.spec.ts b/packages/cli-core/src/services/CliExeca.spec.ts new file mode 100644 index 000000000..246bb568c --- /dev/null +++ b/packages/cli-core/src/services/CliExeca.spec.ts @@ -0,0 +1,106 @@ +// @ts-ignore +import {CliPlatformTest} from "@tsed/cli-testing"; +import {Observable} from "rxjs"; + +import {CliExeca} from "./CliExeca.js"; + +vi.mock("../utils/streamToObservable.js", () => { + return { + streamToObservable: vi.fn() + }; +}); + +vi.mock("split", () => ({ + default: vi.fn(() => "SPLIT_STREAM") +})); + +const mockedStreamToObservable = vi.importMock("../utils/streamToObservable.js"); + +describe("CliExeca", () => { + beforeEach(() => CliPlatformTest.create()); + afterEach(() => CliPlatformTest.reset()); + + describe("run()", () => { + it("should merge stdout and stderr observables and filter falsy events", async () => { + const stdout$ = new Observable((observer) => { + observer.next("build:started"); + observer.next(""); + observer.complete(); + }); + const stderr$ = new Observable((observer) => { + observer.next("warning"); + observer.next(undefined as any); + observer.complete(); + }); + + const streamToObservable = await mockedStreamToObservable; + vi.mocked(streamToObservable.streamToObservable).mockReturnValueOnce(stdout$).mockReturnValueOnce(stderr$); + + const service = await CliPlatformTest.invoke(CliExeca); + const stdoutPipe = vi.fn().mockReturnValue("STDOUT"); + const stderrPipe = vi.fn().mockReturnValue("STDERR"); + const cp: any = { + stdout: { + pipe: stdoutPipe + }, + stderr: { + pipe: stderrPipe + } + }; + + (service as any).raw = vi.fn().mockReturnValue(cp) as any; + + const events: string[] = []; + await new Promise((resolve, reject) => { + service.run("node", ["--version"], {cwd: "/repo"}).subscribe({ + next: (value) => events.push(value), + complete: () => resolve(), + error: (err) => reject(err) + }); + }); + + expect(events).toEqual(["build:started", "warning"]); + expect(stdoutPipe).toHaveBeenCalled(); + expect(stderrPipe).toHaveBeenCalled(); + expect(service.raw).toHaveBeenCalledWith("node", ["--version"], {cwd: "/repo"}); + expect(streamToObservable.streamToObservable).toHaveBeenNthCalledWith(1, "STDOUT", {await: cp}); + expect(streamToObservable.streamToObservable).toHaveBeenNthCalledWith(2, "STDERR", {await: cp}); + }); + }); + + describe("runSync()", () => { + it("should delegate to rawSync", async () => { + const service = await CliPlatformTest.invoke(CliExeca); + const result = {stdout: "done"}; + + (service as any).rawSync = vi.fn().mockReturnValue(result) as any; + + expect(service.runSync("npm", ["install"], {} as any)).toEqual(result); + expect(service.rawSync).toHaveBeenCalledWith("npm", ["install"], {}); + }); + }); + + describe("getAsync()", () => { + it("should resolve stdout from the async process", async () => { + const service = await CliPlatformTest.invoke(CliExeca); + (service as any).raw = vi.fn().mockResolvedValue({stdout: "ok"}) as any; + + const result = await service.getAsync("npm", ["--version"]); + + expect(service.raw).toHaveBeenCalledWith("npm", ["--version"], undefined); + expect(result).toEqual("ok"); + }); + }); + + describe("get()", () => { + it("should return stdout from the sync process", async () => { + const service = await CliPlatformTest.invoke(CliExeca); + (service as any).rawSync = vi.fn().mockReturnValue({stdout: "1.0.0"}) as any; + + const result = service.get("npm", ["--version"]); + + expect(service.rawSync).toHaveBeenCalledWith("npm", ["--version"], undefined); + expect(result).toEqual("1.0.0"); + }); + }); +}); diff --git a/packages/cli-core/src/services/CliFs.spec.ts b/packages/cli-core/src/services/CliFs.spec.ts new file mode 100644 index 000000000..4c5a750b2 --- /dev/null +++ b/packages/cli-core/src/services/CliFs.spec.ts @@ -0,0 +1,85 @@ +import {mkdir, mkdtemp, rm, writeFile} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; + +// @ts-ignore +import {CliPlatformTest} from "@tsed/cli-testing"; + +import {CliFs} from "./CliFs.js"; + +describe("CliFs", () => { + beforeEach(() => CliPlatformTest.create()); + afterEach(() => CliPlatformTest.reset()); + + it("should delegate exists() to fs-extra", async () => { + const service = await CliPlatformTest.invoke(CliFs); + service.raw.existsSync = vi.fn().mockReturnValue(true); + + expect(service.exists("package.json")).toEqual(true); + expect(service.raw.existsSync).toHaveBeenCalledWith("package.json"); + }); + + it("should normalize joins using forward slashes", async () => { + const service = await CliPlatformTest.invoke(CliFs); + + expect(service.join("src", "cli", "..", "core")).toBe("src/core"); + }); + + it("should read and parse json files", async () => { + const service = await CliPlatformTest.invoke(CliFs); + const spy = vi.spyOn(service, "readFile").mockResolvedValueOnce('{"name":"cli"}'); + + const result = await service.readJson("package.json"); + + expect(spy).toHaveBeenCalledWith("package.json", undefined); + expect(result).toEqual({name: "cli"}); + }); + + it("should stringify payloads when writing json", async () => { + const service = await CliPlatformTest.invoke(CliFs); + (service as any).raw.writeFile = vi.fn().mockResolvedValue(undefined); + + await service.writeJson("package.json", {name: "cli"}); + + expect(service.raw.writeFile).toHaveBeenCalledWith("package.json", JSON.stringify({name: "cli"}, null, 2), {encoding: "utf8"}); + }); + + it("should locate the nearest matching file walking up the directory tree", async () => { + const service = await CliPlatformTest.invoke(CliFs); + const hits = new Set(["/repo/a/file.yml", "/repo/file.yml"]); + + vi.spyOn(service, "fileExistsSync").mockImplementation((path) => hits.has(path)); + + const result = service.findUpFile("/repo/a/b", "file.yml"); + + expect(result).toEqual("/repo/a/file.yml"); + }); + + it("should import a module discovered under node_modules by inspecting exports", async () => { + const service = await CliPlatformTest.invoke(CliFs); + const tempDir = await mkdtemp(join(tmpdir(), "cli-fs-")); + const moduleDir = join(tempDir, "node_modules", "cli-fs-mock"); + await mkdir(moduleDir, {recursive: true}); + await writeFile( + join(moduleDir, "package.json"), + JSON.stringify({ + exports: { + ".": { + import: "./index.mjs" + } + } + }) + ); + await writeFile(join(moduleDir, "index.mjs"), "export const value = 42;"); + const finder = vi.spyOn(service, "findUpFile").mockReturnValue(moduleDir); + + try { + const mod: any = await service.importModule("cli-fs-mock", tempDir); + + expect(mod.value).toEqual(42); + } finally { + finder.mockRestore(); + await rm(tempDir, {recursive: true, force: true}); + } + }); +}); diff --git a/packages/cli-core/src/services/CliHooks.spec.ts b/packages/cli-core/src/services/CliHooks.spec.ts new file mode 100644 index 000000000..042fe5954 --- /dev/null +++ b/packages/cli-core/src/services/CliHooks.spec.ts @@ -0,0 +1,53 @@ +// @ts-ignore +import {CliPlatformTest} from "@tsed/cli-testing"; +import {injector} from "@tsed/di"; + +import {CliHooks} from "./CliHooks.js"; + +describe("CliHooks", () => { + beforeEach(() => CliPlatformTest.create()); + afterEach(() => CliPlatformTest.reset()); + + it("should execute hook handlers declared on providers", async () => { + class TestHook { + onTestHook(arg: string) { + return [`${arg}-done`]; + } + } + + const inj = injector(); + inj.addProvider(TestHook, { + useClass: TestHook + }); + const provider = inj.getProvider(TestHook)!; + provider.useClass = TestHook; + provider.store.set("$onTest", { + cmd: ["onTestHook"] + }); + + const hooks = await CliPlatformTest.invoke(CliHooks); + const result = await hooks.emit("$onTest", "cmd", "value"); + + expect(result).toEqual(["value-done"]); + }); + + it("should ignore providers that do not declare the hook/cmd combination", async () => { + class EmptyHook { + onOtherHook() { + return ["noop"]; + } + } + + const inj = injector(); + inj.addProvider(EmptyHook, { + useClass: EmptyHook + }); + const provider = inj.getProvider(EmptyHook)!; + provider.useClass = EmptyHook; + + const hooks = await CliPlatformTest.invoke(CliHooks); + const result = await hooks.emit("$onUnknown", "cmd"); + + expect(result).toEqual([]); + }); +}); diff --git a/packages/cli-core/src/services/CliHttpLogClient.spec.ts b/packages/cli-core/src/services/CliHttpLogClient.spec.ts new file mode 100644 index 000000000..45dc167db --- /dev/null +++ b/packages/cli-core/src/services/CliHttpLogClient.spec.ts @@ -0,0 +1,148 @@ +// @ts-ignore +import {CliPlatformTest} from "@tsed/cli-testing"; +import {Logger} from "@tsed/logger"; + +import {CliHttpLogClient} from "./CliHttpLogClient.js"; + +class TestCliHttpLogClient extends CliHttpLogClient { + public override logToCurl(options: any) { + return "curl --request"; + } + + public callOnSuccess(options: any) { + return this.onSuccess(options); + } + + public callOnError(error: any, options: any) { + return this.onError(error, options); + } + + public callFormatLog(options: any) { + return this.formatLog(options); + } + + public callErrorMapper(error: any) { + return this.errorMapper(error); + } +} + +describe("CliHttpLogClient", () => { + beforeEach(() => CliPlatformTest.create()); + afterEach(() => CliPlatformTest.reset()); + + async function createClient(loggerOverrides: Partial = {}) { + const logger = { + debug: vi.fn(), + warn: vi.fn(), + level: "info", + ...loggerOverrides + } as Logger; + + const client = await CliPlatformTest.invoke(TestCliHttpLogClient, [ + { + token: Logger, + use: logger + } + ]); + + return {client, logger}; + } + + it("should log successful calls with formatted metadata", async () => { + const {client, logger} = await createClient(); + const start = Date.now() - 10; + + client.callOnSuccess({ + startTime: start, + url: "/health", + method: "GET", + params: {foo: "bar"} + }); + + expect(logger.debug).toHaveBeenCalledWith( + expect.objectContaining({ + callee: "http", + method: "GET", + url: "/health", + callee_qs: "foo=bar", + status: "OK" + }) + ); + }); + + it("should map errors, serialize request/response metadata, and log them", async () => { + const {client, logger} = await createClient(); + const error = { + message: "Request failed", + response: { + status: 500, + headers: { + "x-request-id": "req-1" + }, + data: { + message: "boom" + } + } + }; + + await client.callOnError(error, { + startTime: Date.now() - 5, + url: "/health", + method: "POST", + headers: { + accept: "application/json" + }, + params: {filter: "all"}, + data: {payload: true} + }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + status: "KO", + callee_code: 500, + callee_error: "Request failed", + callee_response_request_id: "req-1", + callee_request_body: JSON.stringify({payload: true}), + callee_response_body: JSON.stringify({message: "boom"}), + curl: "curl --request" + }) + ); + }); + + it("should compute duration and omit invalid values when startTime is missing", async () => { + const {client} = await createClient(); + + const formatted = client.callFormatLog({ + startTime: undefined, + url: "/health", + method: "GET" + }); + + expect(formatted).toEqual({ + callee: "http", + method: "GET", + url: "/health", + callee_qs: undefined, + duration: undefined + }); + }); + + it("should map error metadata even when message is missing", async () => { + const {client} = await createClient(); + const mapped = client.callErrorMapper({ + response: { + status: 429, + headers: {"x-request-id": "req-42"}, + data: {error: "too many requests"} + } + }); + + expect(mapped).toEqual({ + message: 429, + code: 429, + headers: {"x-request-id": "req-42"}, + body: {error: "too many requests"}, + x_request_id: "req-42" + }); + }); +}); diff --git a/packages/cli-core/src/services/CliPackageJson.spec.ts b/packages/cli-core/src/services/CliPackageJson.spec.ts new file mode 100644 index 000000000..2e549b0d5 --- /dev/null +++ b/packages/cli-core/src/services/CliPackageJson.spec.ts @@ -0,0 +1,24 @@ +// @ts-ignore +import {CliPlatformTest} from "@tsed/cli-testing"; +import {constant} from "@tsed/di"; + +import {CliPackageJson as CliPackageJsonToken, cliPackageJson} from "./CliPackageJson.js"; + +describe("CliPackageJson provider", () => { + beforeEach(() => CliPlatformTest.create()); + afterEach(() => CliPlatformTest.reset()); + + it("should expose the current package.json via DI", () => { + const expected = constant("pkg", {}); + const pkg = CliPlatformTest.get(CliPackageJsonToken as any); + + expect(pkg).toEqual(expected); + }); + + it("should resolve through the cliPackageJson helper", () => { + const expected = constant("pkg", {}); + const pkg = cliPackageJson(); + + expect(pkg).toEqual(expected); + }); +}); diff --git a/packages/cli-core/src/services/CliPlugins.ts b/packages/cli-core/src/services/CliPlugins.ts index d5a5b6ca4..b68dc6070 100644 --- a/packages/cli-core/src/services/CliPlugins.ts +++ b/packages/cli-core/src/services/CliPlugins.ts @@ -1,7 +1,7 @@ import {constant, inject, injectable} from "@tsed/di"; +import {$asyncEmit} from "@tsed/hooks"; import chalk from "chalk"; -import {CommandStoreKeys} from "../domains/CommandStoreKeys.js"; import type {Task} from "../interfaces/index.js"; import {PackageManagersModule} from "../packageManagers/PackageManagersModule.js"; import {createSubTasks} from "../utils/createTasksRunner.js"; @@ -39,7 +39,7 @@ export class CliPlugins { return { title: `Run plugin '${chalk.cyan(plugin)}'`, task: () => { - return this.cliHooks.emit(CommandStoreKeys.ADD, plugin, ctx); + return $asyncEmit("$onAddPlugin", [plugin, ctx]); } }; }); diff --git a/packages/cli-core/src/services/CliService.spec.ts b/packages/cli-core/src/services/CliService.spec.ts new file mode 100644 index 000000000..6a0962453 --- /dev/null +++ b/packages/cli-core/src/services/CliService.spec.ts @@ -0,0 +1,265 @@ +// @ts-ignore +import {CliPlatformTest} from "@tsed/cli-testing"; +import {Store} from "@tsed/core"; +import {DIContext, injector, logger} from "@tsed/di"; +import {s} from "@tsed/schema"; +import {Command} from "commander"; + +import type {CommandMetadata} from "../interfaces/CommandMetadata.js"; +import {CliService} from "./CliService.js"; + +describe("CliService", () => { + beforeEach(() => CliPlatformTest.create()); + afterEach(() => CliPlatformTest.reset()); + + it("should map command data, honor $mapContext, and toggle logger level", async () => { + const service = await CliPlatformTest.invoke(CliService); + const token = class TestCommand {}; + const instance = { + $mapContext: vi.fn().mockImplementation((payload) => { + payload.flag = true; + return payload; + }) + }; + + injector().addProvider(token, { + useValue: instance + }); + + service["commands"].set("generate", {token} as any); + + const $ctx = new DIContext({ + id: "ctx", + injector: injector(), + logger: logger(), + level: "info", + platform: "CLI" + }); + $ctx.set("command", {bindLogger: vi.fn()}); + + const originalLevel = logger().level; + try { + const result = service["mapData"]("generate", {verbose: true, tsed: "yes"} as any, $ctx); + + expect(instance.$mapContext).toHaveBeenCalledWith( + expect.objectContaining({ + verbose: true, + tsed: "yes", + commandName: "generate" + }) + ); + expect(result).toEqual({ + verbose: true, + tsed: "yes", + flag: true, + commandName: "generate", + bindLogger: $ctx.get("command")!.bindLogger + }); + expect(logger().level.toLowerCase()).toBe("debug"); + } finally { + logger().level = originalLevel; + } + }); + + it("should keep info logging level when verbose is falsy", async () => { + const service = await CliPlatformTest.invoke(CliService); + const token = class OtherCommand {}; + injector().addProvider(token, { + useValue: {} + }); + service["commands"].set("add", {token} as any); + const $ctx = new DIContext({ + id: "ctx", + injector: injector(), + logger: logger(), + level: "info", + platform: "CLI" + }); + + const originalLevel = logger().level; + try { + const result = service["mapData"]("add", {verbose: false}, $ctx); + + expect(result.verbose).toBe(false); + expect(logger().level.toLowerCase()).toBe("info"); + } finally { + logger().level = originalLevel; + } + }); + + it("should build options with defaults, include root-dir/verbose, and tolerate unknown options", async () => { + const service = await CliPlatformTest.invoke(CliService); + const cmd = new Command("generate"); + cmd.exitOverride(); + + const built = service["buildOption"]( + cmd, + { + "-f, --feature ": { + description: "Feature name", + required: true, + type: String + }, + "--toggle": { + description: "Enable toggle", + type: Boolean + } + }, + true + ); + + expect(() => + built.parse(["node", "cli", "--feature", "http", "--toggle", "-r", "./repo", "--verbose", "--custom"], {from: "user"}) + ).not.toThrow(); + + const opts = built.opts(); + expect(opts.feature).toBe("http"); + expect(opts.toggle).toBe(true); + expect(opts.rootDir).toBe("./repo"); + expect(opts.verbose).toBe(true); + expect((built as any)._allowUnknownOption).toBe(true); + }); + + it("should register command arguments with required flags and defaults", async () => { + const service = await CliPlatformTest.invoke(CliService); + const cmd = new Command("generate"); + + service["buildArguments"](cmd, { + project: { + description: "Project name", + required: true + }, + template: { + description: "Template name", + required: false, + defaultValue: "rest" + } + }); + + const args = (cmd as any).registeredArguments; + expect(args[0].name()).toBe("project"); + expect(args[0].required).toBe(true); + expect(args[1].name()).toBe("template"); + expect(args[1].required).toBe(false); + expect(args[1].defaultValue).toBe("rest"); + }); + + it("should register providers via build() and guard against duplicate command names", async () => { + const service = await CliPlatformTest.invoke(CliService); + const token = class First {}; + Store.from(token).set("command", {name: "generate", description: "desc", args: {}, options: {}} as any); + const provider: any = { + token, + useClass: token + }; + const createCommand = vi.spyOn(service as any, "createCommand").mockReturnValue({} as any); + + service["build"](provider); + + expect(createCommand).toHaveBeenCalledWith(expect.objectContaining({name: "generate"})); + expect(service["commands"].get("generate")).toBe(provider); + + const duplicateProvider: any = { + token: class Duplicate {}, + useClass: class Duplicate {}, + command: {} + }; + Store.from(duplicateProvider.token).set("command", {name: "generate", description: "dup"} as any); + + expect(() => service["build"](duplicateProvider)).toThrow("The generate command is already registered"); + }); + + it("should validate command inputs via inputSchema before running lifecycle", async () => { + const service = await CliPlatformTest.invoke(CliService); + const schema = s.object({ + blueprint: s.string().required(), + feature: s.string().required(), + count: s.number().required() + }); + const metadata: CommandMetadata = { + name: "generate", + description: "Generate something", + alias: undefined, + getOptions() { + return { + args: { + blueprint: { + description: "Blueprint name", + required: true, + type: String + } + }, + options: {}, + allowUnknownOption: false + }; + }, + enableFeatures: [], + disableReadUpPkg: false, + bindLogger: true, + inputSchema: schema + }; + + const cmd = service.createCommand(metadata); + const runLifecycle = vi.spyOn(service, "runLifecycle").mockResolvedValue(undefined as never); + + service.program.args = ["controller"]; + cmd.args = ["generate", "controller"]; + cmd.setOptionValue("feature", "rest"); + cmd.setOptionValue("count", "3"); + + await (cmd as any)._actionHandler(["controller"]); + + expect(runLifecycle).toHaveBeenCalledTimes(1); + const [, data, ctx] = runLifecycle.mock.calls[0]; + expect(data).toEqual( + expect.objectContaining({ + blueprint: "controller", + feature: "rest", + count: 3, + verbose: false, + rawArgs: ["controller"] + }) + ); + expect(ctx).toBeInstanceOf(DIContext); + }); + + it("should report validation errors and skip lifecycle when inputSchema fails", async () => { + const service = await CliPlatformTest.invoke(CliService); + const schema = s.object({ + feature: s.string().required() + }); + const metadata: CommandMetadata = { + name: "deploy", + description: "Deploy something", + alias: undefined, + getOptions() { + return { + args: {}, + options: {}, + allowUnknownOption: false + }; + }, + enableFeatures: [], + disableReadUpPkg: false, + bindLogger: true, + inputSchema: schema + }; + const cmd = service.createCommand(metadata); + const runLifecycle = vi.spyOn(service, "runLifecycle").mockResolvedValue(undefined as never); + const log = logger(); + const errorSpy = vi.spyOn(log, "error").mockReturnValue(undefined as never); + + cmd.args = ["deploy"]; + service.program.args = []; + + expect(() => (cmd as any)._actionHandler(["missing-required"])).toThrow("Validation error"); + + expect(runLifecycle).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith({ + event: "VALIDATION_ERROR", + errors: expect.any(Array) + }); + + errorSpy.mockRestore(); + }); +}); diff --git a/packages/cli-core/src/services/CliService.ts b/packages/cli-core/src/services/CliService.ts index a742fb6cd..ea9ef1f42 100644 --- a/packages/cli-core/src/services/CliService.ts +++ b/packages/cli-core/src/services/CliService.ts @@ -1,4 +1,4 @@ -import {classOf} from "@tsed/core"; +import {classOf, isArrowFn} from "@tsed/core"; import { configuration, constant, @@ -20,16 +20,15 @@ import Inquirer from "inquirer"; import inquirer_autocomplete_prompt from "inquirer-autocomplete-prompt"; import {v4} from "uuid"; -import {CommandStoreKeys} from "../domains/CommandStoreKeys.js"; import type {CommandData} from "../interfaces/CommandData.js"; import type {CommandMetadata} from "../interfaces/CommandMetadata.js"; -import type {CommandArg, CommandOptions} from "../interfaces/CommandParameters.js"; +import type {CommandArg, CommandOpts} from "../interfaces/CommandOptions.js"; import type {CommandProvider} from "../interfaces/CommandProvider.js"; import type {Task} from "../interfaces/index.js"; import {PackageManagersModule} from "../packageManagers/index.js"; import {createSubTasks, createTasksRunner} from "../utils/createTasksRunner.js"; import {getCommandMetadata} from "../utils/getCommandMetadata.js"; -import {mapCommanderOptions} from "../utils/index.js"; +import {mapCommanderOptions, validate} from "../utils/index.js"; import {mapCommanderArgs} from "../utils/mapCommanderArgs.js"; import {parseOption} from "../utils/parseOption.js"; import {CliHooks} from "./CliHooks.js"; @@ -72,7 +71,6 @@ export class CliService { await $asyncEmit("$loadPackageJson"); - data = await this.beforePrompt(cmdName, data, $ctx); data = await this.prompt(cmdName, data, $ctx); try { @@ -89,20 +87,20 @@ export class CliService { } public async exec(cmdName: string, data: any, $ctx: DIContext) { - const initialTasks = await this.getTasks(cmdName, data); + const tasks = await this.getTasks(cmdName, data); $ctx.set("data", data); - if (initialTasks.length) { - const tasks = [ - ...initialTasks, - { - title: "Install dependencies", - enabled: () => this.reinstallAfterRun && (this.projectPkg.rewrite || this.projectPkg.reinstall), - task: createSubTasks(() => this.packageManagers.install(data), {...data, concurrent: false}) - }, - ...(await this.getPostInstallTasks(cmdName, data)) - ]; + if (tasks.length) { + if (this.reinstallAfterRun && (this.projectPkg.rewrite || this.projectPkg.reinstall)) { + tasks.push( + { + title: "Install dependencies", + task: createSubTasks(() => this.packageManagers.install(data), {...data, concurrent: false}) + }, + ...(await this.getPostInstallTasks(cmdName, data)) + ); + } data = this.mapData(cmdName, data, $ctx); @@ -112,29 +110,6 @@ export class CliService { } } - /** - * Run prompt for a given command - * @param cmdName - * @param data Initial data - * @param $ctx - */ - public async beforePrompt(cmdName: string, data: CommandData = {}, $ctx: DIContext) { - const provider = this.commands.get(cmdName); - const instance = inject(provider.useClass)!; - const verbose = data.verbose; - - $ctx.set("data", data); - - if (instance.$beforePrompt) { - data = await instance.$beforePrompt(JSON.parse(JSON.stringify(data))); - data.verbose = verbose; - } - - $ctx.set("data", data); - - return data; - } - /** * Run prompt for a given command * @param cmdName @@ -143,15 +118,12 @@ export class CliService { */ public async prompt(cmdName: string, data: CommandData = {}, $ctx: DIContext) { const provider = this.commands.get(cmdName); - const instance = inject(provider.useClass)!; + const instance = inject(provider.token)!; $ctx.set("data", data); if (instance.$prompt) { - const questions = [ - ...((await instance.$prompt(data)) as any[]), - ...(await this.hooks.emit(CommandStoreKeys.PROMPT_HOOKS, cmdName, data)) - ]; + const questions = [...((await instance.$prompt(data)) as any[])]; if (questions.length) { data = { @@ -178,15 +150,12 @@ export class CliService { data = this.mapData(cmdName, data, $ctx); - if (instance.$beforeExec) { - await instance.$beforeExec(data); - } + const tasks = []; - return [ - ...(await instance.$exec(data)), - ...(await this.hooks.emit(CommandStoreKeys.EXEC_HOOKS, cmdName, data)), - ...(await $asyncAlter(`$alter${pascalCase(cmdName)}Tasks`, [], [data])) - ].map((opts) => { + tasks.push(...((await instance.$exec(data)) || [])); + tasks.push(...(await $asyncAlter(`$alter${pascalCase(cmdName)}Tasks`, [], [data]))); + + return tasks.map((opts) => { return { ...opts, task: async (arg, task) => { @@ -204,36 +173,37 @@ export class CliService { public async getPostInstallTasks(cmdName: string, data: any) { const provider = this.commands.get(cmdName); - const instance = inject(provider.useClass)!; + const instance = inject(provider.token)!; data = this.mapData(cmdName, data, getContext()!); return [ ...(instance.$postInstall ? await instance.$postInstall(data) : []), - ...(await this.hooks.emit(CommandStoreKeys.POST_INSTALL_HOOKS, cmdName, data)), ...(await $asyncAlter(`$alter${pascalCase(cmdName)}PostInstallTasks`, [] as Task[], [data])), ...(instance.$afterPostInstall ? await instance.$afterPostInstall(data) : []) ]; } public createCommand(metadata: CommandMetadata) { - const {args, name, options, description, alias, allowUnknownOption} = metadata; + const {name, description, alias, inputSchema} = metadata; if (this.commands.has(name)) { return this.commands.get(name).command; } - let cmd = this.program.command(name); + const {args, options, allowUnknownOption} = metadata.getOptions(); const onAction = (commandName: string) => { const [, ...rawArgs] = cmd.args; + const mappedArgs = mapCommanderArgs( args, this.program.args.filter((arg) => commandName === arg) ); + const allOpts = mapCommanderOptions(commandName, this.program.commands); - const data: CommandData = { + let data: CommandData = { ...allOpts, verbose: !!this.program.opts().verbose, ...mappedArgs, @@ -241,6 +211,21 @@ export class CliService { rawArgs }; + if (inputSchema) { + const {isValid, errors, value} = validate(data, isArrowFn(inputSchema) ? inputSchema() : inputSchema); + + if (isValid) { + data = value; + } else { + logger().error({ + event: "VALIDATION_ERROR", + errors + }); + + throw new Error("Validation error"); + } + } + const $ctx = new DIContext({ id: v4(), injector: injector(), @@ -258,6 +243,8 @@ export class CliService { return this.runLifecycle(name, data, $ctx); }; + let cmd = this.program.command(name); + if (alias) { cmd = cmd.alias(alias); } @@ -274,7 +261,7 @@ export class CliService { return cmd; } - private load() { + load() { injector() .getProviders("command") .forEach((provider) => this.build(provider)); @@ -282,7 +269,7 @@ export class CliService { private mapData(cmdName: string, data: CommandData, $ctx: DIContext) { const provider = this.commands.get(cmdName); - const instance = inject(provider.useClass)!; + const instance = inject(provider.token)!; const verbose = data.verbose; data.commandName ||= cmdName; @@ -309,8 +296,8 @@ export class CliService { * Build command and sub-commands * @param provider */ - private build(provider: Provider) { - const metadata = getCommandMetadata(provider.useClass); + private build(provider: Provider) { + const metadata = getCommandMetadata(provider.token); if (metadata.name) { if (this.commands.has(metadata.name)) { @@ -331,7 +318,7 @@ export class CliService { * @param options * @param allowUnknownOptions */ - private buildOption(subCommand: Command, options: {[key: string]: CommandOptions}, allowUnknownOptions: boolean) { + private buildOption(subCommand: Command, options: {[key: string]: CommandOpts}, allowUnknownOptions: boolean) { Object.entries(options).reduce((subCommand, [flags, {description, required, customParser, defaultValue, ...options}]) => { const fn = (v: any) => { return parseOption(v, options); diff --git a/packages/cli-core/src/services/CliYaml.spec.ts b/packages/cli-core/src/services/CliYaml.spec.ts new file mode 100644 index 000000000..54b1e0f94 --- /dev/null +++ b/packages/cli-core/src/services/CliYaml.spec.ts @@ -0,0 +1,43 @@ +// @ts-ignore +import {CliPlatformTest} from "@tsed/cli-testing"; + +import {CliFs} from "./CliFs.js"; +import {CliYaml} from "./CliYaml.js"; + +describe("CliYaml", () => { + beforeEach(() => CliPlatformTest.create()); + afterEach(() => CliPlatformTest.reset()); + + it("should parse yaml files via CliFs", async () => { + const fs = { + readFile: vi.fn().mockResolvedValue("name: cli") + }; + const service = await CliPlatformTest.invoke(CliYaml, [ + { + token: CliFs, + use: fs + } + ]); + + const result = await service.read("settings.yml"); + + expect(fs.readFile).toHaveBeenCalledWith("settings.yml", {encoding: "utf8"}); + expect(result).toEqual({name: "cli"}); + }); + + it("should serialize objects to yaml before writing", async () => { + const fs = { + writeFile: vi.fn().mockResolvedValue(undefined) + }; + const service = await CliPlatformTest.invoke(CliYaml, [ + { + token: CliFs, + use: fs + } + ]); + + await service.write("settings.yml", {name: "cli"}); + + expect(fs.writeFile).toHaveBeenCalledWith("settings.yml", "name: cli\n", {encoding: "utf8"}); + }); +}); diff --git a/packages/cli-core/src/utils/getCommandMetadata.spec.ts b/packages/cli-core/src/utils/getCommandMetadata.spec.ts index c63b7f3bb..58c34beff 100644 --- a/packages/cli-core/src/utils/getCommandMetadata.spec.ts +++ b/packages/cli-core/src/utils/getCommandMetadata.spec.ts @@ -1,3 +1,5 @@ +import {s} from "@tsed/schema"; + import {Command} from "../decorators/index.js"; import {getCommandMetadata} from "./getCommandMetadata.js"; @@ -18,28 +20,126 @@ class TestCmd2 {} describe("getCommandMetadata", () => { it("should return command metadata", () => { - expect(getCommandMetadata(TestCmd)).toEqual({ + const {getOptions, ...result} = getCommandMetadata(TestCmd); + expect({ + ...result, + ...getOptions() + }).toEqual({ args: {}, allowUnknownOption: false, description: "description", name: "name", alias: "g", + inputSchema: undefined, disableReadUpPkg: false, enableFeatures: [], bindLogger: true, - options: {} + options: {}, + token: TestCmd }); - - expect(getCommandMetadata(TestCmd2)).toEqual({ + }); + it("should return command metadata (2)", () => { + const {getOptions, ...result} = getCommandMetadata(TestCmd2); + expect({ + ...result, + ...getOptions() + }).toEqual({ args: {}, allowUnknownOption: false, description: "description", name: "name", alias: "g", + inputSchema: undefined, disableReadUpPkg: false, enableFeatures: [], bindLogger: false, - options: {} + options: {}, + token: TestCmd2 }); }); + + it("should map inputSchema properties to args and options (direct schema)", () => { + const schema = s + .object({ + filename: s.string().required(), + verbose: s.boolean(), + list: s.array().items(s.string()) + }) + .set("custom-parser", (v: any) => v); + + // enrich properties with metadata used by getCommandMetadata + const props = schema.get("properties") as any; + props.filename.set("arg", "filename").set("description", "The file to process").set("default", "index.ts"); + props.verbose.set("opt", "--verbose").set("description", "Enable verbose mode").set("default", false); + props.list.set("opt", "--list").set("description", "List of items"); + + @Command({ + name: "with-schema", + description: "cmd with schema", + alias: "ws", + inputSchema: schema + }) + class Cmd {} + + const metadata = getCommandMetadata(Cmd); + + // inputSchema should be kept + expect(metadata.inputSchema).toBe(schema); + + // args mapped from property with `arg` + expect(metadata.getOptions().args).toEqual( + expect.objectContaining({ + filename: expect.objectContaining({ + description: "The file to process", + defaultValue: "index.ts", + type: String + }) + }) + ); + + // options mapped from properties with `opt`, including customParser from root schema + expect(metadata.getOptions().options).toEqual( + expect.objectContaining({ + "--verbose": expect.objectContaining({ + description: "Enable verbose mode", + defaultValue: false, + required: false, + type: Boolean, + customParser: schema.get("custom-parser") + }), + "--list": expect.objectContaining({ + description: "List of items", + required: false, + type: Array, + itemType: String + }) + }) + ); + }); + + it("should support inputSchema as a factory function (arrow fn)", () => { + const inner = s.object({count: s.number()}); + inner.get("properties").count.set("opt", "-c").set("description", "Count"); + + @Command({ + name: "with-fn", + description: "cmd with schema fn", + alias: "wf", + inputSchema: () => inner + }) + class Cmd {} + + const metadata = getCommandMetadata(Cmd); + + expect(metadata.inputSchema).toBeTypeOf("function"); + // getCommandMetadata resolves the function and maps options + expect(metadata.getOptions().options).toEqual( + expect.objectContaining({ + "-c": expect.objectContaining({ + description: "Count", + type: Number + }) + }) + ); + }); }); diff --git a/packages/cli-core/src/utils/getCommandMetadata.ts b/packages/cli-core/src/utils/getCommandMetadata.ts index 972a4b3e6..7cda362fe 100644 --- a/packages/cli-core/src/utils/getCommandMetadata.ts +++ b/packages/cli-core/src/utils/getCommandMetadata.ts @@ -1,34 +1,66 @@ -import {Store} from "@tsed/core"; +import {isArrowFn, Store} from "@tsed/core"; import type {TokenProvider} from "@tsed/di"; +import type {JsonSchema} from "@tsed/schema"; -import {CommandStoreKeys} from "../domains/CommandStoreKeys.js"; import type {CommandMetadata} from "../interfaces/CommandMetadata.js"; -import type {CommandParameters} from "../interfaces/CommandParameters.js"; +import type {CommandArg, CommandOptions, CommandOpts} from "../interfaces/CommandOptions.js"; export function getCommandMetadata(token: TokenProvider): CommandMetadata { const { name, alias, args = {}, - allowUnknownOption, description, options = {}, enableFeatures, disableReadUpPkg, + inputSchema, bindLogger = true, ...opts - } = Store.from(token)?.get(CommandStoreKeys.COMMAND) as CommandParameters; + } = Store.from(token)?.get("command") as CommandOptions; return { name, + inputSchema, alias, - args, description, - options, - allowUnknownOption: !!allowUnknownOption, enableFeatures: enableFeatures || [], disableReadUpPkg: !!disableReadUpPkg, bindLogger, - ...opts + ...opts, + getOptions() { + if (inputSchema) { + const schema = isArrowFn(inputSchema) ? inputSchema() : inputSchema; + + Object.entries(schema.get("properties") || {})?.forEach(([propertyKey, propertySchema]) => { + const base = { + type: propertySchema.getTarget(), + itemType: propertySchema.isCollection ? propertySchema.get("items").getTarget() : undefined, + description: propertySchema.get("description") || "", + defaultValue: propertySchema.get("default"), + required: schema.isRequired(propertyKey) + }; + + const opt = propertySchema.get("x-opt"); + + if (opt) { + options[opt] = { + ...base, + customParser: schema.get("custom-parser") + } satisfies CommandOpts; + } else { + args[propertyKey] = base satisfies CommandArg; + } + }); + + opts.allowUnknownOption = !!schema.get("additionalProperties"); + } + + return { + args, + options, + allowUnknownOption: !!opts.allowUnknownOption + }; + } }; } diff --git a/packages/cli-core/src/utils/mapCommanderArgs.ts b/packages/cli-core/src/utils/mapCommanderArgs.ts index 43a20c887..4b8e800ac 100644 --- a/packages/cli-core/src/utils/mapCommanderArgs.ts +++ b/packages/cli-core/src/utils/mapCommanderArgs.ts @@ -1,6 +1,6 @@ import {isArray, isClass, Type} from "@tsed/core"; -import type {CommandArg} from "../interfaces/CommandParameters.js"; +import type {CommandArg} from "../interfaces/CommandOptions.js"; function mapValue(value: any, {type, itemType}: {type?: Type; itemType?: Type}) { if (!value) { diff --git a/packages/cli-generate-http-client/src/commands/GenerateHttpClientCmd.ts b/packages/cli-generate-http-client/src/commands/GenerateHttpClientCmd.ts index 6776f11df..8e488e743 100644 --- a/packages/cli-generate-http-client/src/commands/GenerateHttpClientCmd.ts +++ b/packages/cli-generate-http-client/src/commands/GenerateHttpClientCmd.ts @@ -167,7 +167,8 @@ export class GenerateHttpClientCmd implements CommandProvider { } } -command(GenerateHttpClientCmd, { +command({ + token: GenerateHttpClientCmd, name: "generate-http-client", description: "Generate the client API from swagger spec", options: { diff --git a/packages/cli-generate-swagger/src/commands/GenerateSwaggerCmd.ts b/packages/cli-generate-swagger/src/commands/GenerateSwaggerCmd.ts index 25b6b617a..a555a6726 100644 --- a/packages/cli-generate-swagger/src/commands/GenerateSwaggerCmd.ts +++ b/packages/cli-generate-swagger/src/commands/GenerateSwaggerCmd.ts @@ -93,7 +93,8 @@ export class GenerateSwaggerCmd implements CommandProvider { } } -command(GenerateSwaggerCmd, { +command({ + token: GenerateSwaggerCmd, name: "generate-swagger", description: "Generate the client API from swagger spec", options: { diff --git a/packages/cli-mcp/package.json b/packages/cli-mcp/package.json index f132ae28e..a75899871 100644 --- a/packages/cli-mcp/package.json +++ b/packages/cli-mcp/package.json @@ -51,14 +51,14 @@ "@swc/core": "1.7.26", "@swc/helpers": "^0.5.13", "@tsed/cli-core": "workspace:*", - "@tsed/core": ">=8.18.0", - "@tsed/di": ">=8.18.0", - "@tsed/hooks": ">=8.18.0", + "@tsed/core": ">=8.21.0", + "@tsed/di": ">=8.21.0", + "@tsed/hooks": ">=8.21.0", "@tsed/logger": ">=8.0.3", "@tsed/logger-std": ">=8.0.3", - "@tsed/normalize-path": ">=8.18.0", - "@tsed/openspec": ">=8.18.0", - "@tsed/schema": ">=8.18.0", + "@tsed/normalize-path": ">=8.21.0", + "@tsed/openspec": ">=8.21.0", + "@tsed/schema": ">=8.21.0", "chalk": "^5.3.0", "change-case": "^5.4.4", "consolidate": "^1.0.4", diff --git a/packages/cli-mcp/src/fn/defineTool.ts b/packages/cli-mcp/src/fn/defineTool.ts index 963f3abf9..2d96ca8e9 100644 --- a/packages/cli-mcp/src/fn/defineTool.ts +++ b/packages/cli-mcp/src/fn/defineTool.ts @@ -2,6 +2,7 @@ import type {McpServer} from "@modelcontextprotocol/sdk/server/mcp.js"; import type {RequestHandlerExtra} from "@modelcontextprotocol/sdk/shared/protocol.js"; import type {CallToolResult, ServerNotification, ServerRequest, Tool} from "@modelcontextprotocol/sdk/types.js"; import {injectable} from "@tsed/cli-core"; +import {isArrowFn} from "@tsed/core"; import {DIContext, injector, logger, runInContext, type TokenProvider} from "@tsed/di"; import {JsonSchema} from "@tsed/schema"; import {v4} from "uuid"; @@ -17,7 +18,7 @@ type ToolConfig = Parameters[1]; export type ToolProps = Omit & { token?: TokenProvider; name: string; - inputSchema?: JsonSchema | Tool["inputSchema"]; + inputSchema?: JsonSchema | (() => JsonSchema) | Tool["inputSchema"]; outputSchema?: JsonSchema | Tool["outputSchema"]; handler: ToolCallback; }; @@ -57,7 +58,7 @@ export function defineTool(options: ToolProps ({ ...options, - inputSchema: toZod(options.inputSchema), + inputSchema: toZod(isArrowFn(options.inputSchema) ? options.inputSchema() : options.inputSchema), outputSchema: toZod(options.outputSchema), async handler(args: Input, extra: RequestHandlerExtra) { const $ctx = new DIContext({ @@ -73,6 +74,21 @@ export function defineTool(options: ToolProps { return options.handler(args as Input, extra); }); + } catch (er) { + $ctx.logger.error({ + event: "MCP_TOOL_ERROR", + tool: options.name, + error_message: er.message, + stack: er.stack + }); + + return { + content: [], + structuredContent: { + code: "E_MCP_TOOL_ERROR", + message: er.message + } + }; } finally { // Ensure per-invocation context is destroyed to avoid leaks try { diff --git a/packages/cli-mcp/src/index.ts b/packages/cli-mcp/src/index.ts index 3767b5ed8..f451b8e37 100644 --- a/packages/cli-mcp/src/index.ts +++ b/packages/cli-mcp/src/index.ts @@ -1,4 +1,4 @@ export * from "./fn/definePrompt.js"; export * from "./fn/defineResource.js"; export * from "./fn/defineTool.js"; -export * from "./services/CLIMCPServer.js"; +export * from "./services/McpServerFactory.js"; diff --git a/packages/cli-mcp/src/services/CLIMCPServer.spec.ts b/packages/cli-mcp/src/services/CLIMCPServer.spec.ts deleted file mode 100644 index a19f457ec..000000000 --- a/packages/cli-mcp/src/services/CLIMCPServer.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import {injector} from "@tsed/di"; -import {$asyncEmit} from "@tsed/hooks"; -import {beforeEach, describe, expect, it, vi} from "vitest"; - -import {CLIMCPServer} from "./CLIMCPServer.js"; - -vi.mock("@tsed/cli-core", async () => { - const actual = await vi.importActual("@tsed/cli-core"); - return { - ...actual, - loadPlugins: vi.fn().mockResolvedValue(undefined) - }; -}); - -vi.mock("@tsed/hooks", async () => { - const actual = await vi.importActual("@tsed/hooks"); - return { - ...actual, - $asyncEmit: vi.fn().mockResolvedValue(undefined) - }; -}); - -describe("CLIMCPServer", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should have a static bootstrap method", () => { - expect(typeof CLIMCPServer.bootstrap).toBe("function"); - }); - - it("should emit lifecycle hooks", async () => { - const emitSpy = vi.mocked($asyncEmit); - - vi.spyOn(injector(), "load").mockResolvedValue(undefined); - - try { - await CLIMCPServer.bootstrap({name: "test"}); - } catch (e) { - // Ignore connection errors - } - - expect(emitSpy).toHaveBeenCalledWith("$beforeInit"); - expect(emitSpy).toHaveBeenCalledWith("$afterInit"); - expect(emitSpy).toHaveBeenCalledWith("$onReady"); - }); - - it("should load injector", async () => { - const loadSpy = vi.spyOn(injector(), "load").mockResolvedValue(undefined); - - try { - await CLIMCPServer.bootstrap({name: "test"}); - } catch (e) { - // Ignore connection errors - } - - expect(loadSpy).toHaveBeenCalled(); - }); -}); diff --git a/packages/cli-mcp/src/services/CLIMCPServer.ts b/packages/cli-mcp/src/services/CLIMCPServer.ts deleted file mode 100644 index 210d61f54..000000000 --- a/packages/cli-mcp/src/services/CLIMCPServer.ts +++ /dev/null @@ -1,47 +0,0 @@ -import "./McpServerFactory.js"; - -import {createInjector, loadPlugins} from "@tsed/cli-core"; -import {constant, inject, injector} from "@tsed/di"; -import {$asyncEmit} from "@tsed/hooks"; - -import {MCP_SERVER} from "./McpServerFactory.js"; - -export class CLIMCPServer { - protected constructor(settings: Partial) { - createInjector({ - ...settings, - logger: { - level: "info" - } - }); - } - - static async bootstrap(settings: Partial) { - const server = new CLIMCPServer({ - ...settings, - name: settings.name || "tsed", - project: { - srcDir: "src", - scriptsDir: "scripts", - ...(settings.project || {}) - } - }); - - return server.bootstrap(); - } - - async bootstrap() { - constant("plugins") && (await loadPlugins()); - - await $asyncEmit("$beforeInit"); - - await injector().load(); - await $asyncEmit("$afterInit"); - - injector().settings.set("loaded", true); - - await $asyncEmit("$onReady"); - - await inject(MCP_SERVER).connect(); - } -} diff --git a/packages/cli-mcp/src/services/McpServerFactory.spec.ts b/packages/cli-mcp/src/services/McpServerFactory.spec.ts index 54929e909..27986d44f 100644 --- a/packages/cli-mcp/src/services/McpServerFactory.spec.ts +++ b/packages/cli-mcp/src/services/McpServerFactory.spec.ts @@ -65,24 +65,11 @@ describe("McpServerFactory", () => { injector().settings.set("mcp.mode", "stdio"); const result = inject(MCP_SERVER); - const loggerSpy = vi.spyOn(logger(), "info"); await result.connect(); - expect(loggerSpy).toHaveBeenCalledWith({event: "MCP_SERVER_CONNECT"}); - expect(loggerSpy).toHaveBeenCalledWith({event: "MCP_SERVER_CONNECTED"}); expect(mcpStdioServer).toHaveBeenCalledWith(result.server); }); - it("should log connection events", async () => { - const result = injector().invoke(MCP_SERVER); - const loggerSpy = vi.spyOn(logger(), "info"); - - await result.connect(); - - expect(loggerSpy).toHaveBeenCalledTimes(2); - expect(loggerSpy).toHaveBeenNthCalledWith(1, {event: "MCP_SERVER_CONNECT"}); - expect(loggerSpy).toHaveBeenNthCalledWith(2, {event: "MCP_SERVER_CONNECTED"}); - }); }); describe("http", () => { beforeEach(() => @@ -102,10 +89,9 @@ describe("McpServerFactory", () => { const result = inject(MCP_SERVER); const loggerSpy = vi.spyOn(logger(), "info"); - await result.connect(); + await result.connect("streamable-http"); - expect(loggerSpy).toHaveBeenCalledWith({event: "MCP_SERVER_CONNECT"}); - expect(loggerSpy).toHaveBeenCalledWith({event: "MCP_SERVER_CONNECTED"}); + expect(loggerSpy).toHaveBeenCalledWith({event: "MCP_SERVER_CONNECT", mode: "streamable-http"}); expect(mcpStreamableServer).toHaveBeenCalled(); }); }); diff --git a/packages/cli-mcp/src/services/McpServerFactory.ts b/packages/cli-mcp/src/services/McpServerFactory.ts index 4ee3836a7..5f51ecfe2 100644 --- a/packages/cli-mcp/src/services/McpServerFactory.ts +++ b/packages/cli-mcp/src/services/McpServerFactory.ts @@ -9,7 +9,7 @@ import {mcpStreamableServer} from "./McpStreamableServer.js"; export const MCP_SERVER = injectable(McpServer) .factory(() => { - const mode = constant<"streamable-http" | "stdio">("mcp.mode"); + const defaultMode = constant<"streamable-http" | "stdio">("mcp.mode"); const name = constant("name")!; const server = new McpServer({ @@ -42,16 +42,14 @@ export const MCP_SERVER = injectable(McpServer) return { server, - async connect() { - logger().info({event: "MCP_SERVER_CONNECT"}); - + async connect(mode: "streamable-http" | "stdio" | undefined = defaultMode) { if (mode === "streamable-http") { + logger().info({event: "MCP_SERVER_CONNECT", mode}); + await mcpStreamableServer(server); } else { await mcpStdioServer(server); } - - logger().info({event: "MCP_SERVER_CONNECTED"}); } }; }) diff --git a/packages/cli-mcp/src/services/McpStdioServer.spec.ts b/packages/cli-mcp/src/services/McpStdioServer.spec.ts index 05c6e8e18..cdcbce7e5 100644 --- a/packages/cli-mcp/src/services/McpStdioServer.spec.ts +++ b/packages/cli-mcp/src/services/McpStdioServer.spec.ts @@ -1,4 +1,5 @@ import {McpServer} from "@modelcontextprotocol/sdk/server/mcp.js"; +import {DITest} from "@tsed/di"; import {beforeEach, describe, expect, it, vi} from "vitest"; import {mcpStdioServer} from "./McpStdioServer.js"; @@ -15,6 +16,7 @@ describe("mcpStdioServer", () => { let mockServer: McpServer; let mockConnect: any; + beforeEach(() => DITest.create()); beforeEach(async () => { vi.clearAllMocks(); diff --git a/packages/cli-mcp/src/services/McpStdioServer.ts b/packages/cli-mcp/src/services/McpStdioServer.ts index b61da0dda..895548f42 100644 --- a/packages/cli-mcp/src/services/McpStdioServer.ts +++ b/packages/cli-mcp/src/services/McpStdioServer.ts @@ -1,9 +1,12 @@ import type {McpServer} from "@modelcontextprotocol/sdk/server/mcp.js"; +import {logger} from "@tsed/di"; export async function mcpStdioServer(server: McpServer) { const {StdioServerTransport} = await import("@modelcontextprotocol/sdk/server/stdio.js"); const transport = new StdioServerTransport(); + logger().stop(); + return server.connect(transport); } diff --git a/packages/cli-mcp/src/services/McpStreamableServer.ts b/packages/cli-mcp/src/services/McpStreamableServer.ts index e71ed816d..a141375e8 100644 --- a/packages/cli-mcp/src/services/McpStreamableServer.ts +++ b/packages/cli-mcp/src/services/McpStreamableServer.ts @@ -1,4 +1,5 @@ import type {McpServer} from "@modelcontextprotocol/sdk/server/mcp.js"; +import {logger} from "@tsed/di"; export async function mcpStreamableServer(server: McpServer) { const {StreamableHTTPServerTransport} = await import("@modelcontextprotocol/sdk/server/streamableHttp.js"); @@ -25,12 +26,23 @@ export async function mcpStreamableServer(server: McpServer) { const port = parseInt(process.env.PORT || "3000"); - app - .listen(port, () => { - console.log(`Demo MCP Server running on http://localhost:${port}/mcp`); - }) - .on("error", (error: any) => { - console.error("Server error:", error); - process.exit(1); - }); + return new Promise((resolve, reject) => { + app + .listen(port, () => { + logger().info({ + event: "MCP_STREAMABLE_SERVER", + state: "OK", + message: `Running http://localhost:${port}/mcp` + }); + }) + .on("close", () => resolve(true)) + .on("error", (error: any) => { + logger().error({ + event: "MCP_STREAMABLE_SERVER", + state: "KO", + message: error.message + }); + reject(error); + }); + }); } diff --git a/packages/cli-plugin-eslint/src/hooks/EslintInitHook.ts b/packages/cli-plugin-eslint/src/hooks/EslintInitHook.ts index 84ee410b8..15ce39318 100644 --- a/packages/cli-plugin-eslint/src/hooks/EslintInitHook.ts +++ b/packages/cli-plugin-eslint/src/hooks/EslintInitHook.ts @@ -89,21 +89,27 @@ export class EslintInitHook implements CliCommandHooks { return packageJson; } - $alterInitPostInstallTasks(tasks: Task[], data: InitCmdContext): Task[] | Promise { - const packageJson = inject(ProjectPackageJson); - const packageManagers = inject(PackageManagersModule); - + $alterInitSubTasks(tasks: Task[], data: InitCmdContext) { return [ ...tasks, { title: "Add eslint configuration", task: () => { - render("eslint.config", { + return render("eslint.config", { ...data, name: "eslint.config" }); } - }, + } + ]; + } + + $alterInitPostInstallTasks(tasks: Task[], data: InitCmdContext): Task[] | Promise { + const packageJson = inject(ProjectPackageJson); + const packageManagers = inject(PackageManagersModule); + + return [ + ...tasks, { title: "Add husky prepare task", skip: !data.lintstaged, diff --git a/packages/cli-plugin-eslint/test/init.integration.spec.ts b/packages/cli-plugin-eslint/test/init.integration.spec.ts index a76e64c4a..f2e344fa3 100644 --- a/packages/cli-plugin-eslint/test/init.integration.spec.ts +++ b/packages/cli-plugin-eslint/test/init.integration.spec.ts @@ -72,9 +72,6 @@ describe("Eslint: Init cmd", () => { "yarn install", "yarn add --ignore-engines @tsed/logger @tsed/logger-std @tsed/engines @tsed/barrels ajv cross-env @swc/core @swc/cli @swc/helpers @swc-node/register typescript body-parser cors compression cookie-parser express method-override", "yarn add -D --ignore-engines @types/node @types/multer tslib nodemon @types/cors @types/express @types/compression @types/cookie-parser @types/method-override", - "yarn run prepare", - "yarn run test:lint:fix", - "yarn run barrels", ] `); @@ -187,8 +184,6 @@ describe("Eslint: Init cmd", () => { "yarn install", "yarn add --ignore-engines @tsed/logger @tsed/logger-std @tsed/engines @tsed/barrels ajv cross-env @swc/core @swc/cli @swc/helpers @swc-node/register typescript body-parser cors compression cookie-parser express method-override", "yarn add -D --ignore-engines @types/node @types/multer tslib nodemon @types/cors @types/express @types/compression @types/cookie-parser @types/method-override", - "yarn run test:lint:fix", - "yarn run barrels", ] `); diff --git a/packages/cli-plugin-jest/src/CliPluginJestModule.ts b/packages/cli-plugin-jest/src/CliPluginJestModule.ts index 5981b7e8e..d265b6bb1 100644 --- a/packages/cli-plugin-jest/src/CliPluginJestModule.ts +++ b/packages/cli-plugin-jest/src/CliPluginJestModule.ts @@ -2,22 +2,21 @@ import "./templates/index.js"; import {RuntimesModule} from "@tsed/cli"; -import {inject, Module, OnAdd, ProjectPackageJson} from "@tsed/cli-core"; +import {inject, ProjectPackageJson} from "@tsed/cli-core"; +import {injectable} from "@tsed/di"; import {JestGenerateHook} from "./hooks/JestGenerateHook.js"; import {JestInitHook} from "./hooks/JestInitHook.js"; -@Module({ - imports: [JestInitHook, JestGenerateHook] -}) export class CliPluginJestModule { protected runtimes = inject(RuntimesModule); protected packageJson = inject(ProjectPackageJson); - @OnAdd("@tsed/cli-plugin-jest") - install() { - this.addScripts(); - this.addDevDependencies(); + $onAddPlugin(plugin: string) { + if (plugin === "@tsed/cli-plugin-jest") { + this.addScripts(); + this.addDevDependencies(); + } } addScripts() { @@ -37,3 +36,5 @@ export class CliPluginJestModule { }); } } + +injectable(CliPluginJestModule).imports([JestInitHook, JestGenerateHook]); diff --git a/packages/cli-plugin-mongoose/src/CliPluginMongooseModule.ts b/packages/cli-plugin-mongoose/src/CliPluginMongooseModule.ts index 439c24141..5f701b5c8 100644 --- a/packages/cli-plugin-mongoose/src/CliPluginMongooseModule.ts +++ b/packages/cli-plugin-mongoose/src/CliPluginMongooseModule.ts @@ -1,26 +1,27 @@ import "./templates/index.js"; -import {inject, Module, OnAdd, ProjectPackageJson} from "@tsed/cli-core"; +import {inject, ProjectPackageJson} from "@tsed/cli-core"; +import {injectable} from "@tsed/di"; import {MongooseGenerateHook} from "./hooks/MongooseGenerateHook.js"; import {MongooseInitHook} from "./hooks/MongooseInitHook.js"; import {CliMongoose} from "./services/CliMongoose.js"; -@Module({ - imports: [MongooseInitHook, MongooseGenerateHook, CliMongoose] -}) export class CliPluginMongooseModule { protected packageJson = inject(ProjectPackageJson); - @OnAdd("@tsed/cli-plugin-mongoose") - install() { - this.packageJson.addDependencies({ - "@tsed/mongoose": this.packageJson.dependencies["@tsed/platform-http"], - mongoose: "latest" - }); + $onAddPlugin(plugin: string) { + if (plugin == "@tsed/cli-plugin-mongoose") { + this.packageJson.addDependencies({ + "@tsed/mongoose": this.packageJson.dependencies["@tsed/platform-http"], + mongoose: "latest" + }); - this.packageJson.addDevDependencies({ - "@tsed/testing-mongoose": this.packageJson.dependencies["@tsed/platform-http"] - }); + this.packageJson.addDevDependencies({ + "@tsed/testing-mongoose": this.packageJson.dependencies["@tsed/platform-http"] + }); + } } } + +injectable(CliPluginMongooseModule).imports([MongooseInitHook, MongooseGenerateHook, CliMongoose]); diff --git a/packages/cli-plugin-prisma/src/CliPluginPrismaModule.ts b/packages/cli-plugin-prisma/src/CliPluginPrismaModule.ts index 9c339475f..fb92258f1 100644 --- a/packages/cli-plugin-prisma/src/CliPluginPrismaModule.ts +++ b/packages/cli-plugin-prisma/src/CliPluginPrismaModule.ts @@ -1,5 +1,5 @@ import type {InitCmdContext} from "@tsed/cli"; -import {inject, OnAdd, ProjectPackageJson} from "@tsed/cli-core"; +import {inject, ProjectPackageJson} from "@tsed/cli-core"; import {injectable} from "@tsed/di"; import {PrismaCmd} from "./commands/PrismaCmd.js"; @@ -8,11 +8,12 @@ import {PrismaInitHook} from "./hooks/PrismaInitHook.js"; export class CliPluginPrismaModule { protected packageJson = inject(ProjectPackageJson); - @OnAdd("@tsed/cli-plugin-prisma") - onAdd(ctx: InitCmdContext) { - this.addScripts(); - this.addDependencies(ctx); - this.addDevDependencies(ctx); + $onAddPlugin(plugin: string, ctx: InitCmdContext) { + if (plugin == "@tsed/cli-plugin-prisma") { + this.addScripts(); + this.addDependencies(ctx); + this.addDevDependencies(ctx); + } } addScripts() { diff --git a/packages/cli-plugin-prisma/src/commands/PrismaCmd.ts b/packages/cli-plugin-prisma/src/commands/PrismaCmd.ts index 2b3203058..3ce145e2c 100644 --- a/packages/cli-plugin-prisma/src/commands/PrismaCmd.ts +++ b/packages/cli-plugin-prisma/src/commands/PrismaCmd.ts @@ -19,7 +19,8 @@ export class PrismaCmd implements CommandProvider { } } -command(PrismaCmd, { +command({ + token: PrismaCmd, name: "prisma", description: "Run a prisma command", args: { diff --git a/packages/cli-plugin-prisma/tests/init.integration.spec.ts b/packages/cli-plugin-prisma/tests/init.integration.spec.ts index 89ce1cd38..707cfde96 100644 --- a/packages/cli-plugin-prisma/tests/init.integration.spec.ts +++ b/packages/cli-plugin-prisma/tests/init.integration.spec.ts @@ -46,7 +46,7 @@ describe("Prisma: Init cmd", () => { db: true, typeorm: true, mysql: true, - features: [FeatureType.DB, FeatureType.PRISMA], + features: [FeatureType.ORM, FeatureType.PRISMA], srcDir: "src", pnpm: false, npm: false, @@ -94,7 +94,6 @@ describe("Prisma: Init cmd", () => { "yarn add --ignore-engines @tsed/logger @tsed/logger-std @tsed/engines @tsed/barrels ajv cross-env @swc/core @swc/cli @swc/helpers @swc-node/register typescript body-parser cors compression cookie-parser express method-override @tsed/prisma @prisma/client", "yarn add -D --ignore-engines @types/node @types/multer tslib nodemon @types/cors @types/express @types/compression @types/cookie-parser @types/method-override prisma", "npx prisma init", - "yarn run barrels", ] `); diff --git a/packages/cli-plugin-typegraphql/src/TypeGraphqlModule.ts b/packages/cli-plugin-typegraphql/src/TypeGraphqlModule.ts index 331627f9e..da57f04ba 100644 --- a/packages/cli-plugin-typegraphql/src/TypeGraphqlModule.ts +++ b/packages/cli-plugin-typegraphql/src/TypeGraphqlModule.ts @@ -1,33 +1,35 @@ -import {inject, Module, OnAdd, ProjectPackageJson} from "@tsed/cli-core"; +import type {InitCmdContext} from "@tsed/cli"; +import {inject, ProjectPackageJson} from "@tsed/cli-core"; +import {injectable} from "@tsed/di"; import {TypeGraphqlInitHook} from "./hooks/TypeGraphqlInitHook.js"; -@Module({ - imports: [TypeGraphqlInitHook] -}) export class TypeGraphqlModule { protected packageJson = inject(ProjectPackageJson); - @OnAdd("@tsed/cli-plugin-typegraphql") - install(ctx: any) { - this.packageJson.addDependencies( - { - "@tsed/typegraphql": "{{tsedVersion}}", - "apollo-datasource": "^3.3.1", - "apollo-datasource-rest": "^3.5.1", - "apollo-server-core": "^3.6.2", - "type-graphql": "^1.1.1", - "class-validator": "^0.13.2", - graphql: "^15.7.2" - }, - ctx - ); - this.packageJson.addDevDependencies( - { - "@types/validator": "latest", - "apollo-server-testing": "latest" - }, - ctx - ); + $onAddPlugin(plugin: string, ctx: InitCmdContext) { + if (plugin === "@tsed/cli-plugin-typegraphql") { + this.packageJson.addDependencies( + { + "@tsed/typegraphql": "{{tsedVersion}}", + "apollo-datasource": "^3.3.1", + "apollo-datasource-rest": "^3.5.1", + "apollo-server-core": "^3.6.2", + "type-graphql": "^1.1.1", + "class-validator": "^0.13.2", + graphql: "^15.7.2" + }, + ctx + ); + this.packageJson.addDevDependencies( + { + "@types/validator": "latest", + "apollo-server-testing": "latest" + }, + ctx + ); + } } } + +injectable(TypeGraphqlModule).imports([TypeGraphqlInitHook]); diff --git a/packages/cli-plugin-typeorm/src/hooks/TypeORMInitHook.ts b/packages/cli-plugin-typeorm/src/hooks/TypeORMInitHook.ts index fce7cd82d..633d6b0c0 100644 --- a/packages/cli-plugin-typeorm/src/hooks/TypeORMInitHook.ts +++ b/packages/cli-plugin-typeorm/src/hooks/TypeORMInitHook.ts @@ -4,7 +4,7 @@ import {injectable} from "@tsed/di"; import {pascalCase} from "change-case"; function getDatabase(ctx: RenderDataContext): CliDatabases | undefined { - return ctx.features?.find((type) => type.includes("typeorm:"))?.split(":")[1] as CliDatabases; + return ctx.features?.find((type) => type.includes("orm:typeorm:"))?.split(":")[2] as CliDatabases; } export class TypeORMInitHook implements CliCommandHooks { @@ -38,7 +38,7 @@ export class TypeORMInitHook implements CliCommandHooks { return tasks; } - const typeormDataSource = data.features?.find((value) => value.startsWith("typeorm:")); + const typeormDataSource = data.features?.find((value) => value.startsWith("orm:typeorm:")); return [ ...tasks, diff --git a/packages/cli-plugin-typeorm/src/templates/datasource.template.ts b/packages/cli-plugin-typeorm/src/templates/datasource.template.ts index 9f7c56a0d..625f93c38 100644 --- a/packages/cli-plugin-typeorm/src/templates/datasource.template.ts +++ b/packages/cli-plugin-typeorm/src/templates/datasource.template.ts @@ -78,7 +78,7 @@ export default defineTemplate({ name: "typeormDataSource", message: "Which database type?", when(state: any) { - return state.type === "typeorm:datasource"; + return state.type === "orm:typeorm:datasource"; }, source: (state: any, keyword: string) => { if (keyword) { diff --git a/packages/cli-plugin-typeorm/test/integrations/init/init.integration.spec.ts b/packages/cli-plugin-typeorm/test/integrations/init/init.integration.spec.ts index 3635623ac..57c913b9e 100644 --- a/packages/cli-plugin-typeorm/test/integrations/init/init.integration.spec.ts +++ b/packages/cli-plugin-typeorm/test/integrations/init/init.integration.spec.ts @@ -39,7 +39,7 @@ describe("TypeORM: Init cmd", () => { db: true, typeorm: true, mysql: true, - features: [FeatureType.DB, FeatureType.TYPEORM, FeatureType.TYPEORM_MYSQL], + features: [FeatureType.ORM, FeatureType.TYPEORM, FeatureType.TYPEORM_MYSQL], srcDir: "src", pnpm: false, npm: false, diff --git a/packages/cli/package.json b/packages/cli/package.json index 3cf3108fb..6fd81b2b9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -22,8 +22,7 @@ "test:ci": "vitest run --coverage.thresholds.autoUpdate=true" }, "bin": { - "tsed": "lib/esm/bin/tsed.js", - "tsed-mcp": "lib/esm/bin/tsed-mcp.js" + "tsed": "lib/esm/bin/tsed.js" }, "files": [ "lib/esm/bin/tsed.js", @@ -53,14 +52,14 @@ "@swc/helpers": "^0.5.13", "@tsed/cli-core": "workspace:*", "@tsed/cli-mcp": "workspace:*", - "@tsed/core": ">=8.18.0", - "@tsed/di": ">=8.18.0", - "@tsed/hooks": ">=8.18.0", + "@tsed/core": ">=8.21.0", + "@tsed/di": ">=8.21.0", + "@tsed/hooks": ">=8.21.0", "@tsed/logger": ">=8.0.3", "@tsed/logger-std": ">=8.0.3", - "@tsed/normalize-path": ">=8.18.0", - "@tsed/openspec": ">=8.18.0", - "@tsed/schema": ">=8.18.0", + "@tsed/normalize-path": ">=8.21.0", + "@tsed/openspec": ">=8.21.0", + "@tsed/schema": ">=8.21.0", "chalk": "^5.3.0", "change-case": "^5.4.4", "consolidate": "^1.0.4", diff --git a/packages/cli/src/bin/tsed-mcp.ts b/packages/cli/src/bin/tsed-mcp.ts deleted file mode 100644 index fdcca3c3d..000000000 --- a/packages/cli/src/bin/tsed-mcp.ts +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env node -import "@swc-node/register/esm-register"; - -import {register} from "node:module"; -import {join} from "node:path"; -import {pathToFileURL} from "node:url"; - -import type {PackageJson} from "@tsed/cli-core"; -import {CLIMCPServer} from "@tsed/cli-mcp"; - -const EXT = process.env.CLI_MODE === "ts" ? "ts" : "js"; - -register(pathToFileURL(join(import.meta.dirname, `../loaders/alias.hook.${EXT}`)), { - parentURL: import.meta.dirname, - data: { - "@tsed/core": import.meta.resolve("@tsed/core"), - "@tsed/di": import.meta.resolve("@tsed/di"), - "@tsed/schema": import.meta.resolve("@tsed/schema"), - "@tsed/cli-core": import.meta.resolve("@tsed/cli-core"), - "@tsed/cli": import.meta.resolve("@tsed/cli") - }, - transferList: [] -}); - -const {tools, resources, PKG, TEMPLATE_DIR, ArchitectureConvention, ProjectConvention} = await import("../index.js"); - -CLIMCPServer.bootstrap({ - name: "tsed", - version: PKG.version, - pkg: PKG as PackageJson, - templateDir: TEMPLATE_DIR, - tools, - resources, - mcp: { - mode: process.env.args?.includes("--http") || process.env.USE_MCP_HTTP ? "streamable-http" : "stdio" - }, - defaultProjectPreferences() { - return { - convention: ProjectConvention.DEFAULT, - architecture: ArchitectureConvention.DEFAULT - }; - }, - project: { - reinstallAfterRun: true - } -}).catch((error) => { - console.error(error); - process.exit(-1); -}); diff --git a/packages/cli/src/bin/tsed.ts b/packages/cli/src/bin/tsed.ts index b1db99d2d..6d307245e 100644 --- a/packages/cli/src/bin/tsed.ts +++ b/packages/cli/src/bin/tsed.ts @@ -21,7 +21,7 @@ register(pathToFileURL(join(import.meta.dirname, `../loaders/alias.hook.${EXT}`) transferList: [] }); -const {commands, CliCore, PKG, TEMPLATE_DIR, ArchitectureConvention, ProjectConvention} = await import("../index.js"); +const {tools, commands, resources, CliCore, PKG, TEMPLATE_DIR, ArchitectureConvention, ProjectConvention} = await import("../index.js"); CliCore.bootstrap({ name: "tsed", @@ -31,6 +31,8 @@ CliCore.bootstrap({ updateNotifier: true, checkPrecondition: true, commands, + tools, + resources, defaultProjectPreferences() { return { convention: ProjectConvention.DEFAULT, diff --git a/packages/cli/src/commands/add/AddCmd.ts b/packages/cli/src/commands/add/AddCmd.ts index c61b340f8..e441f0688 100644 --- a/packages/cli/src/commands/add/AddCmd.ts +++ b/packages/cli/src/commands/add/AddCmd.ts @@ -59,7 +59,8 @@ export class AddCmd implements CommandProvider { } } -command(AddCmd, { +command({ + token: AddCmd, name: "add", description: "Add cli plugin to the current project", args: { diff --git a/packages/cli/src/commands/generate/GenerateCmd.ts b/packages/cli/src/commands/generate/GenerateCmd.ts index 54179c531..55c0e92ff 100644 --- a/packages/cli/src/commands/generate/GenerateCmd.ts +++ b/packages/cli/src/commands/generate/GenerateCmd.ts @@ -75,7 +75,8 @@ export class GenerateCmd implements CommandProvider { } } -command(GenerateCmd, { +command({ + token: GenerateCmd, name: "generate", alias: "g", description: "Generate a new provider class", diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 4b0aaa067..0fb792c78 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -1,8 +1,10 @@ import {AddCmd} from "./add/AddCmd.js"; import {GenerateCmd} from "./generate/GenerateCmd.js"; import {InitCmd} from "./init/InitCmd.js"; +import {InitOptionsCommand} from "./init/InitOptionsCmd.js"; +import {McpCommand} from "./mcp/McpCommand.js"; import {RunCmd} from "./run/RunCmd.js"; import {CreateTemplateCommand} from "./template/CreateTemplateCommand.js"; import {UpdateCmd} from "./update/UpdateCmd.js"; -export default [AddCmd, InitCmd, GenerateCmd, UpdateCmd, RunCmd, CreateTemplateCommand]; +export default [AddCmd, InitCmd, InitOptionsCommand, GenerateCmd, UpdateCmd, RunCmd, CreateTemplateCommand, McpCommand]; diff --git a/packages/cli/src/commands/init/InitCmd.spec.ts b/packages/cli/src/commands/init/InitCmd.spec.ts deleted file mode 100644 index 142aecf66..000000000 --- a/packages/cli/src/commands/init/InitCmd.spec.ts +++ /dev/null @@ -1,139 +0,0 @@ -import {PackageManagersModule} from "@tsed/cli-core"; -// @ts-ignore -import {CliPlatformTest} from "@tsed/cli-testing"; -import {catchError} from "@tsed/core"; - -import {InitCmd} from "../../../src/commands/init/InitCmd.js"; -import {RuntimesModule} from "../../runtimes/RuntimesModule.js"; - -async function getServiceFixture() { - const packageManagers = { - list: vi.fn().mockReturnValue([]) - }; - - const runtimes = { - list: vi.fn().mockReturnValue(["node"]) - }; - - const service = await CliPlatformTest.invoke(InitCmd, [ - { - token: PackageManagersModule, - use: packageManagers - }, - { - token: RuntimesModule, - use: runtimes - } - ]); - - return {service, packageManagers, runtimes}; -} - -describe("InitCmd", () => { - beforeEach(() => CliPlatformTest.create()); - afterEach(() => CliPlatformTest.reset()); - describe("checkPrecondition()", () => { - it("should throw error (platform)", async () => { - const {service} = await getServiceFixture(); - const result = catchError(() => { - service.checkPrecondition({ - platform: "wrong" - } as any); - }); - expect(result?.message).toEqual("Invalid selected platform: wrong. Possible values: express, koa, fastify."); - }); - - it("should throw error (architecture)", async () => { - const {service} = await getServiceFixture(); - - const result = catchError(() => { - service.checkPrecondition({ - architecture: "wrong" - } as any); - }); - expect(result?.message).toEqual("Invalid selected architecture: wrong. Possible values: arc_default, feature."); - }); - - it("should throw error (convention)", async () => { - const {service} = await getServiceFixture(); - - const result = catchError(() => { - service.checkPrecondition({ - convention: "wrong" - } as any); - }); - expect(result?.message).toEqual("Invalid selected convention: wrong. Possible values: conv_default, angular."); - }); - - it("should not throw error (package manager)", async () => { - const {service, packageManagers} = await getServiceFixture(); - - packageManagers.list.mockReturnValue(["npm"]); - - const result = catchError(() => { - service.checkPrecondition({ - runtime: "node", - packageManager: "npm" - } as any); - }); - - expect(result?.message).toEqual(undefined); - }); - - it("should throw error (package manager)", async () => { - const {service, packageManagers} = await getServiceFixture(); - - packageManagers.list.mockReturnValue(["yarn", "npm", "pnpm"]); - - const result = catchError(() => { - service.checkPrecondition({ - runtime: "node", - packageManager: "unknown" - } as any); - }); - expect(result?.message).toEqual("Invalid selected package manager: unknown. Possible values: yarn, npm, pnpm."); - }); - - it("should throw error (runtime)", async () => { - const {service, packageManagers} = await getServiceFixture(); - - packageManagers.list.mockReturnValue(["yarn", "npm", "pnpm"]); - - const result = catchError(() => { - service.checkPrecondition({ - packageManager: "unknown" - } as any); - }); - expect(result?.message).toEqual("Invalid selected runtime: undefined. Possible values: node."); - }); - - it("should throw error (runtime-2)", async () => { - const {service, packageManagers} = await getServiceFixture(); - - packageManagers.list.mockReturnValue(["yarn", "npm", "pnpm"]); - - const result = catchError(() => { - service.checkPrecondition({ - runtime: "unknown", - packageManager: "unknown" - } as any); - }); - expect(result?.message).toEqual("Invalid selected runtime: unknown. Possible values: node."); - }); - - it("should throw error (features)", async () => { - const {service, packageManagers} = await getServiceFixture(); - - packageManagers.list.mockReturnValue(["yarn", "npm", "pnpm"]); - - const result = catchError(() => { - service.checkPrecondition({ - packageManager: "yarn", - runtime: "node", - features: ["wrong"] - } as any); - }); - expect(result?.message).toContain("Invalid selected feature: wrong. Possible values: "); - }); - }); -}); diff --git a/packages/cli/src/commands/init/InitCmd.ts b/packages/cli/src/commands/init/InitCmd.ts index 3f90f802c..582f2f981 100644 --- a/packages/cli/src/commands/init/InitCmd.ts +++ b/packages/cli/src/commands/init/InitCmd.ts @@ -12,7 +12,6 @@ import { createSubTasks, createTasksRunner, inject, - PackageManager, PackageManagersModule, ProjectPackageJson, type QuestionOptions, @@ -23,14 +22,12 @@ import {constant} from "@tsed/di"; import {$asyncAlter} from "@tsed/hooks"; import {kebabCase} from "change-case"; -import {DEFAULT_TSED_TAGS, TEMPLATE_DIR} from "../../constants/index.js"; +import {TEMPLATE_DIR} from "../../constants/index.js"; import {exec} from "../../fn/exec.js"; import {render} from "../../fn/render.js"; import {taskOutput} from "../../fn/taskOutput.js"; -import {ArchitectureConvention} from "../../interfaces/ArchitectureConvention.js"; -import {type InitCmdContext, PlatformType} from "../../interfaces/index.js"; +import {type InitCmdContext} from "../../interfaces/index.js"; import type {InitOptions} from "../../interfaces/InitCmdOptions.js"; -import {ProjectConvention} from "../../interfaces/ProjectConvention.js"; import {PlatformsModule} from "../../platforms/PlatformsModule.js"; import {RuntimesModule} from "../../runtimes/RuntimesModule.js"; import {BunRuntime} from "../../runtimes/supports/BunRuntime.js"; @@ -54,58 +51,16 @@ export class InitCmd implements CommandProvider { protected execa = inject(CliExeca); protected fs = inject(CliFs); - checkPrecondition(ctx: InitOptions) { - const isValid = (types: any, value: any) => (value ? Object.values(types).includes(value) : true); - - if (!isValid(PlatformType, ctx.platform)) { - throw new Error(`Invalid selected platform: ${ctx.platform}. Possible values: ${Object.values(PlatformType).join(", ")}.`); - } - - if (!isValid(ArchitectureConvention, ctx.architecture)) { - throw new Error( - `Invalid selected architecture: ${ctx.architecture}. Possible values: ${Object.values(ArchitectureConvention).join(", ")}.` - ); - } - - if (!isValid(ProjectConvention, ctx.convention)) { - throw new Error(`Invalid selected convention: ${ctx.convention}. Possible values: ${Object.values(ProjectConvention).join(", ")}.`); - } - - const runtimes = this.runtimes.list(); - if (!runtimes.includes(ctx.runtime)) { - throw new Error(`Invalid selected runtime: ${ctx.runtime}. Possible values: ${runtimes.join(", ")}.`); - } - - const managers = this.packageManagers.list(); - if (!managers.includes(ctx.packageManager)) { - throw new Error(`Invalid selected package manager: ${ctx.packageManager}. Possible values: ${managers.join(", ")}.`); - } - - if (ctx.features) { - ctx.features.forEach((value) => { - const feature = FeaturesMap[value.toLowerCase()]; - - if (!feature) { - throw new Error(`Invalid selected feature: ${value}. Possible values: ${Object.values(FeatureType).join(", ")}.`); - } - }); - } - } - - async $beforePrompt(initialOptions: Partial) { + async $prompt(initialOptions: Partial): Promise { if (initialOptions.file) { const file = join(this.packageJson.cwd, initialOptions.file); - return { + initialOptions = { ...initialOptions, - ...(await this.cliLoadFile.loadFile(file, InitSchema)) + ...(await this.cliLoadFile.loadFile(file, InitSchema())) }; } - return initialOptions; - } - - $prompt(initialOptions: Partial): QuestionOptions { if (initialOptions.skipPrompt) { return []; } @@ -153,7 +108,7 @@ export class InitCmd implements CommandProvider { } as InitOptions; } - async $beforeExec(ctx: InitOptions): Promise { + async $exec(ctx: InitOptions): Promise { this.fs.ensureDirSync(this.packageJson.cwd); ctx.projectName && (this.packageJson.name = ctx.projectName); @@ -199,10 +154,7 @@ export class InitCmd implements CommandProvider { ], ctx ); - } - async $exec(ctx: InitOptions): Promise { - this.checkPrecondition(ctx); const runtime = this.runtimes.get(); ctx = { @@ -446,67 +398,10 @@ export class InitCmd implements CommandProvider { } } -command(InitCmd, { +command({ + token: InitCmd, name: "init", description: "Init a new Ts.ED project", - args: { - root: { - type: String, - defaultValue: ".", - description: "Root directory to initialize the Ts.ED project" - } - }, - options: { - "-n, --project-name ": { - type: String, - defaultValue: "", - description: "Set the project name. By default, the project is the same as the name directory." - }, - "-a, --arch ": { - type: String, - defaultValue: ArchitectureConvention.DEFAULT, - description: `Set the default architecture convention (${ArchitectureConvention.DEFAULT} or ${ArchitectureConvention.FEATURE})` - }, - "-c, --convention ": { - type: String, - defaultValue: ProjectConvention.DEFAULT, - description: `Set the default project convention (${ArchitectureConvention.DEFAULT} or ${ArchitectureConvention.FEATURE})` - }, - "-p, --platform ": { - type: String, - defaultValue: PlatformType.EXPRESS, - description: "Set the default platform for Ts.ED (express, koa or fastify)" - }, - "--features ": { - type: Array, - itemType: String, - defaultValue: [], - description: "List of the Ts.ED features." - }, - "--runtime ": { - itemType: String, - defaultValue: "node", - description: "The default runtime used to run the project" - }, - "-m, --package-manager ": { - itemType: String, - defaultValue: PackageManager.YARN, - description: "The default package manager to install the project" - }, - "-t, --tsed-version ": { - type: String, - defaultValue: DEFAULT_TSED_TAGS, - description: "Use a specific version of Ts.ED (format: 5.x.x)." - }, - "-f, --file ": { - type: String, - description: "Location of a file in which the features are defined." - }, - "-s, --skip-prompt": { - type: Boolean, - defaultValue: false, - description: "Skip the prompt." - } - }, + inputSchema: InitSchema, disableReadUpPkg: true }); diff --git a/packages/cli/src/commands/init/InitOptionsCmd.ts b/packages/cli/src/commands/init/InitOptionsCmd.ts new file mode 100644 index 000000000..93605a7a4 --- /dev/null +++ b/packages/cli/src/commands/init/InitOptionsCmd.ts @@ -0,0 +1,25 @@ +import {command} from "@tsed/cli-core"; +import {s} from "@tsed/schema"; + +import {InitSchema} from "./config/InitSchema.js"; + +export const InitOptionsCommand = command({ + name: "init-options", + description: "Display available options for Ts.ED init command for external tool", + disableReadUpPkg: true, + inputSchema: s.object({ + indent: s.number().default(0).description("Json indentation value").opt("-i, --indent ") + }), + handler(data) { + console.log( + JSON.stringify( + InitSchema().toJSON({ + useAlias: false, + customKeys: true + }), + null, + data.indent + ) + ); + } +}).token(); diff --git a/packages/cli/src/commands/init/config/FeaturesPrompt.ts b/packages/cli/src/commands/init/config/FeaturesPrompt.ts index f4dea246b..fa6618c11 100644 --- a/packages/cli/src/commands/init/config/FeaturesPrompt.ts +++ b/packages/cli/src/commands/init/config/FeaturesPrompt.ts @@ -18,7 +18,7 @@ export enum FeatureType { PASSPORTJS = "passportjs", CONFIG = "config", COMMANDS = "commands", - DB = "db", + ORM = "orm", DOC = "doc", // CONFIG @@ -33,38 +33,38 @@ export enum FeatureType { CONFIG_POSTGRES = "config:postgres:premium", // DOC - SWAGGER = "swagger", - SCALAR = "scalar", + SWAGGER = "doc:swagger", + SCALAR = "doc:scalar", // ORM - PRISMA = "prisma", - MONGOOSE = "mongoose", + PRISMA = "orm:prisma", + MONGOOSE = "orm:mongoose", // TYPEORM - TYPEORM = "typeorm", - TYPEORM_MYSQL = "typeorm:mysql", - TYPEORM_MARIADB = "typeorm:mariadb", - TYPEORM_POSTGRES = "typeorm:postgres", - TYPEORM_COCKROACHDB = "typeorm:cockroachdb", - TYPEORM_SQLITE = "typeorm:sqlite", - TYPEORM_BETTER_SQLITE3 = "typeorm:better-sqlite3", - TYPEORM_CORDOVA = "typeorm:cordova", - TYPEORM_NATIVESCRIPT = "typeorm:nativescript", - TYPEORM_ORACLE = "typeorm:oracle", - TYPEORM_MSSQL = "typeorm:mssql", - TYPEORM_MONGODB = "typeorm:mongodb", - TYPEORM_SQLJS = "typeorm:sqljs", - TYPEORM_REACTNATIVE = "typeorm:reactnative", - TYPEORM_EXPO = "typeorm:expo", + TYPEORM = "orm:typeorm", + TYPEORM_MYSQL = "orm:typeorm:mysql", + TYPEORM_MARIADB = "orm:typeorm:mariadb", + TYPEORM_POSTGRES = "orm:typeorm:postgres", + TYPEORM_COCKROACHDB = "orm:typeorm:cockroachdb", + TYPEORM_SQLITE = "orm:typeorm:sqlite", + TYPEORM_BETTER_SQLITE3 = "orm:typeorm:better-sqlite3", + TYPEORM_CORDOVA = "orm:typeorm:cordova", + TYPEORM_NATIVESCRIPT = "orm:typeorm:nativescript", + TYPEORM_ORACLE = "orm:typeorm:oracle", + TYPEORM_MSSQL = "orm:typeorm:mssql", + TYPEORM_MONGODB = "orm:typeorm:mongodb", + TYPEORM_SQLJS = "orm:typeorm:sqljs", + TYPEORM_REACTNATIVE = "orm:typeorm:reactnative", + TYPEORM_EXPO = "orm:typeorm:expo", // TESTING & LINTER TESTING = "testing", - JEST = "jest", - VITEST = "vitest", + JEST = "testing:jest", + VITEST = "testing:vitest", LINTER = "linter", - ESLINT = "eslint", - LINT_STAGED = "lintstaged", - PRETTIER = "prettier" + ESLINT = "linter:eslint", + LINT_STAGED = "linter:lintstaged", + PRETTIER = "linter:prettier" } export const FeaturesMap: Record = { @@ -92,7 +92,7 @@ export const FeaturesMap: Record = { [FeatureType.DOC]: { name: "Documentation" }, - [FeatureType.DB]: { + [FeatureType.ORM]: { name: "Database" }, [FeatureType.PASSPORTJS]: { @@ -419,11 +419,11 @@ export const FeaturesPrompt = (availableRuntimes: string[], availablePackageMana { type: "checkbox", name: "features", - message: "Check the features needed for your project", + message: "Choose the features needed for your project", choices: [ FeatureType.CONFIG, FeatureType.GRAPHQL, - FeatureType.DB, + FeatureType.ORM, FeatureType.PASSPORTJS, FeatureType.SOCKETIO, FeatureType.DOC, @@ -461,7 +461,7 @@ export const FeaturesPrompt = (availableRuntimes: string[], availablePackageMana message: "Choose a ORM manager", type: "list", name: "featuresDB", - when: hasFeature(FeatureType.DB), + when: hasFeature(FeatureType.ORM), choices: [FeatureType.PRISMA, FeatureType.MONGOOSE, FeatureType.TYPEORM] }, { diff --git a/packages/cli/src/commands/init/config/InitSchema.ts b/packages/cli/src/commands/init/config/InitSchema.ts index b04d5a233..1d021b80e 100644 --- a/packages/cli/src/commands/init/config/InitSchema.ts +++ b/packages/cli/src/commands/init/config/InitSchema.ts @@ -1,29 +1,314 @@ -import {PackageManager} from "@tsed/cli-core"; +import {PackageManager, PackageManagersModule} from "@tsed/cli-core"; +import {inject} from "@tsed/di"; import {s} from "@tsed/schema"; +import {DEFAULT_TSED_TAGS} from "../../../constants/index.js"; import {ArchitectureConvention, PlatformType, ProjectConvention} from "../../../interfaces/index.js"; -import {FeatureType} from "./FeaturesPrompt.js"; +import type {RuntimeTypes} from "../../../interfaces/RuntimeTypes.js"; +import {RuntimesModule} from "../../../runtimes/RuntimesModule.js"; +import {FeaturesMap, FeatureType} from "../../init/config/FeaturesPrompt.js"; -export const InitSchema = s - .object({ - tsedVersion: s.string().optional().description("The CLI will use the given tsed version to generate the project"), - projectName: s.string().optional().description("The project name"), - platform: s - .string() - .enum(PlatformType) - .default(PlatformType.EXPRESS) - .description("Node.js framework used to run server (Express, Koa, Fastify)"), - convention: s - .string() - .enum(ProjectConvention) - .default(ProjectConvention.DEFAULT) - .description("Project convention (Ts.ED or Angular style)"), - packageManager: s.string().enum(PackageManager).default(PackageManager.NPM).description("Used project manager to install dependencies"), - runtime: s - .string() - .enum("node", "babel", "swc", "webpack", "bun") - .description("The javascript runtime used to start application (node, node + webpack, node + swc, node + babel, bun)"), - features: s.array().items(s.string().enum(FeatureType)).required().minItems(1).description("List of feature to create the projet"), - skipPrompt: s.boolean().default(false).description("Skip the prompt") - }) - .unknown(); +export const InitSchema = () => + s + .object({ + root: s.string().required().description("Current working directory to initialize Ts.ED project"), + projectName: s + .string() + .optional() + .prompt("What is your project name") + .when((ctx) => ctx.root !== ".") + .description("Set the project name. By default, the project is the same as the name directory.") + .opt("-n, --project-name "), + platform: s + .enums(PlatformType) + .default(PlatformType.EXPRESS) + .prompt("Choose the target Framework:") + .choices([ + { + label: "Express.js", + value: PlatformType.EXPRESS, + checked: (options: any) => options.platform === PlatformType.EXPRESS || !options.platform // todo maybe it can be infered by default() and item value() + }, + { + label: "Koa.js", + value: PlatformType.KOA, + checked: (options: any) => options.platform === PlatformType.KOA || !options.platform + }, + { + label: "Fastify.js (beta)", + value: PlatformType.KOA, + checked: (options: any) => options.platform === PlatformType.KOA || !options.platform + } + ]) + .description("Set the default platform for Ts.ED (Express.js, Koa.js or Fastify.js)") + .opt("-p, --platform "), + architecture: s + .string() + .enum(ArchitectureConvention) + .default(ArchitectureConvention.DEFAULT) + .prompt("Choose the architecture for your project:") + .description("Architecture convention for tree directory") + .choices([ + { + label: "Ts.ED", + value: ArchitectureConvention.DEFAULT + }, + { + label: "Feature", + value: ArchitectureConvention.FEATURE + } + ]) + .opt("-a, --arch "), + convention: s + .enums(ProjectConvention) + .default(ProjectConvention.DEFAULT) + .prompt("Choose the file naming convention:") + .description("Set the default file naming convention (Ts.ED, Angular).") + .choices([ + { + label: "Ts.ED", + value: ProjectConvention.DEFAULT + }, + { + label: "Angular", + value: ProjectConvention.ANGULAR + } + ]) + .opt("-c, --convention "), + features: s + .array() + .items(s.enums(FeatureType)) + .prompt("Choose the features needed for your project") + .description("List of features to enable (swagger, graphql, prisma, etc.).") + .choices([ + { + label: "Commands", + value: FeatureType.COMMANDS + }, + { + label: "Configuration sources", + value: FeatureType.CONFIG, + items: [ + { + label: "Envs", + value: FeatureType.CONFIG_ENVS + }, + { + label: "Dotenv", + value: FeatureType.CONFIG_DOTENV + }, + { + label: "JSON", + value: FeatureType.CONFIG_JSON + }, + { + label: "YAML", + value: FeatureType.CONFIG_YAML + }, + { + label: "AWS Secrets Manager (Premium)", + value: FeatureType.CONFIG_AWS_SECRETS + }, + { + label: "IORedis (Premium)", + value: FeatureType.CONFIG_IOREDIS + }, + { + label: "MongoDB (Premium)", + value: FeatureType.CONFIG_MONGO + }, + { + label: "Vault (Premium)", + value: FeatureType.CONFIG_VAULT + }, + { + label: "Postgres (Premium)", + value: FeatureType.CONFIG_POSTGRES + } + ] + }, + { + label: "ORM", + value: FeatureType.ORM, + items: [ + { + label: "Prisma", + value: FeatureType.PRISMA + }, + { + label: "Mongoose", + value: FeatureType.MONGOOSE + }, + { + label: "TypeORM", + value: FeatureType.TYPEORM, + items: [ + { + label: "MySQL", + value: FeatureType.TYPEORM_MYSQL + }, + { + label: "MariaDB", + value: "db:typeorm:mariadb" + }, + { + label: "Postgres", + value: "db:typeorm:postgres" + }, + { + label: "CockRoachDB", + value: "db:typeorm:cockroachdb" + }, + { + label: "SQLite", + value: "db:typeorm:sqlite" + }, + { + label: "Better SQLite3", + value: "db:typeorm:better-sqlite3" + }, + { + label: "Cordova", + value: "db:typeorm:cordova" + }, + { + label: "NativeScript", + value: "db:typeorm:nativescript" + }, + { + label: "Oracle", + value: "db:typeorm:oracle" + }, + { + label: "MsSQL", + value: "db:typeorm:mssql" + }, + { + label: "MongoDB", + value: "db:typeorm:mongodb" + }, + { + label: "SQL.js", + value: "db:typeorm:sqljs" + }, + { + label: "ReactNative", + value: "db:typeorm:reactnative" + }, + { + label: "Expo", + value: "db:typeorm:expo" + } + ] + } + ] + }, + { + label: "Documentation", + value: "doc", + items: [ + { + label: "Swagger", + value: "doc:swagger" + }, + { + label: "Scalar", + value: "doc:scalar" + } + ] + }, + { + label: "TypeGraphQL", + value: "graphql", + items: [] + }, + { + label: "Linter", + value: "linter", + items: [ + { + label: "EsLint", + value: "linter:eslint" + }, + { + label: "Prettier", + value: "linter:prettier" + }, + { + label: "Lint on commit", + value: "linter:lintstaged" + } + ] + }, + { + label: "OpenID Connect provider", + value: "oidc", + items: [] + }, + { + label: "Passport.js", + value: "passportjs", + items: [] + }, + { + label: "Socket.io", + value: "socketio", + items: [] + }, + { + label: "Testing", + value: "testing", + items: [ + { + label: "Vitest", + value: "testing:vitest" + }, + { + label: "Jest (unstable with ESM)", + value: "testing:jest" + } + ] + } + ]) + .opt("--features "), + runtime: s + .enums(inject(RuntimesModule).list() as any[]) + .default("node") + .description("Runtime (node, bun, ...).") + .opt("--runtime "), + packageManager: s + .enums(inject(PackageManagersModule).list() as any[]) + .default(PackageManager.NPM) + .choices([ + { + label: FeaturesMap[PackageManager.NPM]!.name, + value: PackageManager.NPM + }, + { + label: FeaturesMap[PackageManager.YARN_BERRY]!.name, + value: PackageManager.YARN_BERRY + }, + { + label: FeaturesMap[PackageManager.PNPM]!.name, + value: PackageManager.PNPM + } + ]) + .description("Package manager (npm, pnpm, yarn, bun).") + .opt("-m, --package-manager "), + GH_TOKEN: s + .string() + .optional() + .description( + "GitHub token to install premium plugins. For example config:aws_secrets:premium or all features endings by `:premium` needs a GH_TOKEN" + ) + .opt("--gh-token "), + tsedVersion: s + .string() + .optional() + .default(DEFAULT_TSED_TAGS) + .description("Use a specific version of Ts.ED (format: x.x.x).") + .opt("-t, --tsed-version "), + file: s.string().optional().description("Location of a file in which the features are defined.").opt("-f, --file "), + skipPrompt: s.boolean().optional().default(false).description("Skip the prompt installation").opt("-s, --skip-prompt") + }) + .unknown(); diff --git a/packages/cli/src/commands/init/mappers/mapToContext.spec.ts b/packages/cli/src/commands/init/mappers/mapToContext.spec.ts index e4cd2c218..589b1d58b 100644 --- a/packages/cli/src/commands/init/mappers/mapToContext.spec.ts +++ b/packages/cli/src/commands/init/mappers/mapToContext.spec.ts @@ -14,7 +14,7 @@ describe("mapToContext", () => { convention: ProjectConvention.ANGULAR, features: [ FeatureType.GRAPHQL, - FeatureType.DB, + FeatureType.ORM, FeatureType.PASSPORTJS, FeatureType.SOCKETIO, FeatureType.SWAGGER, @@ -29,44 +29,48 @@ describe("mapToContext", () => { packageManager: PackageManager.PNPM, runtime: "node" }); - expect(result).toEqual({ - architecture: "feature", - commands: true, - convention: "angular", - premium: true, - db: true, - features: [ - "graphql", - "db", - "passportjs", - "socketio", - "swagger", - "oidc", - "testing", - "linter", - "commands", - "config:postgres:premium", - "typeorm", - "typeorm:mariadb" - ], - graphql: true, - linter: true, - mariadb: true, - oidc: true, - postgres: true, - config: true, - configPostgres: true, - packageManager: "pnpm", - passportjs: true, - platform: "koa", - projectName: "name", - root: ".", - socketio: true, - swagger: true, - testing: true, - typeorm: true, - runtime: "node", - typeormMariadb: true - }); + expect(result).toMatchInlineSnapshot(` + { + "architecture": "feature", + "commands": true, + "config": true, + "configPostgres": true, + "convention": "angular", + "doc": true, + "docSwagger": true, + "features": [ + "graphql", + "orm", + "passportjs", + "socketio", + "doc:swagger", + "oidc", + "testing", + "linter", + "commands", + "config:postgres:premium", + "orm:typeorm", + "orm:typeorm:mariadb", + ], + "graphql": true, + "linter": true, + "oidc": true, + "orm": true, + "ormTypeorm": true, + "ormTypeormMariadb": true, + "packageManager": "pnpm", + "passportjs": true, + "platform": "koa", + "postgres": true, + "premium": true, + "projectName": "name", + "root": ".", + "runtime": "node", + "socketio": true, + "swagger": true, + "testing": true, + "typeorm": true, + } + `); }); }); diff --git a/packages/cli/src/commands/init/prompts/getFeaturesPrompt.spec.ts b/packages/cli/src/commands/init/prompts/getFeaturesPrompt.spec.ts index c099a0b96..98f0b0ec9 100644 --- a/packages/cli/src/commands/init/prompts/getFeaturesPrompt.spec.ts +++ b/packages/cli/src/commands/init/prompts/getFeaturesPrompt.spec.ts @@ -82,10 +82,6 @@ describe("getFeaturesPrompt", () => { "name": "Configuration sources", "value": "config", }, - { - "name": "Database", - "value": "db", - }, { "name": "Documentation", "value": "doc", @@ -111,6 +107,10 @@ describe("getFeaturesPrompt", () => { "name": "OpenID Connect provider", "value": "oidc", }, + { + "name": "Database", + "value": "orm", + }, { "devDependencies": { "@tsed/cli-plugin-passport": "{{cliVersion}}", @@ -137,7 +137,7 @@ describe("getFeaturesPrompt", () => { "value": "testing", }, ], - "message": "Check the features needed for your project", + "message": "Choose the features needed for your project", "name": "features", "type": "checkbox", }, @@ -224,14 +224,14 @@ describe("getFeaturesPrompt", () => { "@tsed/swagger": "{{tsedVersion}}", }, "name": "Swagger", - "value": "swagger", + "value": "doc:swagger", }, { "dependencies": { "@tsed/scalar": "{{tsedVersion}}", }, "name": "Scalar", - "value": "scalar", + "value": "doc:scalar", }, ], "message": "Choose a documentation plugin", @@ -246,14 +246,14 @@ describe("getFeaturesPrompt", () => { "@tsed/cli-plugin-prisma": "{{cliVersion}}", }, "name": "Prisma", - "value": "prisma", + "value": "orm:prisma", }, { "devDependencies": { "@tsed/cli-plugin-mongoose": "{{cliVersion}}", }, "name": "Mongoose", - "value": "mongoose", + "value": "orm:mongoose", }, { "devDependencies": { @@ -261,7 +261,7 @@ describe("getFeaturesPrompt", () => { "typeorm": "latest", }, "name": "TypeORM", - "value": "typeorm", + "value": "orm:typeorm", }, ], "message": "Choose a ORM manager", @@ -276,83 +276,83 @@ describe("getFeaturesPrompt", () => { "mysql2": "latest", }, "name": "MySQL", - "value": "typeorm:mysql", + "value": "orm:typeorm:mysql", }, { "dependencies": { "mariadb": "latest", }, "name": "MariaDB", - "value": "typeorm:mariadb", + "value": "orm:typeorm:mariadb", }, { "dependencies": { "pg": "latest", }, "name": "Postgres", - "value": "typeorm:postgres", + "value": "orm:typeorm:postgres", }, { "dependencies": { "cockroachdb": "latest", }, "name": "CockRoachDB", - "value": "typeorm:cockroachdb", + "value": "orm:typeorm:cockroachdb", }, { "dependencies": { "sqlite3": "latest", }, "name": "SQLite", - "value": "typeorm:sqlite", + "value": "orm:typeorm:sqlite", }, { "dependencies": { "better-sqlite3": "latest", }, "name": "Better SQLite3", - "value": "typeorm:better-sqlite3", + "value": "orm:typeorm:better-sqlite3", }, { "name": "Cordova", - "value": "typeorm:cordova", + "value": "orm:typeorm:cordova", }, { "name": "NativeScript", - "value": "typeorm:nativescript", + "value": "orm:typeorm:nativescript", }, { "dependencies": { "oracledb": "latest", }, "name": "Oracle", - "value": "typeorm:oracle", + "value": "orm:typeorm:oracle", }, { "dependencies": { "mssql": "latest", }, "name": "MsSQL", - "value": "typeorm:mssql", + "value": "orm:typeorm:mssql", }, { "dependencies": { "mongodb": "latest", }, "name": "MongoDB", - "value": "typeorm:mongodb", + "value": "orm:typeorm:mongodb", }, { "name": "SQL.js", - "value": "typeorm:sqljs", + "value": "orm:typeorm:sqljs", }, { "name": "ReactNative", - "value": "typeorm:reactnative", + "value": "orm:typeorm:reactnative", }, { "name": "Expo", - "value": "typeorm:expo", + "value": "orm:typeorm:expo", }, ], "message": "Which TypeORM you want to install?", @@ -373,14 +373,14 @@ describe("getFeaturesPrompt", () => { "@tsed/cli-plugin-vitest": "{{cliVersion}}", }, "name": "Vitest", - "value": "vitest", + "value": "testing:vitest", }, { "devDependencies": { "@tsed/cli-plugin-jest": "{{cliVersion}}", }, "name": "Jest (unstable with ESM)", - "value": "jest", + "value": "testing:jest", }, ], "message": "Choose unit framework", @@ -396,7 +396,7 @@ describe("getFeaturesPrompt", () => { "@tsed/cli-plugin-eslint": "{{cliVersion}}", }, "name": "EsLint", - "value": "eslint", + "value": "linter:eslint", }, ], "message": "Choose linter tools framework", @@ -408,11 +408,11 @@ describe("getFeaturesPrompt", () => { "choices": [ { "name": "Prettier", - "value": "prettier", + "value": "linter:prettier", }, { "name": "Lint on commit", - "value": "lintstaged", + "value": "linter:lintstaged", }, ], "message": "Choose extra linter tools", diff --git a/packages/cli/src/commands/mcp/McpCommand.ts b/packages/cli/src/commands/mcp/McpCommand.ts new file mode 100644 index 000000000..f148f61e9 --- /dev/null +++ b/packages/cli/src/commands/mcp/McpCommand.ts @@ -0,0 +1,17 @@ +import {command} from "@tsed/cli-core"; +import {MCP_SERVER} from "@tsed/cli-mcp"; +import {inject} from "@tsed/di"; +import {s} from "@tsed/schema"; + +const McpSchema = s.object({ + http: s.boolean().default(false).description("Run MCP using HTTP server").opt("--http") +}); + +export const McpCommand = command({ + name: "mcp", + description: "Run a MCP server", + inputSchema: McpSchema, + handler(data) { + return inject(MCP_SERVER).connect(data.http ? "streamable-http" : "stdio"); + } +}).token(); diff --git a/packages/cli/src/mcp/resources/index.ts b/packages/cli/src/commands/mcp/resources/index.ts similarity index 100% rename from packages/cli/src/mcp/resources/index.ts rename to packages/cli/src/commands/mcp/resources/index.ts diff --git a/packages/cli/src/mcp/resources/projectInfoResource.ts b/packages/cli/src/commands/mcp/resources/projectInfoResource.ts similarity index 100% rename from packages/cli/src/mcp/resources/projectInfoResource.ts rename to packages/cli/src/commands/mcp/resources/projectInfoResource.ts diff --git a/packages/cli/src/mcp/resources/serverInfoResource.ts b/packages/cli/src/commands/mcp/resources/serverInfoResource.ts similarity index 100% rename from packages/cli/src/mcp/resources/serverInfoResource.ts rename to packages/cli/src/commands/mcp/resources/serverInfoResource.ts diff --git a/packages/cli/src/commands/mcp/schema/InitMCPSchema.ts b/packages/cli/src/commands/mcp/schema/InitMCPSchema.ts new file mode 100644 index 000000000..921b1836e --- /dev/null +++ b/packages/cli/src/commands/mcp/schema/InitMCPSchema.ts @@ -0,0 +1,11 @@ +import {s} from "@tsed/schema"; + +import {InitSchema} from "../../init/config/InitSchema.js"; + +export const InitMCPSchema = () => { + return s + .object({ + cwd: s.string().required().description("Current working directory to initialize Ts.ED project") + }) + .merge(InitSchema().omit("root", "skipPrompts", "file")); +}; diff --git a/packages/cli/src/mcp/schema/ProjectPreferencesSchema.ts b/packages/cli/src/commands/mcp/schema/ProjectPreferencesSchema.ts similarity index 84% rename from packages/cli/src/mcp/schema/ProjectPreferencesSchema.ts rename to packages/cli/src/commands/mcp/schema/ProjectPreferencesSchema.ts index 98dced5f5..9559af0d0 100644 --- a/packages/cli/src/mcp/schema/ProjectPreferencesSchema.ts +++ b/packages/cli/src/commands/mcp/schema/ProjectPreferencesSchema.ts @@ -1,8 +1,8 @@ import {PackageManager} from "@tsed/cli-core"; import {s} from "@tsed/schema"; -import {PlatformType} from "../../interfaces/PlatformType.js"; -import {ProjectConvention} from "../../interfaces/ProjectConvention.js"; +import {PlatformType} from "../../../interfaces/PlatformType.js"; +import {ProjectConvention} from "../../../interfaces/ProjectConvention.js"; export const ProjectPreferenceSchema = s .object({ diff --git a/packages/cli/src/mcp/tools/generateTool.ts b/packages/cli/src/commands/mcp/tools/generateTool.ts similarity index 94% rename from packages/cli/src/mcp/tools/generateTool.ts rename to packages/cli/src/commands/mcp/tools/generateTool.ts index 7ff68bd7b..022ceb0f6 100644 --- a/packages/cli/src/mcp/tools/generateTool.ts +++ b/packages/cli/src/commands/mcp/tools/generateTool.ts @@ -3,9 +3,9 @@ import {defineTool} from "@tsed/cli-mcp"; import {inject} from "@tsed/di"; import {array, number, object, string} from "@tsed/schema"; -import {CliProjectService} from "../../services/CliProjectService.js"; -import {CliTemplatesService} from "../../services/CliTemplatesService.js"; -import {mapDefaultTemplateOptions} from "../../services/mappers/mapDefaultTemplateOptions.js"; +import {CliProjectService} from "../../../services/CliProjectService.js"; +import {CliTemplatesService} from "../../../services/CliTemplatesService.js"; +import {mapDefaultTemplateOptions} from "../../../services/mappers/mapDefaultTemplateOptions.js"; export const generateTool = defineTool({ name: "generate-file", diff --git a/packages/cli/src/mcp/tools/getTemplateTool.ts b/packages/cli/src/commands/mcp/tools/getTemplateTool.ts similarity index 91% rename from packages/cli/src/mcp/tools/getTemplateTool.ts rename to packages/cli/src/commands/mcp/tools/getTemplateTool.ts index bd6efd603..176d4f545 100644 --- a/packages/cli/src/mcp/tools/getTemplateTool.ts +++ b/packages/cli/src/commands/mcp/tools/getTemplateTool.ts @@ -2,8 +2,8 @@ import {defineTool} from "@tsed/cli-mcp"; import {inject} from "@tsed/di"; import {s} from "@tsed/schema"; -import {CliTemplatesService} from "../../services/CliTemplatesService.js"; -import {resolveSchema} from "../../utils/resolveSchema.js"; +import {CliTemplatesService} from "../../../services/CliTemplatesService.js"; +import {resolveSchema} from "../../../utils/resolveSchema.js"; export const getTemplateTool = defineTool({ name: "get-template", diff --git a/packages/cli/src/mcp/tools/index.ts b/packages/cli/src/commands/mcp/tools/index.ts similarity index 75% rename from packages/cli/src/mcp/tools/index.ts rename to packages/cli/src/commands/mcp/tools/index.ts index a905c5943..b50f1b0e0 100644 --- a/packages/cli/src/mcp/tools/index.ts +++ b/packages/cli/src/commands/mcp/tools/index.ts @@ -1,7 +1,8 @@ import {projectInfoResource} from "../resources/projectInfoResource.js"; import {generateTool} from "./generateTool.js"; import {getTemplateTool} from "./getTemplateTool.js"; +import {initProjectTool} from "./initProjectTool.js"; import {listTemplatesTool} from "./listTemplatesTool.js"; import {setWorkspaceTool} from "./setWorkspaceTool.js"; -export default [setWorkspaceTool, projectInfoResource, listTemplatesTool, getTemplateTool, generateTool]; +export default [setWorkspaceTool, projectInfoResource, listTemplatesTool, getTemplateTool, generateTool, initProjectTool]; diff --git a/packages/cli/src/commands/mcp/tools/initProjectTool.ts b/packages/cli/src/commands/mcp/tools/initProjectTool.ts new file mode 100644 index 000000000..f4fbeaf0c --- /dev/null +++ b/packages/cli/src/commands/mcp/tools/initProjectTool.ts @@ -0,0 +1,76 @@ +import {CliService, ProjectPackageJson} from "@tsed/cli-core"; +import {defineTool} from "@tsed/cli-mcp"; +import {context, inject} from "@tsed/di"; +import {s} from "@tsed/schema"; + +import {CliTemplatesService} from "../../../services/CliTemplatesService.js"; +import {InitMCPSchema} from "../schema/InitMCPSchema.js"; + +export const initProjectTool = defineTool({ + name: "init-project", + title: "Initialize Ts.ED project", + description: "Initialize a new Ts.ED project in the current workspace (sans Listr/logs).", + inputSchema: InitMCPSchema, + outputSchema: s.object({ + files: s.array().items(s.string()).description("List of generated files."), + count: s.number().description("Number of files generated."), + projectName: s.string().optional().description("Resolved project name."), + cwd: s.string().optional().description("Resolved workspace directory in which the project was initialized."), + logs: s.array().items(s.string()).optional().description("Execution logs"), + warnings: s.array().items(s.string()).optional().description("Non blocking warnings") + }), + async handler(input) { + const cliService = inject(CliService); + const projectPackage = inject(ProjectPackageJson); + const templates = inject(CliTemplatesService); + + projectPackage.setCWD(input.cwd); + + // If the current workspace already looks initialized, block to prevent accidental re-init + if (projectPackage.preferences?.packageManager) { + return { + content: [], + isError: true, + structuredContent: { + code: "E_PROJECT_ALREADY_INITIALIZED", + message: "This workspace already appears to be an initialized Ts.ED project.", + suggestion: "Use 'generate-file' to add new files, or call 'set-workspace' with a different folder to initialize a new project." + } + }; + } + + const warnings: string[] = []; + + // Build options and map context (skip prompts by default) + const initialOptions = { + skipPrompt: true, + ...(input as any) + } as any; + + cliService.load(); + + await cliService.exec("init", initialOptions, context()); + + // Collect rendered files from templates service (same pattern as generateTool) + const rendered = templates.getRenderedFiles(); + const files = rendered.map((f) => f.outputPath); + + const logs = [ + `init:root:${projectPackage.cwd ?? "."}`, + `init:platform:${projectPackage.preferences.platform}`, + `files:${files.length}` + ]; + + return { + content: [], + structuredContent: { + files, + count: files.length, + projectName: projectPackage.name, + cwd: projectPackage.cwd, + logs, + warnings: warnings.length ? warnings : undefined + } + }; + } +}); diff --git a/packages/cli/src/mcp/tools/listTemplatesTool.ts b/packages/cli/src/commands/mcp/tools/listTemplatesTool.ts similarity index 88% rename from packages/cli/src/mcp/tools/listTemplatesTool.ts rename to packages/cli/src/commands/mcp/tools/listTemplatesTool.ts index 71b3fd421..726233dea 100644 --- a/packages/cli/src/mcp/tools/listTemplatesTool.ts +++ b/packages/cli/src/commands/mcp/tools/listTemplatesTool.ts @@ -2,8 +2,8 @@ import {defineTool} from "@tsed/cli-mcp"; import {inject} from "@tsed/di"; import {array, object, string} from "@tsed/schema"; -import {CliTemplatesService} from "../../services/CliTemplatesService.js"; -import {summarizeSchema} from "../../utils/summarizeSchema.js"; +import {CliTemplatesService} from "../../../services/CliTemplatesService.js"; +import {summarizeSchema} from "../../../utils/summarizeSchema.js"; export const listTemplatesTool = defineTool({ name: "list-templates", diff --git a/packages/cli/src/mcp/tools/setWorkspaceTool.ts b/packages/cli/src/commands/mcp/tools/setWorkspaceTool.ts similarity index 100% rename from packages/cli/src/mcp/tools/setWorkspaceTool.ts rename to packages/cli/src/commands/mcp/tools/setWorkspaceTool.ts diff --git a/packages/cli/src/commands/run/RunCmd.ts b/packages/cli/src/commands/run/RunCmd.ts index 9d9c5acb2..436f9616c 100644 --- a/packages/cli/src/commands/run/RunCmd.ts +++ b/packages/cli/src/commands/run/RunCmd.ts @@ -26,7 +26,8 @@ export class RunCmd implements CommandProvider { } } -command(RunCmd, { +command({ + token: RunCmd, name: "run", description: "Run a project level command", args: { diff --git a/packages/cli/src/commands/template/CreateTemplateCommand.ts b/packages/cli/src/commands/template/CreateTemplateCommand.ts index 5bf28aeca..eb5c1ced2 100644 --- a/packages/cli/src/commands/template/CreateTemplateCommand.ts +++ b/packages/cli/src/commands/template/CreateTemplateCommand.ts @@ -97,7 +97,8 @@ export class CreateTemplateCommand implements CommandProvider { } } -command(CreateTemplateCommand, { +command({ + token: CreateTemplateCommand, name: "template", description: "Create a custom template that can be selected in tsed generate command", args: { diff --git a/packages/cli/src/commands/update/UpdateCmd.ts b/packages/cli/src/commands/update/UpdateCmd.ts index 4dc5327a5..c5a156223 100644 --- a/packages/cli/src/commands/update/UpdateCmd.ts +++ b/packages/cli/src/commands/update/UpdateCmd.ts @@ -141,9 +141,8 @@ export class UpdateCmd implements CommandProvider { } } -command(UpdateCmd, { +command({ + token: UpdateCmd, name: "update", - description: "Update all Ts.ED packages used by your project", - args: {}, - options: {} + description: "Update all Ts.ED packages used by your project" }); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ba78cfb3a..0cb659feb 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -9,13 +9,13 @@ export * from "./commands/init/config/FeaturesPrompt.js"; export * from "./commands/init/config/FeaturesPrompt.js"; export * from "./commands/init/InitCmd.js"; export * from "./commands/init/prompts/getFeaturesPrompt.js"; +export {default as resources} from "./commands/mcp/resources/index.js"; +export {default as tools} from "./commands/mcp/tools/index.js"; export * from "./commands/update/UpdateCmd.js"; export * from "./constants/index.js"; export * from "./fn/exec.js"; export * from "./fn/render.js"; export * from "./interfaces/index.js"; -export {default as resources} from "./mcp/resources/index.js"; -export {default as tools} from "./mcp/tools/index.js"; export * from "./pipes/index.js"; export * from "./runtimes/index.js"; export * from "./services/CliProjectService.js"; diff --git a/packages/cli/src/interfaces/InitCmdOptions.ts b/packages/cli/src/interfaces/InitCmdOptions.ts index e75601a43..1e4532a0b 100644 --- a/packages/cli/src/interfaces/InitCmdOptions.ts +++ b/packages/cli/src/interfaces/InitCmdOptions.ts @@ -3,7 +3,7 @@ import type {RenderDataContext} from "./RenderDataContext.js"; export interface InitOptions extends RenderDataContext { root: string; srcDir: string; - skipPrompt: boolean; + skipPrompt?: boolean; GH_TOKEN?: string; } diff --git a/tools/integration/mcp.ts b/tools/integration/mcp.ts index e9b554e4c..edebad925 100644 --- a/tools/integration/mcp.ts +++ b/tools/integration/mcp.ts @@ -1 +1 @@ -import "@tsed/cli/bin/mcp"; +import "@tsed/cli/bin"; diff --git a/tools/integration/package.json b/tools/integration/package.json index df911b9dc..12a70c931 100644 --- a/tools/integration/package.json +++ b/tools/integration/package.json @@ -11,14 +11,15 @@ }, "scripts": { "run-cmd": "cross-env NODE_ENV=development CLI_MODE=ts CI=true node --import @swc-node/register/esm-register index.ts", - "run-mcp-stdio": "npx @modelcontextprotocol/inspector node -e NODE_ENV=development -e CLI_MODE=ts -e CI=true -e LOG_SERVER_URL=http://localhost:3838 --import @swc-node/register/esm-register mcp.ts", - "run-mcp-http": "cross-env NODE_ENV=development CLI_MODE=ts CI=true USE_MCP_HTTP=true node --import @swc-node/register/esm-register mcp.ts", + "run-mcp-stdio": "npx @modelcontextprotocol/inspector node -e NODE_ENV=development -e CLI_MODE=ts -e CI=true -e LOG_SERVER_URL=http://localhost:3838 --import @swc-node/register/esm-register mcp.ts mcp", + "run-mcp-http": "cross-env NODE_ENV=development CLI_MODE=ts CI=true USE_MCP_HTTP=true node --import @swc-node/register/esm-register index.ts mcp --http", "mcp:inspector": "npx @modelcontextprotocol/inspector", "start:help": "yarn run-cmd -h", "start:help:g": "yarn run-cmd generate -h", "start:help:i": "yarn run-cmd init -h", "start:version": "yarn run-cmd --version", "start:init:help": "yarn run-cmd init -h", + "start:init:options": "yarn run-cmd init-options --indent 2", "start:init:test": "yarn run-cmd init init -r ./.tmp/init/default --features=oidc --arch=default --convention=conv_default --platform=express --package-manager=npm --skip-prompt .", "start:init:test:jest": "yarn run-cmd init -r ./.tmp/init/default --features=jest --arch=default --convention=conv_default --platform=express --package-manager=npm --skip-prompt .", "start:init:run": "yarn run-cmd init -r ./.tmp/init/default", diff --git a/yarn.lock b/yarn.lock index e52561b29..721581bbd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2532,14 +2532,14 @@ __metadata: "@swc/core": "npm:1.7.26" "@swc/helpers": "npm:^0.5.13" "@tsed/cli-core": "workspace:*" - "@tsed/core": "npm:>=8.18.0" - "@tsed/di": "npm:>=8.18.0" - "@tsed/hooks": "npm:>=8.18.0" + "@tsed/core": "npm:>=8.21.0" + "@tsed/di": "npm:>=8.21.0" + "@tsed/hooks": "npm:>=8.21.0" "@tsed/logger": "npm:>=8.0.3" "@tsed/logger-std": "npm:>=8.0.3" - "@tsed/normalize-path": "npm:>=8.18.0" - "@tsed/openspec": "npm:>=8.18.0" - "@tsed/schema": "npm:>=8.18.0" + "@tsed/normalize-path": "npm:>=8.21.0" + "@tsed/openspec": "npm:>=8.21.0" + "@tsed/schema": "npm:>=8.21.0" "@tsed/typescript": "workspace:*" "@types/change-case": "npm:^2.3.1" "@types/consolidate": "npm:0.14.4" @@ -2733,15 +2733,15 @@ __metadata: "@commitlint/config-conventional": "npm:19.5.0" "@swc/core": "npm:1.7.26" "@swc/helpers": "npm:0.5.13" - "@tsed/core": "npm:>=8.18.0" - "@tsed/di": "npm:>=8.18.0" + "@tsed/core": "npm:>=8.21.0" + "@tsed/di": "npm:>=8.21.0" "@tsed/logger": "npm:>=8.0.3" "@tsed/logger-std": "npm:>=8.0.3" "@tsed/markdown-it-symbols": "npm:3.20.8" "@tsed/monorepo-utils": "npm:3.0.0" - "@tsed/normalize-path": "npm:>=8.18.0" - "@tsed/openspec": "npm:>=8.18.0" - "@tsed/schema": "npm:>=8.18.0" + "@tsed/normalize-path": "npm:>=8.21.0" + "@tsed/openspec": "npm:>=8.21.0" + "@tsed/schema": "npm:>=8.21.0" "@tsed/ts-doc": "npm:^4.1.0" "@types/inquirer-autocomplete-prompt": "npm:3.0.3" "@types/node": "npm:22.7.4" @@ -2815,14 +2815,14 @@ __metadata: "@swc/helpers": "npm:^0.5.13" "@tsed/cli-core": "workspace:*" "@tsed/cli-mcp": "workspace:*" - "@tsed/core": "npm:>=8.18.0" - "@tsed/di": "npm:>=8.18.0" - "@tsed/hooks": "npm:>=8.18.0" + "@tsed/core": "npm:>=8.21.0" + "@tsed/di": "npm:>=8.21.0" + "@tsed/hooks": "npm:>=8.21.0" "@tsed/logger": "npm:>=8.0.3" "@tsed/logger-std": "npm:>=8.0.3" - "@tsed/normalize-path": "npm:>=8.18.0" - "@tsed/openspec": "npm:>=8.18.0" - "@tsed/schema": "npm:>=8.18.0" + "@tsed/normalize-path": "npm:>=8.21.0" + "@tsed/openspec": "npm:>=8.21.0" + "@tsed/schema": "npm:>=8.21.0" "@tsed/typescript": "workspace:*" "@types/change-case": "npm:^2.3.1" "@types/consolidate": "npm:0.14.4" @@ -2866,23 +2866,22 @@ __metadata: optional: false bin: tsed: lib/esm/bin/tsed.js - tsed-mcp: lib/esm/bin/tsed-mcp.js languageName: unknown linkType: soft -"@tsed/core@npm:>=8.18.0": - version: 8.18.2 - resolution: "@tsed/core@npm:8.18.2" +"@tsed/core@npm:>=8.21.0": + version: 8.21.0 + resolution: "@tsed/core@npm:8.21.0" dependencies: reflect-metadata: "npm:^0.2.2" tslib: "npm:2.7.0" - checksum: 10/b2800e2d71bed97633780047354d2d8805f7dee86910297cde62486f697df503ed93eaf650668e39340a17314cf39dc99a52875c069590550a6eee0f74c7e19c + checksum: 10/0f99142c739644bb469934ec4693e1dc613582b9074106a0755c7bf18cf087a4f5724f9f599cbf9a9af4ae84d2d5c891ce4a6ca23a67c223b04f7a84933345c8 languageName: node linkType: hard -"@tsed/di@npm:>=8.18.0": - version: 8.18.2 - resolution: "@tsed/di@npm:8.18.2" +"@tsed/di@npm:>=8.21.0": + version: 8.21.0 + resolution: "@tsed/di@npm:8.21.0" dependencies: tslib: "npm:2.7.0" uuid: "npm:^10.0.0" @@ -2897,17 +2896,17 @@ __metadata: optional: false "@tsed/logger": optional: false - checksum: 10/5bb136218f6dc95208cf67b68881ef24e639ec8e6a64d484fad34a2051970c7f8e28c32ac12f80130f24764868c12a158dbf69eeb18d0bfb89fd3b30bc436bb3 + checksum: 10/ee8836d53548a985cfe472f395c1117e6c96117c6343cb51ee0f50d245893f8bade860754f78b24dcd09ff875372c7b328ca95456e1f94c11e935874b29b1cbe languageName: node linkType: hard -"@tsed/hooks@npm:>=8.18.0": - version: 8.18.2 - resolution: "@tsed/hooks@npm:8.18.2" +"@tsed/hooks@npm:>=8.21.0": + version: 8.21.0 + resolution: "@tsed/hooks@npm:8.21.0" dependencies: reflect-metadata: "npm:^0.2.2" tslib: "npm:2.7.0" - checksum: 10/cbcc6e33fbaf07751dd14c1a8a1e55109a644cb3b229f5bb25e0ed71378ed1c2deba6103ecea1c530b9af1fb50e0a0517f333e61389b0b710f0c99566347450c + checksum: 10/58002cfeaf23ecedfdf90f527058a1f25c4346973ea2e201a3e5d9842fe9c4d093fc584cd52be562752b957f1a7b01848c8a0bbdea4de94f8e9e8e353e60ee77 languageName: node linkType: hard @@ -3004,28 +3003,28 @@ __metadata: languageName: node linkType: hard -"@tsed/normalize-path@npm:>=8.18.0": - version: 8.18.2 - resolution: "@tsed/normalize-path@npm:8.18.2" +"@tsed/normalize-path@npm:>=8.21.0": + version: 8.21.0 + resolution: "@tsed/normalize-path@npm:8.21.0" dependencies: normalize-path: "npm:3.0.0" tslib: "npm:2.7.0" - checksum: 10/59609f694e53d99e71ec054664372a79f892f097c3f003afbaaaf1defc9ec1fc5d27dc23e9c01d18c063b083f6882d771595a1b1f706d537d4d3f5b26221ab67 + checksum: 10/90ec188403435ebdd587d7baace397697b703ca61720d1fa14077a5cb6590bd889a63ade3ea2ebb0e11db8c016d525a16ed9a946bef15b6a62b0984fd229551f languageName: node linkType: hard -"@tsed/openspec@npm:8.18.2, @tsed/openspec@npm:>=8.18.0": - version: 8.18.2 - resolution: "@tsed/openspec@npm:8.18.2" - checksum: 10/2ed970acb36c88bf600a3064ba65931a8a2830f1975eb14790b309077a628c2cac2a7dde0c86d94cc52993c782d105fadf70cd9faf842783bdbd8642d7a2c019 +"@tsed/openspec@npm:8.21.0, @tsed/openspec@npm:>=8.21.0": + version: 8.21.0 + resolution: "@tsed/openspec@npm:8.21.0" + checksum: 10/f4dd87f9007721fb8e71d7efada10197378f9e22d1bfcf6acb6442c40181d708820ab313a807c23c7e6a0ddedfb85f3084954ee64918ce00f7b6dce45dce1f43 languageName: node linkType: hard -"@tsed/schema@npm:>=8.18.0": - version: 8.18.2 - resolution: "@tsed/schema@npm:8.18.2" +"@tsed/schema@npm:>=8.21.0": + version: 8.21.0 + resolution: "@tsed/schema@npm:8.21.0" dependencies: - "@tsed/openspec": "npm:8.18.2" + "@tsed/openspec": "npm:8.21.0" change-case: "npm:^5.4.4" json-schema: "npm:0.4.0" picomatch: "npm:4.0.2" @@ -3040,7 +3039,7 @@ __metadata: optional: false "@tsed/openspec": optional: false - checksum: 10/fdb9c120e9afa12e62f6466a4046457e916e612a7b9dbb2e7bbaafb685b96d19dfd5a0ebc098af1754e673f23471432062727cf173731241774d4b112c2775cb + checksum: 10/c9db92abe934059259798e233147166488dd9255e15a0a1876453821c2441996d039bb5719c33e8837166f0873b6e9b3c354b86b375924b28a187bb398a4b301 languageName: node linkType: hard