From 57579b602a73ad1bc30f9e599b9a1ee74f3fc2c0 Mon Sep 17 00:00:00 2001 From: IT-WIBRC Date: Sat, 4 Oct 2025 13:33:39 +0100 Subject: [PATCH] feat(cli): add support for language aliases (js, ts, n) in all relevant commands --- .changeset/social-cars-go.md | 5 + packages/devkit/README.md | 51 +- packages/devkit/TODO.md | 6 +- .../__tests__/integrations/config/add.spec.ts | 102 +++- .../integrations/config/index.spec.ts | 2 +- .../integrations/config/remove.spec.ts | 51 +- .../integrations/config/update.spec.ts | 92 +++- .../__tests__/integrations/list.spec.ts | 88 +++- .../devkit/__tests__/integrations/new.spec.ts | 113 +++- .../units/commands/config/add.spec.ts | 77 ++- .../units/commands/config/index.spec.ts | 4 +- .../units/commands/config/remove.spec.ts | 496 ------------------ .../commands/config/remove/index.spec.ts | 79 ++- .../commands/config/update/index.spec.ts | 110 +++- .../__tests__/units/commands/index.spec.ts | 2 +- .../__tests__/units/commands/init.spec.ts | 349 ------------ .../__tests__/units/commands/list.spec.ts | 104 +++- .../__tests__/units/commands/new.spec.ts | 84 ++- .../units/core/config/language.spec.ts | 38 ++ packages/devkit/src/commands/config/add.ts | 2 + packages/devkit/src/commands/config/index.ts | 4 +- packages/devkit/src/commands/config/remove.ts | 192 ------- .../src/commands/config/remove/index.ts | 2 + packages/devkit/src/commands/config/types.ts | 4 +- packages/devkit/src/commands/config/update.ts | 209 -------- .../src/commands/config/update/index.ts | 8 +- .../src/commands/config/validate-and-save.ts | 8 +- packages/devkit/src/commands/index.ts | 2 +- packages/devkit/src/commands/init.ts | 140 ----- packages/devkit/src/commands/list.ts | 6 +- packages/devkit/src/commands/new.ts | 4 +- packages/devkit/src/core/config/language.ts | 30 ++ packages/devkit/src/core/prompts/prompts.ts | 8 +- .../src/core/template/template-utils.ts | 4 +- packages/devkit/src/utils/schema/schema.ts | 207 +++++--- .../devkit/src/utils/validations/config.ts | 6 +- 36 files changed, 1039 insertions(+), 1650 deletions(-) create mode 100644 .changeset/social-cars-go.md delete mode 100644 packages/devkit/__tests__/units/commands/config/remove.spec.ts delete mode 100644 packages/devkit/__tests__/units/commands/init.spec.ts create mode 100644 packages/devkit/__tests__/units/core/config/language.spec.ts delete mode 100644 packages/devkit/src/commands/config/remove.ts delete mode 100644 packages/devkit/src/commands/config/update.ts delete mode 100644 packages/devkit/src/commands/init.ts create mode 100644 packages/devkit/src/core/config/language.ts diff --git a/.changeset/social-cars-go.md b/.changeset/social-cars-go.md new file mode 100644 index 0000000..545bb96 --- /dev/null +++ b/.changeset/social-cars-go.md @@ -0,0 +1,5 @@ +--- +"scaffolder-toolkit": minor +--- + +feat(cli): Add support for language aliases (js, ts, n) in all relevant commands diff --git a/packages/devkit/README.md b/packages/devkit/README.md index 5d1a0e6..c2e940c 100644 --- a/packages/devkit/README.md +++ b/packages/devkit/README.md @@ -12,7 +12,7 @@ Built to fit the modern developer workflow, `dk` seamlessly integrates into mono - **Unified Command:** Access all features with the short, intuitive command `dk`. - **Intelligent Scaffolding:** Create new projects from a wide variety of popular frameworks with a single, intuitive command. You can also use custom templates for a consistent workflow. **See the list of supported templates below.** -- **Node.js Ecosystem Support:** All commands and templates are currently designed for and support the **Node.js ecosystem**, including projects managed with **npm**, **Yarn**, **pnpm**, and **Bun**. This includes **JavaScript, TypeScript, and any project that relies on a Node.js runtime or a similar engine like Bun** for dependency management and execution. +- **Node.js Ecosystem Support:** All commands and templates are currently designed for and support the **Node.js ecosystem**, including projects managed with **npm**, **Yarn**, **pnpm**, and **Bun**. This includes **JavaScript, TypeScript, and any project that relies on a Node.js runtime or a similar engine like Bun** for dependency management and execution. **You can use either the full language name or its alias (e.g., `javascript` or `js`, `typescript` or `ts`, `nodejs` or `node`) when executing commands.** - **Robust Configuration:** The tool reliably finds your configuration file (`.devkit.json`) in any project or monorepo structure. It uses a clear priority system to manage both local and global settings. - **Powerful Cache Management:** Optimize project setup speed with flexible caching strategies for your templates. These strategies are mainly applied when using a GitHub URL: - `always-refresh`: Always pull the latest template from the remote repository. @@ -107,7 +107,7 @@ In addition to the options for each command, you can use global flags that affec Scaffolder comes with a set of pre-configured templates for popular frameworks and libraries. You can use these templates out of the box with the `dk new` command. -> **Note:** All templates currently support Node.js projects and must be configured under the `javascript` key. +> **Note:** All templates currently support Node.js projects and must be configured under the **canonical language key** (`javascript`, `typescript`, or `nodejs`) in your config file. However, you can use the short **aliases** (`js`, `ts`, `node`) when running any `dk` command. | Template Name | Description | Alias | | :------------- | :----------------------------------------------------- | :----- | @@ -151,7 +151,10 @@ dk info The `new` command now takes a language and a project name as arguments. You can then specify the template with the `-t` or `--template` flag. ```bash -# Create a new Vue project +# Create a new Vue project using the 'js' language alias +dk new js my-awesome-app -t vue + +# Create a new Vue project using the full 'javascript' name dk new javascript my-awesome-app -t vue ``` @@ -165,8 +168,8 @@ The `dk list` command allows you to view all available templates defined in your # List all templates, prioritizing the local config dk list -# List templates for a specific language (e.g., 'javascript') -dk list javascript +# List templates for a specific language (e.g., 'javascript' or its alias 'js') +dk list js # List templates and filter by a where clause (e.g., 'name:vue') dk list --where name:vue @@ -204,7 +207,7 @@ dk list --all dk list --where alias:rt --where pm=npm # List javascript templates and filter by templates whose name starts with 'r' -dk list javascript --where name:/^r/ +dk list js --where name:/^r/ # List templates in a compact table format, including defaults dk list --mode table -d @@ -265,8 +268,8 @@ dk config list --all -d The `dk config add` command allows you to easily register a new template with your CLI. It intelligently updates the configuration file in your current context. ```bash -# Add a new template from a GitHub repository -dk config add javascript react-ts-template --description "My custom React TS template" --location https://github.com/my-user/my-react-ts-template.git +# Add a new template from a GitHub repository using the 'js' language alias +dk config add js react-ts-template --description "My custom React TS template" --location https://github.com/my-user/my-react-ts-template.git ``` #### Update a template's configuration @@ -274,7 +277,7 @@ dk config add javascript react-ts-template --description "My custom React TS tem The `dk config update` command allows you to modify an existing template's properties. - You must provide the language and the name of the template(s) you wish to update. -- **Wildcard Support:** Use the wildcard character `*` in place of a template name (e.g., `dk config update javascript *`) to apply the update to **all templates** registered under that language. +- **Wildcard Support:** Use the wildcard character `*` in place of a template name (e.g., `dk config update js *`) to apply the update to **all templates** registered under that language. - **Multiple Templates:** You can list multiple template names to apply the **exact same property updates** to all of them. - You can also update the template's name using the `--new-name` flag (this flag only works when updating a single template). - Use the `--global` flag to update templates in your global (`~/.devkitrc`) file. @@ -282,14 +285,14 @@ The `dk config update` command allows you to modify an existing template's prope ```bash -# Update the description and alias for a single template +# Update the description and alias for a single template using the canonical language name dk config update javascript my-template --description "A new and improved description" --alias "my-alias" -# Apply a new package manager to ALL javascript templates in the local config -dk config update javascript * --package-manager bun +# Apply a new package manager to ALL javascript templates in the local config using the 'js' alias +dk config update js * --package-manager bun # Change a template's name and its description in a single command -dk config update javascript my-template --new-name my-cool-template --description "A newly renamed template" +dk config update js my-template --new-name my-cool-template --description "A newly renamed template" ``` --- @@ -299,22 +302,22 @@ dk config update javascript my-template --new-name my-cool-template --descriptio The `dk config remove` command allows you to delete one or more templates from your configuration file. - You must provide the language and the name(s) of the template(s) you wish to remove. -- **Wildcard Support:** Use the wildcard character `*` in place of a template name (e.g., `dk config remove javascript *`) to remove **all templates** registered under that language. -- **Multiple Templates:** You can list multiple template names to remove them all in one operation (e.g., `dk config remove javascript template1 template2`). +- **Wildcard Support:** Use the wildcard character `*` in place of a template name (e.g., `dk config remove js *`) to remove **all templates** registered under that language. +- **Multiple Templates:** You can list multiple template names to remove them all in one operation (e.g., `dk config remove js template1 template2`). - **Global:** You can explicitly remove the template from your global (`~/.devkitrc`) file using the `--global` flag. - **Local:** It removes the template from the `.devkit.json` file in the root of your current project. ```bash -# Remove the 'react-ts-template' for 'javascript' from the local config -dk config remove javascript react-ts-template +# Remove the 'react-ts-template' for 'javascript' from the local config using the 'js' alias +dk config remove js react-ts-template -# Remove ALL javascript templates from the local config -dk config remove javascript * +# Remove ALL javascript templates from the local config using the 'js' alias +dk config remove js * # Remove multiple templates at once from the local config -dk config remove javascript template1 template2 +dk config remove js template1 template2 # Remove the 'node-api' template from the global config dk config remove node node-api --global @@ -408,7 +411,7 @@ Once an alias is configured, you can use it in place of the full template name f ```bash # Create a new project from the alias 'gh-template' -dk new javascript my-new-project-name -t gh-template +dk new js my-new-project-name -t gh-template ``` ### Create and configure a project file @@ -543,11 +546,11 @@ First, build your template. This is a standard project directory containing all Once your template project is ready, use the `dk config add` command to register it with the CLI. This command adds the template's details to your `.devkit.json` file, making it available for use. -> **Note:** All custom templates must be added to the `javascript` section of your configuration file. +> **Note:** All custom templates must be added under a supported language section (e.g., `javascript`, `typescript`, `node`) in your configuration file. ```bash # Add a template from a local folder to your global config -dk config add javascript custom-js-app --description "My personal JavaScript boilerplate" --location /Users/myuser/projects/my-local-template --global +dk config add js custom-js-app --description "My personal JavaScript boilerplate" --location /Users/myuser/projects/my-local-template --global ``` #### Step 3: Use the Template @@ -556,7 +559,7 @@ After running the `dk config add` command, you can scaffold a new project from y ```bash # Create a new project from the template we just added -dk new javascript my-awesome-project -t custom-js-app +dk new js my-awesome-project -t custom-js-app ``` --- diff --git a/packages/devkit/TODO.md b/packages/devkit/TODO.md index 2dce8ee..ff964a6 100644 --- a/packages/devkit/TODO.md +++ b/packages/devkit/TODO.md @@ -64,9 +64,10 @@ This document tracks all planned and completed tasks for the Dev Kit project. - [x] Add a `--settings, -s` to the `dk list` command to display the current configuration settings Only - [x] Add wildcard support for template name in the `dk config update` and `dk config remove` commands. - [x] add short keys to get config using `dk config` like `dk config lang` for `dk config language` -- [ ] ** Enhance for organization Purpose **: Add new language `Typescript(ts)` with same code as javascript(js), also support for nodejs(node) template name for those who prefer it than the programming language name +- [x] ** Enhance for organization Purpose **: Add new language `Typescript(ts)` with same code as javascript(js), also support for nodejs(node) template name for those who prefer it than the programming language name +- [x] **Testing**: Stabilize the integration test of the `new` command +- [ ] Make sure to clean up if the `dk new` command fail - [ ] Add a configuration validation step when updating the config file to ensure all required fields are present and correctly formatted. -- [ ] **Testing**: Stabilize the integration test of the `new` command - [ ] **Dynamic Help Text**: Programmatically generate help text for options with constrained values (e.g., `--cache-strategy`) to ensure it's always up to date. #### Multi-Language Support @@ -82,6 +83,7 @@ This document tracks all planned and completed tasks for the Dev Kit project. ### Debating - [ ] **Skip Confirmation**: Add a global `-y`/`--yes` and `-n/--no` option to skip confirmation prompts in commands like `dk init`. +- [ ] For unsupported languages, we can let the configuration be added but with a warning that it's not supported yet. When using the `dk new` with an unsupported language, we can copy the template as is without any modifications but ignoring the `.git` folder and not installing dependencies. - [ ] **CLI Self-Update**: Implement a command to allow users to update the CLI itself. `dk upgrade` - [ ] **Color Configuration**: Add a feature to allow users to configure the colors for templates. - [ ] Use the interactive approach for the `dk config add` command (code already there) diff --git a/packages/devkit/__tests__/integrations/config/add.spec.ts b/packages/devkit/__tests__/integrations/config/add.spec.ts index e9d92bf..0692b21 100644 --- a/packages/devkit/__tests__/integrations/config/add.spec.ts +++ b/packages/devkit/__tests__/integrations/config/add.spec.ts @@ -115,7 +115,7 @@ describe("dk config add", () => { await fs.remove(globalConfigDir); }); - it("should add a new template to the local config file", async () => { + it("should add a new template to the local config file (using canonical language)", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const vueTemplatePath = path.join( localTemplateDir, @@ -152,6 +152,86 @@ describe("dk config add", () => { }); }); + it("should add a new template to the local config file using the 'js' alias", async () => { + await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); + const svelteTemplatePath = path.join( + localTemplateDir, + "javascript", + "svelte-basic.txt", + ); + await fs.ensureDir(svelteTemplatePath); + + const { exitCode, all } = await execute( + "bun", + [ + CLI_PATH, + "config", + "add", + "js", + "svelte-basic", + "-d", + "A basic Svelte template", + "-o", + svelteTemplatePath, + ], + { all: true }, + ); + + const updatedConfig = await fs.readJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + ); + + expect(exitCode).toBe(0); + expect(all).toContain("Template 'svelte-basic' added successfully!"); + expect( + updatedConfig.templates.javascript.templates["svelte-basic"], + ).toEqual({ + description: "A basic Svelte template", + location: svelteTemplatePath, + }); + expect(updatedConfig.templates.js).toBeUndefined(); + }); + + it("should add a new template and create the 'typescript' language section using the 'ts' alias", async () => { + await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); + const tsNodeTemplatePath = path.join( + localTemplateDir, + "typescript", + "ts-node.txt", + ); + await fs.ensureDir(tsNodeTemplatePath); + + const { exitCode, all } = await execute( + "bun", + [ + CLI_PATH, + "config", + "add", + "ts", + "ts-node", + "-d", + "A basic TS Node template", + "-o", + tsNodeTemplatePath, + ], + { all: true }, + ); + + const updatedConfig = await fs.readJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + ); + + expect(exitCode).toBe(0); + expect(all).toContain("Template 'ts-node' added successfully!"); + + expect(updatedConfig.templates.typescript).toBeDefined(); + expect(updatedConfig.templates.typescript.templates["ts-node"]).toEqual({ + description: "A basic TS Node template", + location: tsNodeTemplatePath, + }); + expect(updatedConfig.templates.ts).toBeUndefined(); + }); + it("should add a new template to the global config with --global flag", async () => { await fs.writeJson( path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), @@ -206,7 +286,7 @@ describe("dk config add", () => { ); }); - it("should fail to add a template if the programming language language is not found", async () => { + it("should fail to add a template if the programming language language is invalid (even with alias resolution)", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const { exitCode, all } = await execute( "bun", @@ -214,7 +294,7 @@ describe("dk config add", () => { CLI_PATH, "config", "add", - "typescript", + "invalid-lang$", "ts-node", "-d", "A TS project", @@ -225,12 +305,10 @@ describe("dk config add", () => { ); expect(exitCode).toBe(1); - expect(all).toContain( - "[DEV]>> Devkit encountered an unexpected internal issue: Invalid value for Programming Language. Valid options are: javascript", - ); + expect(all).toContain("Invalid value for Programming Language."); }); - it("should fail to add a template if it already exists", async () => { + it("should fail to add a template if it already exists (using alias to check canonical key)", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const { exitCode, all } = await execute( "bun", @@ -238,7 +316,7 @@ describe("dk config add", () => { CLI_PATH, "config", "add", - "javascript", + "js", "react-ts", "-d", "A React project with TypeScript", @@ -250,11 +328,11 @@ describe("dk config add", () => { expect(exitCode).toBe(1); expect(all).toContain( - "::[DEV]>> Devkit encountered an unexpected internal issue: Template 'react-ts' already exists in the configuration. Use 'devkit config set' to update it.", + "Template 'react-ts' already exists in the configuration. Use 'devkit config set' to update it.", ); }); - it("should fail to add a template if a template with the same alias exists", async () => { + it("should fail to add a template if a template with the same alias exists (using alias to check canonical key)", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const location = path.join( localTemplateDir, @@ -269,7 +347,7 @@ describe("dk config add", () => { CLI_PATH, "config", "add", - "javascript", + "js", "new-ts-template", "-d", "A new TS project", @@ -283,7 +361,7 @@ describe("dk config add", () => { expect(exitCode).toBe(1); expect(all).toContain( - "::[DEV]>> Devkit encountered an unexpected internal issue: Alias 'rt' already exists for another template in this language. Please choose a different alias.", + "Alias 'rt' already exists for another template in this language. Please choose a different alias.", ); }); }); diff --git a/packages/devkit/__tests__/integrations/config/index.spec.ts b/packages/devkit/__tests__/integrations/config/index.spec.ts index cb9a348..b7cfd11 100644 --- a/packages/devkit/__tests__/integrations/config/index.spec.ts +++ b/packages/devkit/__tests__/integrations/config/index.spec.ts @@ -96,7 +96,7 @@ describe("dk config", () => { it("should throw an error if no config file is found for setting", async () => { const { all, exitCode } = await execute( "bun", - [CLI_PATH, "conf", "--set", "language", "en"], + [CLI_PATH, "conf", "--set", "lang", "en"], { all: true, reject: false }, ); diff --git a/packages/devkit/__tests__/integrations/config/remove.spec.ts b/packages/devkit/__tests__/integrations/config/remove.spec.ts index 3f9ddbd..dfcad81 100644 --- a/packages/devkit/__tests__/integrations/config/remove.spec.ts +++ b/packages/devkit/__tests__/integrations/config/remove.spec.ts @@ -92,7 +92,7 @@ describe("dk config remove - Basic and Alias", () => { await fs.remove(globalConfigDir); }); - it("should remove a single template from the local config by name", async () => { + it("should remove a single template from the local config by name (canonical language)", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const { exitCode, all } = await execute( "bun", @@ -119,6 +119,30 @@ describe("dk config remove - Basic and Alias", () => { ).toBe(2); }); + it("should remove a single template from the local config using the 'js' language alias", async () => { + await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); + const { exitCode, all } = await execute( + "bun", + [CLI_PATH, "config", "remove", "js", "vue-basic"], + { all: true }, + ); + + const updatedConfig = await fs.readJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + ); + + expect(exitCode).toBe(0); + expect(all).toContain( + "Successfully removed 1 template(s) (vue-basic) from javascript.", + ); + expect( + updatedConfig.templates.javascript.templates["vue-basic"], + ).toBeUndefined(); + expect( + Object.keys(updatedConfig.templates.javascript.templates).length, + ).toBe(2); + }); + it("should remove multiple templates at once by name", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const { exitCode, all } = await execute( @@ -190,7 +214,7 @@ describe("dk config remove - Wildcard Support", () => { await fs.remove(globalConfigDir); }); - it("should remove ALL local templates using the wildcard '*'", async () => { + it("should remove ALL local templates using the wildcard '*' (canonical language)", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const { exitCode, all } = await execute( "bun", @@ -211,6 +235,27 @@ describe("dk config remove - Wildcard Support", () => { ).toBe(0); }); + it("should remove ALL local templates using the 'js' language alias and wildcard '*'", async () => { + await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); + const { exitCode, all } = await execute( + "bun", + [CLI_PATH, "config", "remove", "js", "*"], + { all: true }, + ); + + const updatedConfig = await fs.readJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + ); + + expect(exitCode).toBe(0); + expect(all).toContain( + "Successfully removed 3 template(s) (react-ts, vue-basic, node-cli) from javascript.", + ); + expect( + Object.keys(updatedConfig.templates.javascript.templates).length, + ).toBe(0); + }); + it("should remove ALL global templates using the wildcard '*' with --global", async () => { await fs.writeJson( path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), @@ -362,7 +407,7 @@ describe("dk config remove - Global and Edge Cases", () => { ); }); - it("should throw an error if the specified language is not found", async () => { + it("should throw an error if the specified language alias is unknown or invalid", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const { exitCode, all } = await execute( "bun", diff --git a/packages/devkit/__tests__/integrations/config/update.spec.ts b/packages/devkit/__tests__/integrations/config/update.spec.ts index ed88cbd..547d5e2 100644 --- a/packages/devkit/__tests__/integrations/config/update.spec.ts +++ b/packages/devkit/__tests__/integrations/config/update.spec.ts @@ -145,7 +145,7 @@ afterEach(async () => { }); describe("Config Update - Single and Multiple Templates", () => { - it("should update a single template in the local config by name", async () => { + it("should update a single template in the local config by name (canonical language)", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const { exitCode, all } = await execute( "bun", @@ -179,6 +179,37 @@ describe("Config Update - Single and Multiple Templates", () => { ); }); + it("should update a single template in the local config using the 'js' language alias", async () => { + await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); + const { exitCode, all } = await execute( + "bun", + [ + CLI_PATH, + "config", + "update", + "js", + "react-ts", + "-d", + "Updated using JS alias", + "-a", + "rts", + ], + { all: true }, + ); + + const updatedConfig = await fs.readJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + ); + + expect(exitCode).toBe(0); + expect(all).toContain( + "Successfully updated 1 (react-ts) template(s) from javascript!", + ); + expect( + updatedConfig.templates.javascript.templates["react-ts"].description, + ).toBe("Updated using JS alias"); + }); + it("should update multiple templates in the local config", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const { exitCode, all } = await execute( @@ -214,7 +245,7 @@ describe("Config Update - Single and Multiple Templates", () => { }); describe("Config Update - Wildcard and Alias Support", () => { - it("should update a single template in the local config using its alias", async () => { + it("should update a single template in the local config using its alias (canonical language)", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const { exitCode, all } = await execute( "bun", @@ -243,7 +274,7 @@ describe("Config Update - Wildcard and Alias Support", () => { ).toBe("Updated via Alias"); }); - it("should update ALL local templates using the wildcard '*'", async () => { + it("should update ALL local templates using the wildcard '*' (canonical language)", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const { exitCode, all } = await execute( "bun", @@ -272,6 +303,35 @@ describe("Config Update - Wildcard and Alias Support", () => { ).toBe("Updated by Wildcard"); }); + it("should update ALL local templates using the 'js' language alias and wildcard '*'", async () => { + await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); + const { exitCode, all } = await execute( + "bun", + [ + CLI_PATH, + "config", + "update", + "js", + "*", + "-d", + "Updated by JS Alias Wildcard", + ], + { all: true }, + ); + + const updatedConfig = await fs.readJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + ); + + expect(exitCode).toBe(0); + expect(all).toContain( + "Successfully updated 3 (react-ts, vue-basic, other-js) template(s) from javascript!", + ); + expect( + updatedConfig.templates.javascript.templates["other-js"].description, + ).toBe("Updated by JS Alias Wildcard"); + }); + it("should update templates found by '*' but warn for explicitly listed non-existent names", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const { exitCode, all } = await execute( @@ -447,7 +507,7 @@ describe("Config Update - Failure and Edge Cases", () => { ); }); - it("should fail gracefully if a language is not found (validation error)", async () => { + it("should fail gracefully if a language is not found (validation error, canonical language)", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const { exitCode, all } = await execute( "bun", @@ -455,7 +515,7 @@ describe("Config Update - Failure and Edge Cases", () => { CLI_PATH, "config", "update", - "typescript", + "python", "ts-template", "-d", "some-description", @@ -469,6 +529,28 @@ describe("Config Update - Failure and Edge Cases", () => { ); }); + it("should fail gracefully if an unknown language alias is provided (validation error)", async () => { + await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); + const { exitCode, all } = await execute( + "bun", + [ + CLI_PATH, + "config", + "update", + "unknown-alias", + "some-template", + "-d", + "some-description", + ], + { all: true, reject: false }, + ); + + expect(exitCode).toBe(1); + expect(all).toContain( + "Invalid value for Programming Language. Valid options are: javascript", + ); + }); + it("should fail if no template name is provided", async () => { const { exitCode, all } = await execute( "bun", diff --git a/packages/devkit/__tests__/integrations/list.spec.ts b/packages/devkit/__tests__/integrations/list.spec.ts index ecf69b2..de1b055 100644 --- a/packages/devkit/__tests__/integrations/list.spec.ts +++ b/packages/devkit/__tests__/integrations/list.spec.ts @@ -48,7 +48,7 @@ const localConfig: CliConfig = { }, }, }, - node: { + nodejs: { templates: { "node-api": { description: "A Node.js API boilerplate", @@ -119,7 +119,7 @@ describe("dk list", () => { expect(all).toContain("Configuration sources loaded successfully."); expect(all).toContain("Available Templates:"); expect(all).toContain("Javascript"); - expect(all).toContain("Node"); + expect(all).toContain("Nodejs"); expect(all).not.toContain("Python"); }); @@ -143,7 +143,7 @@ describe("dk list", () => { expect(all).toContain("Configuration sources loaded successfully."); expect(all).toContain("Available Templates:"); expect(all).toContain("Javascript"); - expect(all).toContain("Node"); + expect(all).toContain("Nodejs"); expect(all).toContain("Python"); }); @@ -171,10 +171,10 @@ describe("dk list", () => { expect(all).toContain("Available Templates:"); expect(all).toContain("Python"); expect(all).not.toContain("Javascript"); - expect(all).not.toContain("Node"); + expect(all).not.toContain("Nodejs"); }); - it("should filter templates by language argument", async () => { + it("should filter templates by language argument (canonical name)", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const { all, exitCode } = await execute( @@ -190,7 +190,36 @@ describe("dk list", () => { expect(all).toContain("Javascript"); expect(all).toContain("react-ts"); expect(all).toContain("vue-basic"); - expect(all).not.toContain("Node"); + expect(all).not.toContain("Nodejs"); + }); + + it("should filter templates by 'js' language alias", async () => { + await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); + + const { all, exitCode } = await execute("bun", [CLI_PATH, "list", "js"], { + all: true, + env: { HOME: globalConfigDir }, + }); + + expect(exitCode).toBe(0); + expect(all).toContain("Javascript"); + expect(all).toContain("react-ts"); + expect(all).toContain("vue-basic"); + expect(all).not.toContain("Nodejs"); + }); + + it("should filter templates by 'node' language alias", async () => { + await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); + + const { all, exitCode } = await execute("bun", [CLI_PATH, "list", "node"], { + all: true, + env: { HOME: globalConfigDir }, + }); + + expect(exitCode).toBe(0); + expect(all).toContain("Nodejs"); + expect(all).toContain("node-api"); + expect(all).not.toContain("Javascript"); }); it("should filter templates by name using the --where syntax", async () => { @@ -242,7 +271,24 @@ describe("dk list", () => { expect(exitCode).toBe(0); expect(all).toContain("react-ts"); - expect(all).toContain("vue-basic"); + expect(all).not.toContain("node-api"); + }); + + it("should filter templates by substring in packageManager, matching only npm", async () => { + await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); + + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "list", "--where", "pm:/^npm/"], + { + all: true, + env: { HOME: globalConfigDir }, + }, + ); + + expect(exitCode).toBe(0); + expect(all).toContain("react-ts"); + expect(all).not.toContain("vue-basic"); expect(all).not.toContain("node-api"); }); @@ -336,6 +382,7 @@ describe("dk list", () => { javascript: { templates: {}, }, + nodejs: localConfig.templates.nodejs, }, }); @@ -436,10 +483,31 @@ describe("dk list", () => { expect(all).toContain("Available Templates:"); expect(all).toContain("Language"); expect(all).toContain("Javascript"); - expect(all).toContain("Node"); + expect(all).toContain("Nodejs"); expect(all).not.toContain("Python"); }); + it("should filter templates by 'js' language alias in table mode", async () => { + await fs.writeJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + localConfig, + ); + + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "list", "js", "--mode", "table"], + { + all: true, + env: { HOME: globalConfigDir }, + }, + ); + + expect(exitCode).toBe(0); + expect(all).toContain("Javascript"); + expect(all).toContain("react-ts"); + expect(all).not.toContain("Nodejs"); + }); + it("should handle both local and global configs being empty", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), { settings: {}, @@ -486,7 +554,7 @@ describe("dk list", () => { expect(all).toContain("Javascript"); expect(all).toContain("vue-basic"); expect(all).not.toContain("react-ts"); - expect(all).not.toContain("Node"); + expect(all).not.toContain("Nodejs"); expect(all).not.toContain("Python"); }); }); @@ -553,7 +621,7 @@ describe("dk list", () => { expect(all).toContain("Available Templates:"); expect(all).toContain("Javascript"); expect(all).toContain("remix"); - expect(all).toContain("Node"); + expect(all).toContain("Nodejs"); expect(all).toContain("node-api"); }); diff --git a/packages/devkit/__tests__/integrations/new.spec.ts b/packages/devkit/__tests__/integrations/new.spec.ts index 67c7ce5..ac96e4d 100644 --- a/packages/devkit/__tests__/integrations/new.spec.ts +++ b/packages/devkit/__tests__/integrations/new.spec.ts @@ -44,6 +44,16 @@ const userTemplates = { }, }, }, + typescript: { + templates: { + tsexpress: { + description: "A TypeScript Express app", + location: "file://./packages/templates/typescript/tsexpress", + alias: "ts-exp", + packageManager: "npm", + }, + }, + }, }; const createConfigWithUserTemplates = (templateLocation: string): CliConfig => @@ -63,6 +73,14 @@ const createConfigWithUserTemplates = (templateLocation: string): CliConfig => }, }, }, + typescript: { + templates: { + tsexpress: { + ...userTemplates.typescript.templates.tsexpress, + location: `${templateLocation}/typescript/tsexpress`, + }, + }, + }, }, }) as CliConfig; @@ -96,9 +114,13 @@ describe("dk new", () => { process.env.HOME = mockInstallDir; const devkitDistDir = path.join(mockInstallDir, "dist"); - const templatesDir = path.join(mockInstallDir, "templates", "javascript"); + const templatesDir = path.join(mockInstallDir, "templates"); + const templatesJsDir = path.join(templatesDir, "javascript"); + const templatesTsDir = path.join(templatesDir, "typescript"); + await fs.ensureDir(devkitDistDir); - await fs.ensureDir(templatesDir); + await fs.ensureDir(templatesJsDir); + await fs.ensureDir(templatesTsDir); const configWithTemplates = createConfigWithUserTemplates( `file://${path.join(mockInstallDir, "templates")}`, @@ -108,25 +130,33 @@ describe("dk new", () => { configWithTemplates, ); - await fs.ensureDir(path.join(templatesDir, "vuejs")); - - await fs.writeFile(path.join(templatesDir, "vuejs", "package.json"), { + await fs.ensureDir(path.join(templatesJsDir, "vuejs")); + await fs.writeFile(path.join(templatesJsDir, "vuejs", "package.json"), { name: "test-vue-template", }); - await fs.writeFile( - path.join(templatesDir, "vuejs", "vue-test.txt"), + path.join(templatesJsDir, "vuejs", "vue-test.txt"), "vue content", ); - await fs.ensureDir(path.join(templatesDir, "nestjs")); - await fs.writeFile(path.join(templatesDir, "nestjs", "package.json"), { + await fs.ensureDir(path.join(templatesJsDir, "nestjs")); + await fs.writeFile(path.join(templatesJsDir, "nestjs", "package.json"), { name: "test-nest-template", }); await fs.writeFile( - path.join(templatesDir, "nestjs", "nest-test.txt"), + path.join(templatesJsDir, "nestjs", "nest-test.txt"), "nestjs content", ); + + await fs.ensureDir(path.join(templatesTsDir, "tsexpress")); + await fs.writeFile( + path.join(templatesTsDir, "tsexpress", "package.json"), + { name: "test-ts-express-template" }, + ); + await fs.writeFile( + path.join(templatesTsDir, "tsexpress", "ts-express-test.txt"), + "ts express content", + ); }); afterEach(async () => { @@ -135,7 +165,7 @@ describe("dk new", () => { delete process.env.HOME; }); - it("should successfully scaffold a new project from a different directory", async () => { + it("should successfully scaffold a new project from a different directory (canonical language: javascript)", async () => { const { exitCode } = await execute( "bun", [CLI_PATH, "new", "javascript", "my-vue-app", "-t", "vuejs"], @@ -154,6 +184,27 @@ describe("dk new", () => { ), ).toBe(true); }); + + it("should successfully scaffold a new project using the 'ts' language alias", async () => { + const { exitCode } = await execute( + "bun", + [CLI_PATH, "new", "ts", "my-ts-app", "-t", "ts-exp"], + { cwd: mockProjectDir, all: true }, + ); + expect(exitCode).toBe(0); + + expect( + await fs.pathExists( + path.join(mockProjectDir, "my-ts-app", "package.json"), + ), + ).toBe(true); + + expect( + await fs.pathExists( + path.join(mockProjectDir, "my-ts-app", "ts-express-test.txt"), + ), + ).toBe(true); + }); }, 10000); describe("dk new (Monorepo Usage)", () => { @@ -173,8 +224,16 @@ describe("dk new", () => { "templates", "javascript", ); + const packagesTemplatesTsDir = path.join( + tempDir, + "packages", + "templates", + "typescript", + ); + await fs.ensureDir(packagesDevkitDir); await fs.ensureDir(packagesTemplatesJsDir); + await fs.ensureDir(packagesTemplatesTsDir); const configWithTemplates = createConfigWithUserTemplates( "file://./packages/templates", @@ -203,6 +262,16 @@ describe("dk new", () => { path.join(packagesTemplatesJsDir, "nestjs", "nest-test.txt"), "nestjs content", ); + + await fs.ensureDir(path.join(packagesTemplatesTsDir, "tsexpress")); + await fs.writeFile( + path.join(packagesTemplatesTsDir, "tsexpress", "package.json"), + { name: "test-ts-express-template" }, + ); + await fs.writeFile( + path.join(packagesTemplatesTsDir, "tsexpress", "ts-express-test.txt"), + "ts express content", + ); }); afterEach(async () => { @@ -210,7 +279,7 @@ describe("dk new", () => { delete process.env.HOME; }); - it("should successfully scaffold a new project within the monorepo", async () => { + it("should successfully scaffold a new project within the monorepo (canonical language: javascript)", async () => { const { exitCode } = await execute( "bun", [CLI_PATH, "new", "javascript", "my-vue-app", "-t", "vuejs"], @@ -225,5 +294,25 @@ describe("dk new", () => { await fs.pathExists(path.join(tempDir, "my-vue-app", "vue-test.txt")), ).toBe(true); }); + + it("should successfully scaffold a new project within the monorepo using the 'ts' language alias", async () => { + const { exitCode } = await execute( + "bun", + [CLI_PATH, "new", "ts", "my-ts-app-mono", "-t", "ts-exp"], + { all: true, cwd: tempDir }, + ); + expect(exitCode).toBe(0); + + expect( + await fs.pathExists( + path.join(tempDir, "my-ts-app-mono", "package.json"), + ), + ).toBe(true); + expect( + await fs.pathExists( + path.join(tempDir, "my-ts-app-mono", "ts-express-test.txt"), + ), + ).toBe(true); + }); }); }); diff --git a/packages/devkit/__tests__/units/commands/config/add.spec.ts b/packages/devkit/__tests__/units/commands/config/add.spec.ts index 94239ed..aa404e6 100644 --- a/packages/devkit/__tests__/units/commands/config/add.spec.ts +++ b/packages/devkit/__tests__/units/commands/config/add.spec.ts @@ -12,11 +12,13 @@ const { mockReadConfigSources, mockValidateAndSaveTemplate, mockValidateProgrammingLanguage, + mockMapLanguageAliasToCanonicalKey, } = vi.hoisted(() => ({ mockHandleErrorAndExit: vi.fn(), mockReadConfigSources: vi.fn(), mockValidateAndSaveTemplate: vi.fn(), mockValidateProgrammingLanguage: vi.fn(), + mockMapLanguageAliasToCanonicalKey: vi.fn((lang) => lang), })); let actionFn: (...options: unknown[]) => Promise; @@ -33,6 +35,10 @@ vi.mock("#utils/validations/config.js", () => ({ validateProgrammingLanguage: mockValidateProgrammingLanguage, })); +vi.mock("#core/config/language.js", () => ({ + mapLanguageAliasToCanonicalKey: mockMapLanguageAliasToCanonicalKey, +})); + vi.mock("../../../../src/commands/config/validate-and-save.js", () => ({ validateAndSaveTemplate: mockValidateAndSaveTemplate, })); @@ -88,6 +94,7 @@ describe("setupAddCommand", () => { configFound: true, }); mockValidateProgrammingLanguage.mockReturnValue(true); + mockMapLanguageAliasToCanonicalKey.mockImplementation((lang) => lang); }); it("should set up the add command with correct options and arguments", () => { @@ -113,6 +120,40 @@ describe("setupAddCommand", () => { }, }; + it("should map a language alias ('ts') to its canonical key and use it for validation/saving", async () => { + setupAddCommand(mockConfigCommand); + const aliasLang = "ts"; + const canonicalLang = "typescript"; + const templateName = "my-ts-template"; + + mockMapLanguageAliasToCanonicalKey.mockReturnValue(canonicalLang); + + await actionFn( + aliasLang, + templateName, + defaultCmdOptions, + mockParentCommand, + ); + + expect(mockMapLanguageAliasToCanonicalKey).toHaveBeenCalledWith( + aliasLang, + ); + + expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith( + canonicalLang, + ); + + expect(mockValidateAndSaveTemplate).toHaveBeenCalledWith( + expect.objectContaining({ + language: canonicalLang, + templateName, + }), + MOCK_LOCAL_CONFIG, + false, + mockSpinner, + ); + }); + it("should process and save a new template targeting the LOCAL config by default", async () => { setupAddCommand(mockConfigCommand); await actionFn( @@ -148,7 +189,7 @@ describe("setupAddCommand", () => { mockParentCommand.parent.opts.mockReturnValue({ global: true }); await actionFn( - "python", + "nodejs", "django-app", defaultCmdOptions, mockParentCommand as any, @@ -160,7 +201,7 @@ describe("setupAddCommand", () => { }); expect(mockValidateAndSaveTemplate).toHaveBeenCalledWith( - expect.objectContaining({ language: "python" }), + expect.objectContaining({ language: "nodejs" }), MOCK_GLOBAL_CONFIG, true, mockSpinner, @@ -186,7 +227,7 @@ describe("setupAddCommand", () => { ); expect(mockValidateAndSaveTemplate).toHaveBeenCalledWith( - expect.any(Object), + expect.objectContaining({ language: "javascript" }), MOCK_DEFAULT_CONFIG, false, mockSpinner, @@ -212,7 +253,7 @@ describe("setupAddCommand", () => { ); expect(mockValidateAndSaveTemplate).toHaveBeenCalledWith( - expect.any(Object), + expect.objectContaining({ language: "javascript" }), MOCK_DEFAULT_CONFIG, true, mockSpinner, @@ -230,13 +271,15 @@ describe("setupAddCommand", () => { mockParentCommand as any, ); + const expectedError = new DevkitError( + mocktFn(MISSING_REQUIRED_KEY, { + fields: "--description, --location", + }), + ); + expect(mockHandleErrorAndExit).toHaveBeenCalledOnce(); expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - new DevkitError( - mocktFn(MISSING_REQUIRED_KEY, { - fields: "--description, --location", - }), - ), + expectedError, mockSpinner, ); expect(mockReadConfigSources).not.toHaveBeenCalled(); @@ -253,21 +296,21 @@ describe("setupAddCommand", () => { mockParentCommand as any, ); + const expectedError = new DevkitError( + mocktFn(MISSING_REQUIRED_KEY, { + fields: "--description, --location", + }), + ); + expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - new DevkitError( - mocktFn(MISSING_REQUIRED_KEY, { - fields: "--description, --location", - }), - ), + expectedError, mockSpinner, ); expect(mockReadConfigSources).not.toHaveBeenCalled(); }); it("should handle an invalid language gracefully (pre-config load)", async () => { - const mockError = new DevkitError( - "error.language_config_not_found - keys: language, values: invalid-lang", - ); + const mockError = new DevkitError("Invalid language"); mockValidateProgrammingLanguage.mockImplementationOnce(() => { throw mockError; }); diff --git a/packages/devkit/__tests__/units/commands/config/index.spec.ts b/packages/devkit/__tests__/units/commands/config/index.spec.ts index 174c4e4..1b24e36 100644 --- a/packages/devkit/__tests__/units/commands/config/index.spec.ts +++ b/packages/devkit/__tests__/units/commands/config/index.spec.ts @@ -28,11 +28,11 @@ vi.mock("../../../../src/commands/config/add.js", () => ({ setupAddCommand: mockSetupAddCommand, })); -vi.mock("../../../../src/commands/config/remove.js", () => ({ +vi.mock("../../../../src/commands/config/remove/index.js", () => ({ setupRemoveCommand: mockSetupRemoveCommand, })); -vi.mock("../../../../src/commands/config/update.js", () => ({ +vi.mock("../../../../src/commands/config/update/index.js", () => ({ setupUpdateCommand: mockSetupUpdateCommand, })); diff --git a/packages/devkit/__tests__/units/commands/config/remove.spec.ts b/packages/devkit/__tests__/units/commands/config/remove.spec.ts deleted file mode 100644 index 679238c..0000000 --- a/packages/devkit/__tests__/units/commands/config/remove.spec.ts +++ /dev/null @@ -1,496 +0,0 @@ -import { vi, describe, it, expect, beforeEach } from "vitest"; -import { setupRemoveCommand } from "../../../../src/commands/config/remove.js"; -import { mockSpinner, mockLogger, mocktFn } from "../../../../vitest.setup.js"; -import { DevkitError } from "../../../../src/utils/errors/base.js"; -import type { - CliConfig, - ConfigurationSource, -} from "../../../../src/utils/schema/schema.js"; - -const { - mockHandleErrorAndExit, - mockReadConfigSources, - mockSaveGlobalConfig, - mockSaveLocalConfig, - mockValidateProgrammingLanguage, -} = vi.hoisted(() => ({ - mockHandleErrorAndExit: vi.fn(), - mockReadConfigSources: vi.fn(), - mockSaveGlobalConfig: vi.fn(), - mockSaveLocalConfig: vi.fn(), - mockValidateProgrammingLanguage: vi.fn(), -})); - -let actionFn: (...options: unknown[]) => Promise; - -vi.mock("#utils/errors/handler.js", () => ({ - handleErrorAndExit: mockHandleErrorAndExit, -})); - -vi.mock("#core/config/loader.js", () => ({ - readConfigSources: mockReadConfigSources, -})); - -vi.mock("#core/config/writer.js", () => ({ - saveGlobalConfig: mockSaveGlobalConfig, - saveLocalConfig: mockSaveLocalConfig, -})); - -vi.mock("#utils/validations/config.js", () => ({ - validateProgrammingLanguage: mockValidateProgrammingLanguage, -})); - -const CMD_DESCRIPTION_KEY = "commands.template.remove.command.description"; -const SUCCESS_REMOVED_KEY = "messages.success.template_removed"; -const WARNING_NOT_FOUND_KEY = "warnings.template.list_not_found"; -const ERROR_TEMPLATE_NOT_FOUND_KEY = "errors.template.not_found"; -const ERROR_LANG_NOT_FOUND_KEY = "errors.template.language_not_found"; -const ERROR_LOCAL_NOT_FOUND_KEY = "errors.config.local_not_found"; -const ERROR_GLOBAL_NOT_FOUND_KEY = "errors.config.global_not_found"; - -const defaultTemplateConfig: CliConfig["templates"] = { - javascript: { - templates: { - "vue-basic": { - description: "A basic Vue template", - location: "https://github.com/vuejs/vue", - alias: "vb", - }, - "react-basic": { - description: "A basic React template", - location: "https://github.com/facebook/react", - }, - "node-cli": { - description: "A Node.js CLI template", - location: "https://github.com/node/node-cli", - }, - }, - }, - typescript: { - templates: {}, - }, -}; - -const sampleConfig: CliConfig = { - settings: {} as CliConfig["settings"], - templates: defaultTemplateConfig, -}; - -const createMockSources = ( - targetType: ConfigurationSource, -): ReturnType => { - const local = targetType === "local" ? structuredClone(sampleConfig) : null; - const global = targetType === "global" ? structuredClone(sampleConfig) : null; - return Promise.resolve({ - local, - global, - default: structuredClone(sampleConfig), - configFound: targetType !== "default", - }); -}; - -describe("setupRemoveCommand", () => { - let mockConfigCommand: any; - const callAction = ( - language: string, - templateNames: string[], - isGlobal: boolean, - ) => { - const parentOpts = { global: isGlobal }; - return actionFn( - language, - templateNames, - {}, - { - parent: { - opts: vi.fn(() => parentOpts), - }, - }, - ); - }; - - beforeEach(() => { - vi.clearAllMocks(); - actionFn = vi.fn(); - mockConfigCommand = { - command: vi.fn(() => mockConfigCommand), - description: vi.fn(() => mockConfigCommand), - option: vi.fn(() => mockConfigCommand), - alias: vi.fn(() => mockConfigCommand), - action: vi.fn((fn) => { - actionFn = fn; - return mockConfigCommand; - }), - }; - mockValidateProgrammingLanguage.mockReturnValue(true); - }); - - it("should set up the remove command with correct options and arguments", () => { - setupRemoveCommand(mockConfigCommand); - expect(mockConfigCommand.command).toHaveBeenCalledWith( - "remove ", - ); - expect(mockConfigCommand.alias).toHaveBeenCalledWith("rm"); - expect(mockConfigCommand.description).toHaveBeenCalledWith( - mocktFn(CMD_DESCRIPTION_KEY), - ); - }); - - describe("action handler - Single and Multiple Named Removal", () => { - it("should remove a template by its name from local config", async () => { - mockReadConfigSources.mockImplementationOnce(() => - createMockSources("local"), - ); - - setupRemoveCommand(mockConfigCommand); - await callAction("javascript", ["vue-basic"], false); - - expect(mockReadConfigSources).toHaveBeenCalledWith({ - forceGlobal: false, - forceLocal: true, - }); - - expect(mockSaveLocalConfig).toHaveBeenCalledOnce(); - expect(mockSaveLocalConfig).toHaveBeenCalledWith( - expect.objectContaining({ - settings: { - ...defaultTemplateConfig.settings, - }, - templates: { - javascript: { - templates: { - ...defaultTemplateConfig.javascript?.templates, - "vue-basic": undefined, - }, - }, - typescript: { - ...defaultTemplateConfig.typescript, - }, - }, - }), - ); - - expect(mockSpinner.succeed).toHaveBeenCalledWith( - mocktFn(SUCCESS_REMOVED_KEY, { - count: "1", - templateName: "vue-basic", - language: "javascript", - }), - ); - }); - - it("should remove a template by its alias", async () => { - mockReadConfigSources.mockImplementation(() => - createMockSources("local"), - ); - - setupRemoveCommand(mockConfigCommand); - await callAction("javascript", ["vb"], false); - - expect(mockSaveLocalConfig).toHaveBeenCalledOnce(); - expect(mockSpinner.succeed).toHaveBeenCalledWith( - mocktFn(SUCCESS_REMOVED_KEY, { - count: "1", - templateName: "vue-basic", - language: "javascript", - }), - ); - }); - - it("should remove multiple templates at once (name and alias)", async () => { - mockReadConfigSources.mockImplementation(() => - createMockSources("local"), - ); - - setupRemoveCommand(mockConfigCommand); - await callAction("javascript", ["vue-basic", "react-basic", "vb"], false); - - expect(mockSaveLocalConfig).toHaveBeenCalledWith( - expect.objectContaining({ - settings: { - ...defaultTemplateConfig.settings, - }, - templates: { - javascript: { - templates: { - "node-cli": { - ...defaultTemplateConfig.javascript?.templates?.["node-cli"], - }, - }, - }, - typescript: { - ...defaultTemplateConfig.typescript, - }, - }, - }), - ); - - expect(mockSpinner.succeed).toHaveBeenCalledWith( - mocktFn(SUCCESS_REMOVED_KEY, { - count: "2", - templateName: "vue-basic, react-basic", - language: "javascript", - }), - ); - }); - - it("should remove from global config when isGlobal is true", async () => { - mockReadConfigSources.mockImplementation(() => - createMockSources("global"), - ); - - setupRemoveCommand(mockConfigCommand); - await callAction("javascript", ["vue-basic"], true); - - expect(mockReadConfigSources).toHaveBeenCalledWith({ - forceGlobal: true, - forceLocal: false, - }); - expect(mockSaveGlobalConfig).toHaveBeenCalledOnce(); - }); - }); - - describe("action handler - Wildcard and Mixed Removal", () => { - it("should remove ALL templates using the wildcard '*'", async () => { - mockReadConfigSources.mockImplementation(() => - createMockSources("local"), - ); - - setupRemoveCommand(mockConfigCommand); - await callAction("javascript", ["*"], false); - - expect(mockSaveLocalConfig).toHaveBeenCalledOnce(); - expect(mockSaveLocalConfig).toHaveBeenCalledWith( - expect.objectContaining({ - templates: { - javascript: { - templates: {}, - }, - typescript: { - templates: {}, - }, - }, - }), - ); - expect(mockSpinner.succeed).toHaveBeenCalledWith( - mocktFn(SUCCESS_REMOVED_KEY, { - count: "3", - templateName: "vue-basic, react-basic, node-cli", - language: "javascript", - }), - ); - expect(mockLogger.warning).not.toHaveBeenCalled(); - }); - - it("should remove ALL templates and warn about explicitly listed non-existent names (wildcard + extra)", async () => { - mockReadConfigSources.mockImplementation(() => - createMockSources("local"), - ); - - setupRemoveCommand(mockConfigCommand); - await callAction("javascript", ["*", "non-existent-A", "vb"], false); - - expect(mockSaveLocalConfig).toHaveBeenCalledOnce(); - expect(mockSaveLocalConfig).toHaveBeenCalledWith( - expect.objectContaining({ - settings: {}, - templates: { - javascript: { - templates: {}, - }, - typescript: { - templates: {}, - }, - }, - }), - ); - expect(mockSpinner.succeed).toHaveBeenCalledWith( - mocktFn(SUCCESS_REMOVED_KEY, { - count: "3", - templateName: "vue-basic, react-basic, node-cli", - language: "javascript", - }), - ); - expect(mockLogger.warning).toHaveBeenCalledWith( - mockLogger.colors.yellow( - mocktFn(WARNING_NOT_FOUND_KEY, { - templates: ["non-existent-A", "vb"].join(", "), - }), - ), - ); - }); - - it("should remove existing templates and warn about non-existent ones (no wildcard)", async () => { - mockReadConfigSources.mockImplementation(() => - createMockSources("local"), - ); - - setupRemoveCommand(mockConfigCommand); - await callAction("javascript", ["vue-basic", "non-existent"], false); - - expect(mockSaveLocalConfig).toHaveBeenCalledOnce(); - expect(mockSaveLocalConfig).toHaveBeenCalledWith( - expect.objectContaining({ - settings: {}, - templates: { - javascript: { - templates: { - "react-basic": { - ...defaultTemplateConfig.javascript?.templates?.[ - "react-basic" - ], - }, - "node-cli": { - ...defaultTemplateConfig.javascript?.templates?.["node-cli"], - }, - }, - }, - typescript: { - templates: {}, - }, - }, - }), - ); - expect(mockSpinner.succeed).toHaveBeenCalledWith( - mocktFn(SUCCESS_REMOVED_KEY, { - count: "1", - templateName: "vue-basic", - language: "javascript", - }), - ); - expect(mockLogger.warning).toHaveBeenCalledWith( - mockLogger.colors.yellow( - mocktFn(WARNING_NOT_FOUND_KEY, { - templates: "non-existent", - }), - ), - ); - }); - }); - - describe("action handler - Error and Edge Cases", () => { - const callAction = ( - language: string, - templateNames: string[], - isGlobal: boolean, - ) => { - const parentOpts = { global: isGlobal }; - return actionFn( - language, - templateNames, - {}, - { - parent: { - opts: vi.fn(() => parentOpts), - }, - }, - ); - }; - - it("should throw DevkitError if local config is not found (isGlobal=false)", async () => { - mockReadConfigSources.mockResolvedValue({ - local: null, - global: structuredClone(sampleConfig), - default: structuredClone(sampleConfig), - configFound: true, - }); - - setupRemoveCommand(mockConfigCommand); - await callAction("javascript", ["vue-basic"], false); - - expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - new DevkitError(mocktFn(ERROR_LOCAL_NOT_FOUND_KEY)), - mockSpinner, - ); - }); - - it("should throw DevkitError if global config is not found (isGlobal=true)", async () => { - mockReadConfigSources.mockResolvedValue({ - local: structuredClone(sampleConfig), - global: null, - default: structuredClone(sampleConfig), - configFound: true, - }); - - setupRemoveCommand(mockConfigCommand); - await callAction("javascript", ["vue-basic"], true); - - expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - new DevkitError(mocktFn(ERROR_GLOBAL_NOT_FOUND_KEY)), - mockSpinner, - ); - }); - - it("should throw an error if no templates are found for the given language", async () => { - const initialConfig = { - settings: {}, - templates: {}, - }; - mockReadConfigSources.mockResolvedValue({ - local: initialConfig, - global: null, - default: structuredClone(sampleConfig), - configFound: true, - }); - - setupRemoveCommand(mockConfigCommand); - await callAction("python", ["basic-script"], false); - - expect(mockHandleErrorAndExit).toHaveBeenCalledOnce(); - expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - new DevkitError( - mocktFn(ERROR_LANG_NOT_FOUND_KEY, { language: "python" }), - ), - mockSpinner, - ); - }); - - it("should throw an error if none of the provided template names exist (templatesToActOn.length === 0)", async () => { - mockReadConfigSources.mockImplementation(() => - createMockSources("local"), - ); - - setupRemoveCommand(mockConfigCommand); - await callAction("javascript", ["non-existent", "another-one"], false); - - expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - new DevkitError( - mocktFn(ERROR_TEMPLATE_NOT_FOUND_KEY, { - template: "non-existent, another-one", - }), - ), - mockSpinner, - ); - }); - - it("should handle unexpected errors gracefully", async () => { - const mockError = new Error("Config read failed"); - mockReadConfigSources.mockRejectedValue(mockError); - - setupRemoveCommand(mockConfigCommand); - await callAction("javascript", ["vue-basic"], false); - - expect(mockSpinner.start).toHaveBeenCalled(); - expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - mockError, - mockSpinner, - ); - }); - - it("should handle an invalid language gracefully", async () => { - const mockError = new DevkitError("Invalid language provided"); - mockValidateProgrammingLanguage.mockImplementation(() => { - throw mockError; - }); - - setupRemoveCommand(mockConfigCommand); - await callAction("invalid-lang", ["vue-basic"], false); - - expect(mockReadConfigSources).not.toHaveBeenCalled(); - expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - mockError, - mockSpinner, - ); - }); - }); -}); diff --git a/packages/devkit/__tests__/units/commands/config/remove/index.spec.ts b/packages/devkit/__tests__/units/commands/config/remove/index.spec.ts index 477c2a2..43f20ca 100644 --- a/packages/devkit/__tests__/units/commands/config/remove/index.spec.ts +++ b/packages/devkit/__tests__/units/commands/config/remove/index.spec.ts @@ -8,12 +8,17 @@ import { import { DevkitError } from "../../../../../src/utils/errors/base.js"; import type { CliConfig } from "../../../../../src/utils/schema/schema.js"; -const { mockHandleErrorAndExit, mockGetTemplateNamesToActOn, mockSaveConfig } = - vi.hoisted(() => ({ - mockHandleErrorAndExit: vi.fn(), - mockGetTemplateNamesToActOn: vi.fn(), - mockSaveConfig: vi.fn(), - })); +const { + mockHandleErrorAndExit, + mockGetTemplateNamesToActOn, + mockSaveConfig, + mockMapLanguageAliasToCanonicalKey, +} = vi.hoisted(() => ({ + mockHandleErrorAndExit: vi.fn(), + mockGetTemplateNamesToActOn: vi.fn(), + mockSaveConfig: vi.fn(), + mockMapLanguageAliasToCanonicalKey: vi.fn((lang) => lang), +})); let actionFn: (...options: unknown[]) => Promise; @@ -21,6 +26,10 @@ vi.mock("#utils/errors/handler.js", () => ({ handleErrorAndExit: mockHandleErrorAndExit, })); +vi.mock("#core/config/language.js", () => ({ + mapLanguageAliasToCanonicalKey: mockMapLanguageAliasToCanonicalKey, +})); + vi.mock("../../../../../src/commands/config/remove/logic.js", () => ({ getTemplateNamesToActOn: mockGetTemplateNamesToActOn, saveConfig: mockSaveConfig, @@ -92,6 +101,7 @@ describe("setupRemoveCommand (Command Handler)", () => { return mockConfigCommand; }), }; + mockMapLanguageAliasToCanonicalKey.mockImplementation((lang) => lang); }); it("should set up the remove command with correct options and arguments", () => { @@ -105,6 +115,46 @@ describe("setupRemoveCommand (Command Handler)", () => { ); }); + it("should map language alias ('js') to canonical key and pass it to logic", async () => { + const aliasLang = "js"; + const canonicalLang = "javascript"; + const templateToRemove = "vue-basic"; + + mockMapLanguageAliasToCanonicalKey.mockReturnValueOnce(canonicalLang); + mockGetTemplateNamesToActOn.mockResolvedValueOnce({ + targetConfig: structuredClone(MOCK_TARGET_CONFIG), + languageTemplates: structuredClone(MOCK_LANGUAGE_TEMPLATES), + templatesToActOn: [templateToRemove], + notFound: [], + }); + mockSaveConfig.mockResolvedValue(undefined); + + setupRemoveCommand(mockConfigCommand); + await callAction(aliasLang, [templateToRemove], false); + + expect(mockMapLanguageAliasToCanonicalKey).toHaveBeenCalledWith(aliasLang); + + expect(mockGetTemplateNamesToActOn).toHaveBeenCalledWith( + canonicalLang, + [templateToRemove], + false, + ); + + expect(mockSpinner.succeed).toHaveBeenCalledWith( + mocktFn(SUCCESS_REMOVED_KEY, { + count: "1", + templateName: templateToRemove, + language: canonicalLang, + }), + ); + + const savedConfig = mockSaveConfig.mock.calls[0]![0]; + expect( + savedConfig.templates[canonicalLang].templates[templateToRemove], + ).toBeUndefined(); + expect(savedConfig.templates[aliasLang]).toBeUndefined(); + }); + describe("action handler", () => { it("should remove one template and save to local config (success case)", async () => { const templateToRemove = "vue-basic"; @@ -170,7 +220,10 @@ describe("setupRemoveCommand (Command Handler)", () => { templates, true, ); - expect(mockSaveConfig).toHaveBeenCalledWith(expect.any(Object), true); + expect(mockSaveConfig).toHaveBeenCalledWith( + expect.objectContaining({ templates: expect.any(Object) }), + true, + ); const savedConfig = mockSaveConfig.mock.calls[0]![0]; expect(savedConfig.templates[language].templates).toEqual({}); @@ -245,12 +298,14 @@ describe("setupRemoveCommand (Command Handler)", () => { setupRemoveCommand(mockConfigCommand); await callAction("javascript", missingTemplates, false); + const expectedError = new DevkitError( + mocktFn(ERROR_TEMPLATE_NOT_FOUND_KEY, { + template: missingTemplates.join(", "), + }), + ); + expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - new DevkitError( - mocktFn(ERROR_TEMPLATE_NOT_FOUND_KEY, { - template: missingTemplates.join(", "), - }), - ), + expectedError, mockSpinner, ); expect(mockSaveConfig).not.toHaveBeenCalled(); diff --git a/packages/devkit/__tests__/units/commands/config/update/index.spec.ts b/packages/devkit/__tests__/units/commands/config/update/index.spec.ts index 067f91a..6c89a09 100644 --- a/packages/devkit/__tests__/units/commands/config/update/index.spec.ts +++ b/packages/devkit/__tests__/units/commands/config/update/index.spec.ts @@ -12,11 +12,13 @@ const { mockHandleNonInteractiveTemplateUpdate, mockResolveTemplateNamesForUpdate, mockValidateProgrammingLanguage, + mockMapLanguageAliasToCanonicalKey, } = vi.hoisted(() => ({ mockHandleErrorAndExit: vi.fn(), mockHandleNonInteractiveTemplateUpdate: vi.fn(), mockResolveTemplateNamesForUpdate: vi.fn(), mockValidateProgrammingLanguage: vi.fn(), + mockMapLanguageAliasToCanonicalKey: vi.fn((lang) => lang), })); let actionFn: (...options: unknown[]) => Promise; @@ -29,6 +31,10 @@ vi.mock("#utils/validations/config.js", () => ({ validateProgrammingLanguage: mockValidateProgrammingLanguage, })); +vi.mock("#core/config/language.js", () => ({ + mapLanguageAliasToCanonicalKey: mockMapLanguageAliasToCanonicalKey, +})); + vi.mock("../../../../../src/commands/config/logic.js", () => ({ handleNonInteractiveTemplateUpdate: mockHandleNonInteractiveTemplateUpdate, })); @@ -76,6 +82,7 @@ describe("setupUpdateCommand", () => { }; mockProcessExit.mockClear(); mockValidateProgrammingLanguage.mockReturnValue(true); + mockMapLanguageAliasToCanonicalKey.mockImplementation((lang) => lang); }); it("should set up the update command with correct options and arguments", () => { @@ -116,6 +123,53 @@ describe("setupUpdateCommand", () => { ); }); + it("should map a language alias ('ts') to its canonical key and use it for update logic", async () => { + const aliasLang = "ts"; + const canonicalLang = "typescript"; + const templateName = "my-template-actual"; + + mockMapLanguageAliasToCanonicalKey.mockReturnValueOnce(canonicalLang); + mockResolveTemplateNamesForUpdate.mockResolvedValueOnce({ + resolvedNames: [templateName], + notFoundNames: [], + }); + mockHandleNonInteractiveTemplateUpdate.mockResolvedValueOnce(undefined); + + const defaultCmdOptions = { + description: "Updated description", + location: "http://updated.com", + newName: "new-name", + global: false, + }; + const parentOpts = { parent: { opts: () => ({ global: false }) } }; + + setupUpdateCommand(mockConfigCommand); + await actionFn(aliasLang, [templateName], defaultCmdOptions, parentOpts); + + expect(mockMapLanguageAliasToCanonicalKey).toHaveBeenCalledOnce(); + expect(mockMapLanguageAliasToCanonicalKey).toHaveBeenCalledWith(aliasLang); + + expect(mockValidateProgrammingLanguage).toHaveBeenCalledOnce(); + expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith(canonicalLang); + expect(mockResolveTemplateNamesForUpdate).toHaveBeenCalledOnce(); + expect(mockResolveTemplateNamesForUpdate).toHaveBeenCalledWith( + canonicalLang, + [templateName], + false, + ); + + expect(mockHandleNonInteractiveTemplateUpdate).toHaveBeenCalledOnce(); + expect(mockHandleNonInteractiveTemplateUpdate).toHaveBeenCalledWith( + canonicalLang, + templateName, + { + ...defaultCmdOptions, + language: "ts", + }, + false, + ); + }); + describe("action handler - Success and Wildcard", () => { const defaultCmdOptions = { description: "Updated description", @@ -150,7 +204,6 @@ describe("setupUpdateCommand", () => { { ...defaultCmdOptions, language: "javascript", - isGlobal: false, }, false, ); @@ -184,13 +237,25 @@ describe("setupUpdateCommand", () => { expect(mockHandleNonInteractiveTemplateUpdate).toHaveBeenCalledWith( "javascript", "temp1", - expect.objectContaining({ language: "javascript", isGlobal: false }), + { + language: "javascript", + global: false, + description: "Updated description", + location: "http://updated.com", + newName: "new-name", + }, false, ); expect(mockHandleNonInteractiveTemplateUpdate).toHaveBeenCalledWith( "javascript", "temp2", - expect.objectContaining({ language: "javascript", isGlobal: false }), + { + language: "javascript", + global: false, + description: "Updated description", + location: "http://updated.com", + newName: "new-name", + }, false, ); @@ -255,6 +320,7 @@ describe("setupUpdateCommand", () => { mocktFn(SUCCESS_SUMMARY_KEY, { count: "2", templateName: "tempA, tempB", + language: "javascript", }), ), ), @@ -286,13 +352,13 @@ describe("setupUpdateCommand", () => { notFoundNames: [], }); + const devkitErrorInstance = new DevkitError( + mocktFn(TEMPLATE_NOT_FOUND_KEY, { template: "temp2" }), + ); + mockHandleNonInteractiveTemplateUpdate .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce( - new DevkitError( - mocktFn(TEMPLATE_NOT_FOUND_KEY, { template: "temp2" }), - ), - ) + .mockRejectedValueOnce(devkitErrorInstance) .mockResolvedValueOnce(undefined); setupUpdateCommand(mockConfigCommand); @@ -346,25 +412,28 @@ describe("setupUpdateCommand", () => { }); it("should throw an error if no templates are found to act on after resolution", async () => { + const missingTemplates = ["missing-1", "missing-2"]; mockResolveTemplateNamesForUpdate.mockResolvedValueOnce({ resolvedNames: [], - notFoundNames: ["missing-1", "missing-2"], + notFoundNames: missingTemplates, }); setupUpdateCommand(mockConfigCommand); await actionFn( "javascript", - ["missing-1", "missing-2"], + missingTemplates, defaultCmdOptions, parentOpts, ); + const expectedError = new DevkitError( + mocktFn(TEMPLATE_NOT_FOUND_KEY, { + template: missingTemplates.join(", "), + }), + ); + expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - new DevkitError( - mocktFn(TEMPLATE_NOT_FOUND_KEY, { - template: "missing-1, missing-2", - }), - ), + expectedError, mockSpinner, ); expect(mockHandleNonInteractiveTemplateUpdate).not.toHaveBeenCalled(); @@ -375,8 +444,10 @@ describe("setupUpdateCommand", () => { await actionFn("javascript", [], defaultCmdOptions, parentOpts); + const expectedError = new DevkitError(mocktFn(VALIDATION_REQUIRED_KEY)); + expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - new DevkitError(mocktFn(VALIDATION_REQUIRED_KEY)), + expectedError, mockSpinner, ); expect(mockResolveTemplateNamesForUpdate).not.toHaveBeenCalled(); @@ -384,9 +455,10 @@ describe("setupUpdateCommand", () => { }); it("should handle unexpected errors during template update gracefully", async () => { + const templateName = "my-template"; const mockError = new Error("Unexpected error"); mockResolveTemplateNamesForUpdate.mockResolvedValueOnce({ - resolvedNames: ["my-template"], + resolvedNames: [templateName], notFoundNames: [], }); mockHandleNonInteractiveTemplateUpdate.mockRejectedValueOnce(mockError); @@ -394,7 +466,7 @@ describe("setupUpdateCommand", () => { setupUpdateCommand(mockConfigCommand); await actionFn( "javascript", - ["my-template"], + [templateName], defaultCmdOptions, parentOpts, ); @@ -403,7 +475,7 @@ describe("setupUpdateCommand", () => { expect(consoleLogSpy).toHaveBeenCalledWith( mockLogger.colors.yellow( `\n${mocktFn(SINGLE_FAIL_KEY, { - templateName: "my-template", + templateName, error: "unknown error", })}`, ), diff --git a/packages/devkit/__tests__/units/commands/index.spec.ts b/packages/devkit/__tests__/units/commands/index.spec.ts index c0bd8c6..c8683a2 100644 --- a/packages/devkit/__tests__/units/commands/index.spec.ts +++ b/packages/devkit/__tests__/units/commands/index.spec.ts @@ -25,7 +25,7 @@ const { mockT: vi.fn((key) => key), })); -vi.mock("#commands/init.js", () => ({ +vi.mock("#commands/init/index.js", () => ({ setupInitCommand: mockSetupInitCommand, })); diff --git a/packages/devkit/__tests__/units/commands/init.spec.ts b/packages/devkit/__tests__/units/commands/init.spec.ts deleted file mode 100644 index da8f582..0000000 --- a/packages/devkit/__tests__/units/commands/init.spec.ts +++ /dev/null @@ -1,349 +0,0 @@ -import { vi, describe, it, expect, beforeEach } from "vitest"; -import { setupInitCommand } from "../../../src/commands/init.js"; -import { - CONFIG_FILE_NAMES, - defaultCliConfig, -} from "../../../src/utils/schema/schema.js"; -import { mockSpinner } from "../../../vitest.setup.js"; -import { ConfigError } from "../../../src/utils/errors/base.js"; -import path from "path"; -import os from "os"; - -const { - mockFs, - mockInquirerSelect, - mockSaveConfig, - mockHandleErrorAndExit, - mockFindUp, - mockFindMonorepoRoot, - mockFindProjectRoot, - mockFindGlobalConfigFile, - mockGetPackageManager, -} = vi.hoisted(() => ({ - mockFs: { - pathExists: vi.fn(), - }, - mockInquirerSelect: vi.fn(), - mockSaveConfig: vi.fn(), - mockHandleErrorAndExit: vi.fn(), - mockFindUp: vi.fn(), - mockFindMonorepoRoot: vi.fn(), - mockFindProjectRoot: vi.fn(), - mockFindGlobalConfigFile: vi.fn(), - mockGetPackageManager: vi.fn(), -})); - -let actionFn: (...options: unknown[]) => Promise; -vi.mock("os", async () => { - const actual = await vi.importActual("os"); - return { - ...actual, - homedir: vi.fn(() => "/home/user"), - }; -}); - -vi.mock("process", () => ({ - default: { - cwd: vi.fn(() => "/current/directory"), - }, -})); - -vi.mock("#utils/fs/file.js", () => ({ - default: { - pathExists: mockFs.pathExists, - }, -})); - -vi.mock("#utils/package-manager/index.js", () => ({ - getPackageManager: mockGetPackageManager, -})); - -vi.mock("@inquirer/prompts", () => ({ select: mockInquirerSelect })); - -vi.mock("#utils/errors/handler.js", () => ({ - handleErrorAndExit: mockHandleErrorAndExit, -})); - -vi.mock("#core/config/writer.js", () => ({ - saveConfig: mockSaveConfig, -})); - -vi.mock("#utils/fs/find-up.js", () => ({ - findUp: mockFindUp, -})); - -vi.mock("#utils/fs/finder.js", () => ({ - findMonorepoRoot: mockFindMonorepoRoot, - findProjectRoot: mockFindProjectRoot, -})); - -vi.mock("#core/config/search.js", () => ({ - findGlobalConfigFile: mockFindGlobalConfigFile, -})); - -const LOCAL_OPTION_KEY = "commands.config.init.option.local"; -const GLOBAL_OPTION_KEY = "commands.config.init.option.global"; -const CONFIRM_OVERWRITE_KEY = "commands.config.init.confirm_overwrite"; -const SUCCESS_KEY = "messages.success.config_initialized"; -const ABORTED_KEY = "commands.config.init.aborted"; -const YES_KEY = "common.yes"; -const NO_KEY = "common.no"; -const LOCAL_GLOBAL_ERROR_KEY = "errors.config.init_local_and_global"; - -describe("setupInitCommand", () => { - let mockProgram: any; - const localConfigFile = CONFIG_FILE_NAMES[1]; - const globalConfigFile = CONFIG_FILE_NAMES[0]; - const localConfigPath = `/current/directory/${localConfigFile}`; - const globalConfigPath = `/home/user/${globalConfigFile}`; - const monorepoRootPath = "/monorepo/root"; - const monorepoRootConfigPath = path.join(monorepoRootPath, localConfigFile); - const projectRootPath = "/project/root"; - const projectRootConfigPath = path.join(projectRootPath, localConfigFile); - - const mockDetectedConfig = { - ...defaultCliConfig, - settings: { ...defaultCliConfig.settings, defaultPackageManager: "npm" }, - }; - - beforeEach(() => { - vi.clearAllMocks(); - actionFn = vi.fn(); - mockProgram = { - command: vi.fn(() => mockProgram), - alias: vi.fn(() => mockProgram), - description: vi.fn(() => mockProgram), - option: vi.fn(() => mockProgram), - action: vi.fn((fn) => { - actionFn = fn; - return mockProgram; - }), - }; - vi.spyOn(process, "cwd").mockReturnValue("/current/directory"); - vi.spyOn(os, "homedir").mockReturnValue("/home/user"); - mockGetPackageManager.mockResolvedValue("npm"); - }); - - it("should set up the init command correctly", () => { - setupInitCommand({ program: mockProgram }); - expect(mockProgram.command).toHaveBeenCalledWith("init"); - expect(mockProgram.alias).toHaveBeenCalledWith("i"); - expect(mockProgram.option).toHaveBeenCalledWith( - "-l, --local", - LOCAL_OPTION_KEY, - false, - ); - expect(mockProgram.option).toHaveBeenCalledWith( - "-g, --global", - GLOBAL_OPTION_KEY, - false, - ); - }); - - describe("handleGlobalInit", () => { - it("should create a global config file when --global flag is set and no file exists", async () => { - mockFindGlobalConfigFile.mockResolvedValueOnce(null); - mockFs.pathExists.mockResolvedValueOnce(false); - setupInitCommand({ program: mockProgram }); - await actionFn({ local: false, global: true }); - - expect(mockFindGlobalConfigFile).toHaveBeenCalledOnce(); - expect(mockFs.pathExists).toHaveBeenCalledOnce(); - expect(mockFs.pathExists).toHaveBeenCalledWith(globalConfigPath); - expect(mockGetPackageManager).toHaveBeenCalledOnce(); - expect(mockSaveConfig).toHaveBeenCalledWith( - mockDetectedConfig, - globalConfigPath, - ); - expect(mockSpinner.succeed).toHaveBeenCalledWith(SUCCESS_KEY); - }); - - it("should default to homedir if findGlobalConfigFile returns null", async () => { - mockFindGlobalConfigFile.mockResolvedValueOnce(null); - mockFs.pathExists.mockResolvedValueOnce(false); - setupInitCommand({ program: mockProgram }); - await actionFn({ local: false, global: true }); - - expect(mockFindGlobalConfigFile).toHaveBeenCalledOnce(); - expect(mockFs.pathExists).toHaveBeenCalledOnce(); - expect(mockFs.pathExists).toHaveBeenCalledWith(globalConfigPath); - expect(mockGetPackageManager).toHaveBeenCalledOnce(); - expect(mockSaveConfig).toHaveBeenCalledWith( - mockDetectedConfig, - globalConfigPath, - ); - expect(mockSpinner.succeed).toHaveBeenCalledWith(SUCCESS_KEY); - }); - - it("should overwrite a global config file when --global flag is set and user confirms", async () => { - mockFindGlobalConfigFile.mockResolvedValueOnce(globalConfigPath); - mockFs.pathExists.mockResolvedValueOnce(true); - mockInquirerSelect.mockResolvedValueOnce(true); - setupInitCommand({ program: mockProgram }); - - await actionFn({ local: false, global: true }); - - expect(mockFs.pathExists).toHaveBeenCalledWith(globalConfigPath); - expect(mockInquirerSelect).toHaveBeenCalledWith({ - message: `${CONFIRM_OVERWRITE_KEY}- options path:${globalConfigPath}`, - choices: [ - { name: YES_KEY, value: true }, - { name: NO_KEY, value: false }, - ], - default: true, - }); - expect(mockGetPackageManager).toHaveBeenCalledOnce(); - expect(mockSaveConfig).toHaveBeenCalledWith( - mockDetectedConfig, - globalConfigPath, - ); - expect(mockSpinner.succeed).toHaveBeenCalledWith(SUCCESS_KEY); - }); - - it("should not overwrite a global config file when user cancels", async () => { - mockFindGlobalConfigFile.mockResolvedValueOnce(globalConfigPath); - mockFs.pathExists.mockResolvedValueOnce(true); - mockInquirerSelect.mockResolvedValueOnce(false); - setupInitCommand({ program: mockProgram }); - - await actionFn({ local: false, global: true }); - - expect(mockFs.pathExists).toHaveBeenCalledWith(globalConfigPath); - expect(mockGetPackageManager).not.toHaveBeenCalled(); - expect(mockSaveConfig).not.toHaveBeenCalled(); - expect(mockSpinner.info).toHaveBeenCalledWith(ABORTED_KEY); - }); - }); - - describe("handleLocalInit", () => { - it("should create a local config file in a non-monorepo project when no local config exists", async () => { - mockFindMonorepoRoot.mockResolvedValueOnce(null); - mockFindProjectRoot.mockResolvedValueOnce(null); - mockFindUp.mockResolvedValueOnce(null); - - setupInitCommand({ program: mockProgram }); - await actionFn({ local: true, global: false }); - - expect(mockFindMonorepoRoot).toHaveBeenCalled(); - expect(mockFindProjectRoot).toHaveBeenCalled(); - expect(mockFindUp).toHaveBeenCalledWith({ - files: CONFIG_FILE_NAMES, - cwd: "/current/directory", - limit: "/current/directory", - }); - expect(mockGetPackageManager).toHaveBeenCalledOnce(); - expect(mockSaveConfig).toHaveBeenCalledWith( - mockDetectedConfig, - localConfigPath, - ); - expect(mockSpinner.succeed).toHaveBeenCalledWith(SUCCESS_KEY); - }); - - it("should ask to overwrite a local config file if it already exists", async () => { - mockFindMonorepoRoot.mockResolvedValueOnce(null); - mockFindProjectRoot.mockResolvedValueOnce(null); - mockFindUp.mockResolvedValueOnce(localConfigPath); - mockInquirerSelect.mockResolvedValueOnce(true); - - setupInitCommand({ program: mockProgram }); - await actionFn({ local: true, global: false }); - - expect(mockFindMonorepoRoot).toHaveBeenCalledOnce(); - expect(mockFindProjectRoot).toHaveBeenCalledOnce(); - expect(mockFindUp).toHaveBeenCalledWith({ - files: CONFIG_FILE_NAMES, - cwd: "/current/directory", - limit: "/current/directory", - }); - expect(mockInquirerSelect).toHaveBeenCalledWith({ - message: `${CONFIRM_OVERWRITE_KEY}- options path:${localConfigPath}`, - choices: [ - { name: YES_KEY, value: true }, - { name: NO_KEY, value: false }, - ], - default: true, - }); - expect(mockGetPackageManager).toHaveBeenCalledOnce(); - expect(mockSaveConfig).toHaveBeenCalledWith( - mockDetectedConfig, - localConfigPath, - ); - expect(mockSpinner.succeed).toHaveBeenCalledWith(SUCCESS_KEY); - }); - - it("should use the monorepo root as the limit and overwrite an existing config there", async () => { - mockFindMonorepoRoot.mockResolvedValueOnce(monorepoRootPath); - mockFindProjectRoot.mockResolvedValueOnce(null); - mockFindUp.mockResolvedValueOnce(monorepoRootConfigPath); - mockInquirerSelect.mockResolvedValueOnce(true); - - setupInitCommand({ program: mockProgram }); - await actionFn({ local: true, global: false }); - - expect(mockFindMonorepoRoot).toHaveBeenCalledOnce(); - expect(mockFindProjectRoot).toHaveBeenCalledOnce(); - expect(mockFindUp).toHaveBeenCalledWith({ - files: CONFIG_FILE_NAMES, - cwd: monorepoRootPath, - limit: monorepoRootPath, - }); - expect(mockInquirerSelect).toHaveBeenCalledWith({ - message: `${CONFIRM_OVERWRITE_KEY}- options path:${monorepoRootConfigPath}`, - choices: [ - { name: YES_KEY, value: true }, - { name: NO_KEY, value: false }, - ], - default: true, - }); - expect(mockGetPackageManager).toHaveBeenCalledOnce(); - expect(mockSaveConfig).toHaveBeenCalledWith( - mockDetectedConfig, - monorepoRootConfigPath, - ); - expect(mockSpinner.succeed).toHaveBeenCalledWith(SUCCESS_KEY); - }); - - it("should use the project root as the limit and overwrite an existing config there", async () => { - mockFindMonorepoRoot.mockResolvedValueOnce(null); - mockFindProjectRoot.mockResolvedValueOnce(projectRootPath); - mockFindUp.mockResolvedValueOnce(projectRootConfigPath); - mockInquirerSelect.mockResolvedValueOnce(true); - - setupInitCommand({ program: mockProgram }); - await actionFn({ local: true, global: false }); - - expect(mockFindMonorepoRoot).toHaveBeenCalledOnce(); - expect(mockFindProjectRoot).toHaveBeenCalledOnce(); - expect(mockFindUp).toHaveBeenCalledWith({ - files: CONFIG_FILE_NAMES, - cwd: projectRootPath, - limit: projectRootPath, - }); - expect(mockInquirerSelect).toHaveBeenCalledWith({ - message: `${CONFIRM_OVERWRITE_KEY}- options path:${projectRootConfigPath}`, - choices: [ - { name: YES_KEY, value: true }, - { name: NO_KEY, value: false }, - ], - default: true, - }); - expect(mockGetPackageManager).toHaveBeenCalledOnce(); - expect(mockSaveConfig).toHaveBeenCalledWith( - mockDetectedConfig, - projectRootConfigPath, - ); - expect(mockSpinner.succeed).toHaveBeenCalledWith(SUCCESS_KEY); - }); - }); - - it("should throw a ConfigError when both --local and --global flags are used", async () => { - setupInitCommand({ program: mockProgram }); - await actionFn({ local: true, global: true }); - expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - new ConfigError(LOCAL_GLOBAL_ERROR_KEY), - mockSpinner, - ); - expect(mockSaveConfig).not.toHaveBeenCalled(); - expect(mockGetPackageManager).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/devkit/__tests__/units/commands/list.spec.ts b/packages/devkit/__tests__/units/commands/list.spec.ts index 5d5fd95..e38475b 100644 --- a/packages/devkit/__tests__/units/commands/list.spec.ts +++ b/packages/devkit/__tests__/units/commands/list.spec.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { setupListCommand } from "../../../src/commands/list.js"; import { DevkitError } from "../../../src/utils/errors/base.js"; import type { CliConfig } from "../../../src/utils/schema/schema.js"; -import { mockSpinner, mockLogger } from "../../../vitest.setup.js"; +import { mockSpinner, mockLogger, mocktFn } from "../../../vitest.setup.js"; type AnnotatedTemplate = { _language: string; @@ -28,6 +28,13 @@ const MOCK_ANNOTATED_TEMPLATES: AnnotatedTemplate[] = [ location: "/path/to/global/templates/typescript-express", packageManager: "yarn", }, + { + _language: "nodejs", + name: "node-api", + description: "Generic Node API project", + location: "/path/to/global/templates/node-api", + packageManager: "pnpm", + }, ]; const MOCK_CLI_CONFIG_WITH_SETTINGS: CliConfig = { @@ -36,7 +43,11 @@ const MOCK_CLI_CONFIG_WITH_SETTINGS: CliConfig = { cacheStrategy: "daily" as const, language: "en" as const, }, - templates: {}, + templates: { + javascript: { templates: {} }, + typescript: { templates: {} }, + nodejs: { templates: {} }, + }, }; const { @@ -47,6 +58,7 @@ const { mockValidateProgrammingLanguage, mockValidateDisplayMode, mockHandleErrorAndExit, + mockMapLanguageAliasToCanonicalKey, } = vi.hoisted(() => { return { mockGetAnnotatedTemplates: vi.fn(), @@ -56,6 +68,7 @@ const { mockValidateProgrammingLanguage: vi.fn(), mockHandleErrorAndExit: vi.fn(), mockValidateDisplayMode: vi.fn(), + mockMapLanguageAliasToCanonicalKey: vi.fn((lang) => lang), }; }); @@ -94,6 +107,10 @@ vi.mock("#utils/errors/handler.js", () => ({ handleErrorAndExit: mockHandleErrorAndExit, })); +vi.mock("#core/config/language.js", () => ({ + mapLanguageAliasToCanonicalKey: mockMapLanguageAliasToCanonicalKey, +})); + const CMD_DESCRIPTION_KEY = "commands.list.command.description"; const LANG_ARGUMENT_KEY = "commands.list.command.language.argument"; const GLOBAL_OPTION_KEY = "commands.list.options.global"; @@ -105,13 +122,16 @@ const MODE_OPTION_KEY = "commands.list.command.mode.option"; const HEADER_KEY = "commands.list.output.header"; const SETTINGS_HEADER_KEY = "commands.list.output.settings_header"; const MUTUALLY_EXCLUSIVE_KEY = "errors.command.mutually_exclusive_options"; +const SUCCESS_CONFIG_LOADED_KEY = "messages.success.config_loaded"; +const WARNING_TEMPLATE_NOT_FOUND_KEY = + "warnings.template.not_found_for_language"; describe("list command", () => { beforeEach(() => { vi.clearAllMocks(); - mockGetAnnotatedTemplates.mockResolvedValueOnce(MOCK_ANNOTATED_TEMPLATES); - mockGetMergedConfig.mockResolvedValueOnce(MOCK_CLI_CONFIG_WITH_SETTINGS); - mockValidateProgrammingLanguage.mockReturnValueOnce(true); + mockGetAnnotatedTemplates.mockResolvedValue(MOCK_ANNOTATED_TEMPLATES); + mockGetMergedConfig.mockResolvedValue(MOCK_CLI_CONFIG_WITH_SETTINGS); + mockMapLanguageAliasToCanonicalKey.mockImplementation((lang) => lang); }); it("should define the list command correctly with new options", () => { @@ -172,12 +192,39 @@ describe("list command", () => { "tree", ); + expect(mockSpinner.succeed).toHaveBeenCalledWith( + expect.stringContaining(SUCCESS_CONFIG_LOADED_KEY), + ); + expect(mockLogger.log).toHaveBeenCalled(); expect(mockLogger.log).toHaveBeenCalledWith( expect.stringContaining(`\n${HEADER_KEY}`), ); }); + it("should map language alias (e.g., 'ts') to canonical key and filter correctly", async () => { + setupListCommand({ program: mockProgram }); + const alias = "ts"; + const canonical = "typescript"; + + mockMapLanguageAliasToCanonicalKey.mockReturnValue(canonical); + + await actionFn(alias, { mode: "tree" }); + + expect(mockMapLanguageAliasToCanonicalKey).toHaveBeenCalledWith(alias); + expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith(canonical); + + const expectedTemplates = MOCK_ANNOTATED_TEMPLATES.filter( + (t) => t._language === canonical, + ); + + expect(mockPrintTemplates).toHaveBeenCalledWith( + expectedTemplates, + [], + "tree", + ); + }); + it("should pass mergeAll: true to annotator with --all flag", async () => { setupListCommand({ program: mockProgram }); await actionFn("", { all: true, mode: "table" }); @@ -211,8 +258,12 @@ describe("list command", () => { expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith("javascript"); + const expectedTemplates = MOCK_ANNOTATED_TEMPLATES.filter( + (t) => t._language === "javascript", + ); + expect(mockPrintTemplates).toHaveBeenCalledWith( - [MOCK_ANNOTATED_TEMPLATES[0]], + expectedTemplates, [], "tree", ); @@ -230,12 +281,13 @@ describe("list command", () => { ); }); - it("should print settings when --settings is used", async () => { + it("should print settings when --settings is used (and call config merger)", async () => { setupListCommand({ program: mockProgram }); await actionFn("", { settings: true, mode: "tree" }); expect(mockGetMergedConfig).toHaveBeenCalledOnce(); expect(mockGetMergedConfig).toHaveBeenCalledWith(false); + expect(mockPrintSettings).toHaveBeenCalledWith( MOCK_CLI_CONFIG_WITH_SETTINGS.settings, ); @@ -247,6 +299,13 @@ describe("list command", () => { expect(mockPrintTemplates).toHaveBeenCalledTimes(1); }); + it("should call getMergedConfig(true) when --settings and --all are used", async () => { + setupListCommand({ program: mockProgram }); + await actionFn("", { settings: true, all: true, mode: "tree" }); + + expect(mockGetMergedConfig).toHaveBeenCalledWith(true); + }); + it("should pass includeDefaults: true to annotator with --include-defaults flag", async () => { setupListCommand({ program: mockProgram }); await actionFn("", { includeDefaults: true, mode: "tree" }); @@ -260,26 +319,36 @@ describe("list command", () => { it("should throw a DevkitError if both --global and --all flags are used", async () => { setupListCommand({ program: mockProgram }); + + const expectedErrorMessage = mocktFn(MUTUALLY_EXCLUSIVE_KEY, { + options: "global, all", + }); + const expectedError = new DevkitError(expectedErrorMessage); + await actionFn("", { global: true, all: true, mode: "table" }); expect(mockGetAnnotatedTemplates).not.toHaveBeenCalled(); expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - new DevkitError(`${MUTUALLY_EXCLUSIVE_KEY}- options options:global, all`), - expect.any(Object), + expectedError, + mockSpinner, ); }); - it("should display a message if no templates are found (after language filter)", async () => { + it("should show a warning message if no templates are found after language filter", async () => { + mockGetAnnotatedTemplates.mockResolvedValue([]); + setupListCommand({ program: mockProgram }); + const language = "nonexistent"; - await actionFn("nonexistent", { mode: "tree" }); + await actionFn(language, { mode: "tree" }); - expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith("nonexistent"); + expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith(language); - expect(mockSpinner.succeed).toHaveBeenCalledOnce(); - expect(mockSpinner.succeed).toHaveBeenCalledWith( - "messages.success.config_loaded", + expect(mockSpinner.warn).toHaveBeenCalledWith( + expect.stringContaining( + mocktFn(WARNING_TEMPLATE_NOT_FOUND_KEY, { language }), + ), ); expect(mockPrintTemplates).not.toHaveBeenCalled(); @@ -301,9 +370,6 @@ describe("list command", () => { expect(mockValidateDisplayMode).toHaveBeenCalledWith("folder"); expect(mockHandleErrorAndExit).toHaveBeenCalledOnce(); - expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - modeError, - expect.any(Object), - ); + expect(mockHandleErrorAndExit).toHaveBeenCalledWith(modeError, mockSpinner); }); }); diff --git a/packages/devkit/__tests__/units/commands/new.spec.ts b/packages/devkit/__tests__/units/commands/new.spec.ts index 1927c06..4a39b91 100644 --- a/packages/devkit/__tests__/units/commands/new.spec.ts +++ b/packages/devkit/__tests__/units/commands/new.spec.ts @@ -9,11 +9,13 @@ const { mockScaffoldProject, mockValidateProgrammingLanguage, mockGetMergedConfig, + mockMapLanguageAliasToCanonicalKey, } = vi.hoisted(() => ({ mockHandleErrorAndExit: vi.fn(), mockScaffoldProject: vi.fn(), mockValidateProgrammingLanguage: vi.fn(), mockGetMergedConfig: vi.fn(), + mockMapLanguageAliasToCanonicalKey: vi.fn((lang) => lang), })); let actionFn: (...options: unknown[]) => Promise; @@ -34,7 +36,10 @@ vi.mock("#utils/validations/config.js", () => ({ validateProgrammingLanguage: mockValidateProgrammingLanguage, })); -const LANGUAGE_NOT_FOUND_KEY = "errors.scaffolding.language_not_found"; +vi.mock("#core/config/language.js", () => ({ + mapLanguageAliasToCanonicalKey: mockMapLanguageAliasToCanonicalKey, +})); + const TEMPLATE_NOT_FOUND_KEY = "errors.template.not_found"; const NEW_PROJECT_SUCCESS_KEY = "messages.success.new_project"; const CMD_DESCRIPTION_KEY = "commands.new.command.description"; @@ -70,6 +75,15 @@ describe("setupNewCommand", () => { }, }, }, + nodejs: { + templates: { + "node-api": { + description: "Node.js API template", + location: "https://github.com/node-api", + alias: "node", + }, + }, + }, }, settings: { defaultPackageManager: "npm", @@ -82,6 +96,7 @@ describe("setupNewCommand", () => { vi.clearAllMocks(); actionFn = vi.fn(); mockGetMergedConfig.mockResolvedValue(sampleConfig); + mockMapLanguageAliasToCanonicalKey.mockImplementation((lang) => lang); mockProgram = { command: vi.fn(() => mockProgram), @@ -144,6 +159,35 @@ describe("setupNewCommand", () => { expect(mockHandleErrorAndExit).not.toHaveBeenCalled(); }); + it("should map a language alias (e.g., 'ts') to its canonical key and scaffold", async () => { + setupNewCommand({ program: mockProgram }); + const aliasLanguage = "ts"; + const canonicalLanguage = "typescript"; + const projectName = "ts-project"; + const templateName = "ts-node"; + const templateConfig = + sampleConfig?.templates?.typescript?.templates[templateName]; + const cmdOptions = { template: templateName }; + + mockMapLanguageAliasToCanonicalKey.mockReturnValue(canonicalLanguage); + + await actionFn(aliasLanguage, projectName, cmdOptions); + + expect(mockMapLanguageAliasToCanonicalKey).toHaveBeenCalledWith( + aliasLanguage, + ); + expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith( + canonicalLanguage, + ); + expect(mockGetMergedConfig).toHaveBeenCalledWith(true); + expect(mockScaffoldProject).toHaveBeenCalledWith({ + projectName, + templateConfig, + packageManager: sampleConfig.settings.defaultPackageManager, + cacheStrategy: sampleConfig.settings.cacheStrategy, + }); + }); + it("should scaffold a project using a template alias and global default settings", async () => { setupNewCommand({ program: mockProgram }); const language = "javascript"; @@ -168,28 +212,35 @@ describe("setupNewCommand", () => { expect(mockHandleErrorAndExit).not.toHaveBeenCalled(); }); - it("should throw a DevkitError if the language config is not found in the merged config", async () => { - mockGetMergedConfig.mockResolvedValue(sampleConfig); - + it("should throw a DevkitError if the language is not valid (python)", async () => { setupNewCommand({ program: mockProgram }); const language = "python"; const projectName = "my-python-project"; const cmdOptions = { template: "my-template" }; - const expectedErrorMessage = mocktFn(LANGUAGE_NOT_FOUND_KEY, { - language: "python", + const expectedError = new DevkitError("Invalid language"); + mockMapLanguageAliasToCanonicalKey.mockReturnValue(language); + + mockValidateProgrammingLanguage.mockImplementation(() => { + throw expectedError; }); await actionFn(language, projectName, cmdOptions); + expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith(language); + expect(mockGetMergedConfig).not.toHaveBeenCalled(); + expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - new DevkitError(expectedErrorMessage), - expect.any(Object), + expectedError, + mockSpinner, ); expect(mockScaffoldProject).not.toHaveBeenCalled(); }); it("should throw a DevkitError if the specified template is not found by name or alias", async () => { + vi.restoreAllMocks(); + mockGetMergedConfig.mockClear(); + mockGetMergedConfig.mockResolvedValueOnce(sampleConfig); setupNewCommand({ program: mockProgram }); const language = "javascript"; const projectName = "my-project"; @@ -199,12 +250,13 @@ describe("setupNewCommand", () => { const expectedErrorMessage = mocktFn(TEMPLATE_NOT_FOUND_KEY, { template: templateName, }); + const expectedError = new DevkitError(expectedErrorMessage); await actionFn(language, projectName, cmdOptions); expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - new DevkitError(expectedErrorMessage), - expect.any(Object), + expectedError, + mockSpinner, ); expect(mockScaffoldProject).not.toHaveBeenCalled(); }); @@ -220,15 +272,13 @@ describe("setupNewCommand", () => { await actionFn(language, projectName, cmdOptions); - expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - mockError, - expect.any(Object), - ); + expect(mockHandleErrorAndExit).toHaveBeenCalledWith(mockError, mockSpinner); }); it("should throw a DevkitError if the language is not valid (before config lookup)", async () => { + const expectedError = new DevkitError("Invalid language"); mockValidateProgrammingLanguage.mockImplementation(() => { - throw new DevkitError("Invalid language"); + throw expectedError; }); setupNewCommand({ program: mockProgram }); @@ -240,8 +290,8 @@ describe("setupNewCommand", () => { expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith(language); expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - expect.any(DevkitError), - expect.any(Object), + expectedError, + mockSpinner, ); expect(mockGetMergedConfig).not.toHaveBeenCalled(); }); diff --git a/packages/devkit/__tests__/units/core/config/language.spec.ts b/packages/devkit/__tests__/units/core/config/language.spec.ts new file mode 100644 index 0000000..61931cf --- /dev/null +++ b/packages/devkit/__tests__/units/core/config/language.spec.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { ProgrammingLanguageAlias } from "../../../../src/utils/schema/schema.js"; +import { mapLanguageAliasToCanonicalKey } from "../../../../src/core/config/language.js"; + +const aliases: Record = ProgrammingLanguageAlias as Record< + string, + string +>; + +describe("mapLanguageAliasToCanonicalKey", () => { + it("should map all short aliases (js, ts, node) to their canonical keys", () => { + expect(mapLanguageAliasToCanonicalKey("js")).toBe(aliases.js); + expect(mapLanguageAliasToCanonicalKey("ts")).toBe(aliases.ts); + expect(mapLanguageAliasToCanonicalKey("node")).toBe(aliases.node); + }); + + it("should return the canonical key when the input is already in canonical form (lowercase)", () => { + expect(mapLanguageAliasToCanonicalKey("javascript")).toBe("javascript"); + expect(mapLanguageAliasToCanonicalKey("typescript")).toBe("typescript"); + expect(mapLanguageAliasToCanonicalKey("nodejs")).toBe("nodejs"); + }); + + it("should return the canonical key in lowercase regardless of input casing (e.g., TS, JavaScript)", () => { + expect(mapLanguageAliasToCanonicalKey("TS")).toBe("typescript"); + expect(mapLanguageAliasToCanonicalKey("jS")).toBe("javascript"); + expect(mapLanguageAliasToCanonicalKey("NoDe")).toBe("nodejs"); + + expect(mapLanguageAliasToCanonicalKey("JavaScript")).toBe("javascript"); + expect(mapLanguageAliasToCanonicalKey("TYPEscript")).toBe("typescript"); + expect(mapLanguageAliasToCanonicalKey("NodeJS")).toBe("nodejs"); + }); + + it("should return the lowercase input when the input is not a recognized alias or canonical key", () => { + expect(mapLanguageAliasToCanonicalKey("python")).toBe("python"); + expect(mapLanguageAliasToCanonicalKey("UnknownLang")).toBe("unknownlang"); + expect(mapLanguageAliasToCanonicalKey("")).toBe(""); + }); +}); diff --git a/packages/devkit/src/commands/config/add.ts b/packages/devkit/src/commands/config/add.ts index b2e94bd..edf3e35 100644 --- a/packages/devkit/src/commands/config/add.ts +++ b/packages/devkit/src/commands/config/add.ts @@ -8,6 +8,7 @@ import { type Command } from "commander"; import { type AddCommandOptions, type AddTemplateSchema } from "./types.js"; import { validateProgrammingLanguage } from "#utils/validations/config.js"; import type { CliConfig } from "#utils/schema/schema.js"; +import { mapLanguageAliasToCanonicalKey } from "#core/config/language.js"; async function getTargetConfigForModification( isGlobal: boolean, @@ -85,6 +86,7 @@ export function setupAddCommand(configCommand: Command): void { ); } + language = mapLanguageAliasToCanonicalKey(language); validateProgrammingLanguage(language); const config = await getTargetConfigForModification(isGlobal); diff --git a/packages/devkit/src/commands/config/index.ts b/packages/devkit/src/commands/config/index.ts index 8a003b5..5a7b919 100644 --- a/packages/devkit/src/commands/config/index.ts +++ b/packages/devkit/src/commands/config/index.ts @@ -4,8 +4,8 @@ import { type Command } from "commander"; import { logger, type TSpinner } from "#utils/logger.js"; import { setupAddCommand } from "./add.js"; -import { setupRemoveCommand } from "./remove.js"; -import { setupUpdateCommand } from "./update.js"; +import { setupRemoveCommand } from "./remove/index.js"; +import { setupUpdateCommand } from "./update/index.js"; import { setupListCommand } from "./list.js"; import { handleSetAction } from "./set/index.js"; import { handleGetAction } from "./get/index.js"; diff --git a/packages/devkit/src/commands/config/remove.ts b/packages/devkit/src/commands/config/remove.ts deleted file mode 100644 index c7cd11e..0000000 --- a/packages/devkit/src/commands/config/remove.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { t } from "#utils/i18n/translator.js"; -import { DevkitError } from "#utils/errors/base.js"; -import { handleErrorAndExit } from "#utils/errors/handler.js"; -import { logger, type TSpinner } from "#utils/logger.js"; -import { readConfigSources } from "#core/config/loader.js"; -import { saveGlobalConfig, saveLocalConfig } from "#core/config/writer.js"; -import { type Command } from "commander"; -import { type CliConfig, type TemplateConfig } from "#utils/schema/schema.js"; -import { type RemoveCommandOptions } from "./types.js"; -import { validateProgrammingLanguage } from "#utils/validations/config.js"; - -async function saveConfig( - targetConfig: CliConfig, - isGlobal: boolean, -): Promise { - if (isGlobal) { - await saveGlobalConfig(targetConfig); - } else { - await saveLocalConfig(targetConfig); - } -} - -async function getTargetConfigForModification( - isGlobal: boolean, -): Promise { - const sources = await readConfigSources({ - forceGlobal: isGlobal, - forceLocal: !isGlobal, - }); - - const targetConfig = isGlobal ? sources.global : sources.local; - - if (!targetConfig) { - if (isGlobal) { - throw new DevkitError(t("errors.config.global_not_found")); - } else { - throw new DevkitError(t("errors.config.local_not_found")); - } - } - - return targetConfig; -} - -function resolveTemplateNames( - templateNames: string[], - templatesMap: Record, -): { templatesToActOn: string[]; notFound: string[] } { - const actualTemplateNames = Object.values(templatesMap); - const uniqueActualTemplateNames = [...new Set(actualTemplateNames)]; - - if (templateNames.includes("*")) { - return { - templatesToActOn: uniqueActualTemplateNames, - notFound: - templateNames.length > 1 - ? templateNames.filter((name) => name !== "*") - : [], - }; - } - - const templatesToActOn: string[] = []; - const notFound: string[] = []; - - for (const name of templateNames) { - const actualName = templatesMap[name]; - if (actualName) { - if (!templatesToActOn.includes(actualName)) { - templatesToActOn.push(actualName); - } - } else { - notFound.push(name); - } - } - - return { templatesToActOn, notFound }; -} - -async function getTemplateNamesToActOn( - language: string, - templateNames: string[], - isGlobal: boolean, -): Promise<{ - targetConfig: CliConfig; - languageTemplates: Record; - templatesToActOn: string[]; - notFound: string[]; -}> { - validateProgrammingLanguage(language); - - const targetConfig = await getTargetConfigForModification(isGlobal); - - const languageTemplates = targetConfig?.templates?.[language]?.templates; - - if (!languageTemplates) { - throw new DevkitError( - t("errors.template.language_not_found", { language: language }), - ); - } - - const templatesMap = Object.entries(languageTemplates).reduce( - (acc, [name, template]) => { - acc[name] = name; - if (template?.alias) { - acc[template.alias] = name; - } - return acc; - }, - {} as Record, - ); - - const { templatesToActOn, notFound } = resolveTemplateNames( - templateNames, - templatesMap, - ); - - return { targetConfig, languageTemplates, templatesToActOn, notFound }; -} - -export function setupRemoveCommand(configCommand: Command): void { - configCommand - .command("remove ") - .alias("rm") - .description(t("commands.template.remove.command.description")) - .action( - async ( - language: string, - templateNames: string[], - _: RemoveCommandOptions, - childCommand: Command, - ) => { - const parentOpts = childCommand?.parent?.opts(); - const isGlobal = !!parentOpts?.global; - - const spinner: TSpinner = logger - .spinner() - .start(logger.colors.cyan(t("messages.status.template_removing"))); - - try { - if (templateNames.length === 0) { - throw new DevkitError( - t("errors.validation.template_name_required"), - ); - } - - const { - targetConfig, - languageTemplates, - templatesToActOn, - notFound, - } = await getTemplateNamesToActOn(language, templateNames, isGlobal); - - if (templatesToActOn.length === 0) { - throw new DevkitError( - t("errors.template.not_found", { - template: notFound.join(", "), - }), - ); - } - - const templatesToKeep = Object.fromEntries( - Object.entries(languageTemplates).filter( - ([key]) => !templatesToActOn.includes(key), - ), - ); - - targetConfig.templates[language].templates = templatesToKeep; - - await saveConfig(targetConfig, !!isGlobal); - - spinner.succeed( - t("messages.success.template_removed", { - count: templatesToActOn.length.toString(), - templateName: templatesToActOn.join(", "), - language, - }), - ); - - if (notFound.length > 0) { - logger.warning( - logger.colors.yellow( - t("warnings.template.list_not_found", { - templates: notFound.join(", "), - }), - ), - ); - } - } catch (error: unknown) { - handleErrorAndExit(error as Error, spinner); - } - }, - ); -} diff --git a/packages/devkit/src/commands/config/remove/index.ts b/packages/devkit/src/commands/config/remove/index.ts index 1b097d8..392b8cc 100644 --- a/packages/devkit/src/commands/config/remove/index.ts +++ b/packages/devkit/src/commands/config/remove/index.ts @@ -5,6 +5,7 @@ import { logger, type TSpinner } from "#utils/logger.js"; import { type Command } from "commander"; import { type RemoveCommandOptions } from "../types.js"; import { getTemplateNamesToActOn, saveConfig } from "./logic.js"; +import { mapLanguageAliasToCanonicalKey } from "#core/config/language.js"; export function setupRemoveCommand(configCommand: Command): void { configCommand @@ -32,6 +33,7 @@ export function setupRemoveCommand(configCommand: Command): void { ); } + language = mapLanguageAliasToCanonicalKey(language); const { targetConfig, languageTemplates, diff --git a/packages/devkit/src/commands/config/types.ts b/packages/devkit/src/commands/config/types.ts index 05e8d2e..36f8f0e 100644 --- a/packages/devkit/src/commands/config/types.ts +++ b/packages/devkit/src/commands/config/types.ts @@ -1,7 +1,7 @@ import { type CacheStrategy, type SupportedPackageManager, - type SupportedProgrammingLanguageValues, + type SupportedProgrammingLanguageKeys, } from "#utils/schema/schema.js"; export type ConfigCommandOptions = { @@ -22,7 +22,7 @@ export type RemoveCommandOptions = { }; export type AddTemplateSchema = { - language: SupportedProgrammingLanguageValues; + language: SupportedProgrammingLanguageKeys; templateName: string; description: string; location: string; diff --git a/packages/devkit/src/commands/config/update.ts b/packages/devkit/src/commands/config/update.ts deleted file mode 100644 index f2771c5..0000000 --- a/packages/devkit/src/commands/config/update.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { t } from "#utils/i18n/translator.js"; -import { logger, type TSpinner } from "#utils/logger.js"; -import { handleErrorAndExit } from "#utils/errors/handler.js"; -import { handleNonInteractiveTemplateUpdate } from "./logic.js"; -import { type Command } from "commander"; -import { type UpdateCommandOptions } from "./types.js"; -import { DevkitError } from "#utils/errors/base.js"; -import { readConfigSources } from "#core/config/loader.js"; -import type { TemplateConfig } from "#utils/schema/schema.js"; -import { validateProgrammingLanguage } from "#utils/validations/config.js"; - -async function resolveTemplateNamesForUpdate( - language: string, - templateNames: string[], - isGlobal: boolean, -): Promise<{ resolvedNames: string[]; notFoundNames: string[] }> { - const sources = await readConfigSources({ - forceGlobal: isGlobal, - forceLocal: !isGlobal, - }); - - const targetConfig = isGlobal ? sources.global : sources.local; - const languageTemplates = targetConfig?.templates?.[language]?.templates; - - if (!languageTemplates) { - if (languageTemplates === undefined) { - return { resolvedNames: [], notFoundNames: templateNames }; - } - } - - const templatesMap = Object.entries(languageTemplates || {}).reduce( - (acc, [name, template]: [string, TemplateConfig]) => { - acc[name] = name; - if (template?.alias) { - acc[template.alias] = name; - } - return acc; - }, - {} as Record, - ); - - if (templateNames.includes("*")) { - return { - resolvedNames: [...new Set(Object.values(templatesMap))], - notFoundNames: templateNames.filter((name) => name !== "*"), - }; - } - - const resolvedNames: string[] = []; - const notFoundNames: string[] = []; - - for (const name of templateNames) { - const actualName = templatesMap[name]; - if (actualName) { - if (!resolvedNames.includes(actualName)) { - resolvedNames.push(actualName); - } - } else { - notFoundNames.push(name); - } - } - - return { resolvedNames, notFoundNames }; -} - -export function setupUpdateCommand(configCommand: Command): void { - configCommand - .command("update ") - .alias("up") - .description(t("commands.config.update_template.command.description")) - .option( - "-n, --new-name ", - t("commands.config.update_template.options.new_name"), - ) - .option( - "-d, --description ", - t("commands.config.update_template.options.description"), - ) - .option( - "-a, --alias ", - t("commands.config.update_template.options.alias"), - ) - .option( - "-l, --location ", - t("commands.config.update_template.options.location"), - ) - .option( - "--cache-strategy ", - t("commands.config.update_template.options.cache_strategy"), - ) - .option( - "--package-manager ", - t("commands.config.update_template.options.package_manager"), - ) - .option( - "-g, --global", - t("commands.config.update_template.options.global"), - false, - ) - .action( - async ( - language: string, - templateNames: string[], - cmdOptions: UpdateCommandOptions, - childCommand: Command, - ) => { - const parentOpts = childCommand?.parent?.opts(); - const isGlobal = !!parentOpts?.global; - - const spinner: TSpinner = logger.spinner().start( - logger.colors.cyan( - t("messages.status.template_updating", { - templateName: templateNames.join(", "), - }), - ), - ); - - const updates = { ...cmdOptions, language, isGlobal }; - let hasErrors = false; - let successfullyUpdatedCount = 0; - let templatesToActOn: string[] = []; - let notFoundNames: string[] = []; - - try { - if (templateNames.length === 0) { - throw new DevkitError( - t("errors.validation.template_name_required"), - ); - } - - if (language) validateProgrammingLanguage(language); - - const resolution = await resolveTemplateNamesForUpdate( - language, - templateNames, - isGlobal, - ); - templatesToActOn = resolution.resolvedNames; - notFoundNames = resolution.notFoundNames; - - if (templatesToActOn.length === 0) { - throw new DevkitError( - t("errors.template.not_found", { - template: notFoundNames.join(", "), - }), - ); - } - - for (const templateName of templatesToActOn) { - try { - await handleNonInteractiveTemplateUpdate( - language, - templateName, - updates, - !!isGlobal, - ); - - successfullyUpdatedCount++; - } catch (error: unknown) { - hasErrors = true; - if (error instanceof DevkitError) { - logger.log( - logger.colors.yellow( - `\n${t("errors.template.single_fail", { templateName, error: error.message })}`, - ), - ); - } else { - logger.log( - logger.colors.yellow( - `\n${t("errors.template.single_fail", { templateName, error: "unknown error" })}`, - ), - ); - } - } - } - - spinner.stop(); - - if (successfullyUpdatedCount > 0) { - logger.log( - logger.colors.green( - `\n✔ ${t("messages.success.template_summary_updated", { - count: successfullyUpdatedCount.toString(), - templateName: templatesToActOn.join(", "), - language, - })}`, - ), - ); - } - - if (notFoundNames.length > 0) { - logger.warning( - logger.colors.yellow( - t("warnings.template.list_not_found", { - templates: notFoundNames.join(", "), - }), - ), - ); - } - - if (hasErrors) { - process.exit(1); - } - } catch (error: unknown) { - handleErrorAndExit(error as Error, spinner); - } - }, - ); -} diff --git a/packages/devkit/src/commands/config/update/index.ts b/packages/devkit/src/commands/config/update/index.ts index d002b98..149ff2a 100644 --- a/packages/devkit/src/commands/config/update/index.ts +++ b/packages/devkit/src/commands/config/update/index.ts @@ -7,6 +7,7 @@ import { type UpdateCommandOptions } from "../types.js"; import { resolveTemplateNamesForUpdate } from "./logic.js"; import { DevkitError } from "#utils/errors/base.js"; import { validateProgrammingLanguage } from "#utils/validations/config.js"; +import { mapLanguageAliasToCanonicalKey } from "#core/config/language.js"; export function setupUpdateCommand(configCommand: Command): void { configCommand @@ -60,7 +61,7 @@ export function setupUpdateCommand(configCommand: Command): void { ), ); - const updates = { ...cmdOptions, language, isGlobal }; + const updates = { ...cmdOptions, language }; let hasErrors = false; let successfullyUpdatedCount = 0; let templatesToActOn: string[] = []; @@ -73,7 +74,10 @@ export function setupUpdateCommand(configCommand: Command): void { ); } - if (language) validateProgrammingLanguage(language); + if (language) { + language = mapLanguageAliasToCanonicalKey(language); + validateProgrammingLanguage(language); + } const resolution = await resolveTemplateNamesForUpdate( language, diff --git a/packages/devkit/src/commands/config/validate-and-save.ts b/packages/devkit/src/commands/config/validate-and-save.ts index 1aac237..122a8e3 100644 --- a/packages/devkit/src/commands/config/validate-and-save.ts +++ b/packages/devkit/src/commands/config/validate-and-save.ts @@ -33,9 +33,10 @@ export async function validateAndSaveTemplate( validateProgrammingLanguage(language); - const languageConfig = targetConfig.templates[language]; + let languageConfig = targetConfig.templates[language]; if (!languageConfig) { targetConfig.templates[language] = { templates: {} }; + languageConfig = targetConfig.templates[language]; } await validateLocation(location, addSpinner); @@ -49,7 +50,7 @@ export async function validateAndSaveTemplate( validateCacheStrategy(cacheStrategy); } - if (languageConfig.templates[templateName]) { + if (languageConfig?.templates[templateName]) { throw new DevkitError( t("errors.template.exists", { template: templateName }), ); @@ -57,9 +58,10 @@ export async function validateAndSaveTemplate( if (alias) { validateAlias(alias); - const aliasExists = Object.values(languageConfig.templates).some( + const aliasExists = Object.values(languageConfig?.templates).some( (t) => t.alias === alias, ); + if (aliasExists) { throw new DevkitError( t("errors.validation.alias_exists", { alias: alias }), diff --git a/packages/devkit/src/commands/index.ts b/packages/devkit/src/commands/index.ts index 01e6130..b66da6b 100644 --- a/packages/devkit/src/commands/index.ts +++ b/packages/devkit/src/commands/index.ts @@ -7,7 +7,7 @@ import { handleErrorAndExit } from "#utils/errors/handler.js"; import { setupNewCommand } from "#commands/new.js"; import { setupConfigCommand } from "#commands/config/index.js"; import { setupListCommand } from "#commands/list.js"; -import { setupInitCommand } from "#commands/init.js"; +import { setupInitCommand } from "#commands/init/index.js"; import { setupInfoCommand } from "#commands/info.js"; import { loadTranslations } from "#utils/i18n/translation-loader.js"; diff --git a/packages/devkit/src/commands/init.ts b/packages/devkit/src/commands/init.ts deleted file mode 100644 index 39dfc5b..0000000 --- a/packages/devkit/src/commands/init.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { - CONFIG_FILE_NAMES, - defaultCliConfig, - type CliConfig, - type SetupCommandOptions, -} from "#utils/schema/schema.js"; -import { t } from "#utils/i18n/translator.js"; -import { ConfigError } from "#utils/errors/base.js"; -import fs from "#utils/fs/file.js"; -import path from "path"; -import os from "os"; -import { logger, TSpinner } from "#utils/logger.js"; -import { select } from "@inquirer/prompts"; -import { findGlobalConfigFile } from "#core/config/search.js"; -import { findMonorepoRoot, findProjectRoot } from "#utils/fs/finder.js"; -import { findUp } from "#utils/fs/find-up.js"; -import { saveConfig } from "#core/config/writer.js"; -import { handleErrorAndExit } from "#utils/errors/handler.js"; -import { getPackageManager } from "#utils/package-manager/index.js"; - -async function promptForStandardOverwrite(filePath: string): Promise { - const response = await select({ - message: logger.colors.yellow( - t("commands.config.init.confirm_overwrite", { path: filePath }), - ), - choices: [ - { name: t("common.yes"), value: true }, - { name: t("common.no"), value: false }, - ], - default: true, - }); - return response; -} - -async function getUpdatedConfig(): Promise { - const detectedPackageManager = await getPackageManager(true); - return { - ...defaultCliConfig, - settings: { - ...defaultCliConfig.settings, - defaultPackageManager: - detectedPackageManager || - defaultCliConfig.settings.defaultPackageManager, - }, - }; -} - -async function handleGlobalInit(spinner: TSpinner): Promise { - let finalPath = await findGlobalConfigFile(); - if (!finalPath) { - finalPath = path.join(os.homedir(), CONFIG_FILE_NAMES[0]); - } - - const shouldOverwrite = (await fs.pathExists(finalPath)) - ? await promptForStandardOverwrite(finalPath) - : true; - - if (shouldOverwrite) { - const configToSave = await getUpdatedConfig(); - spinner.start( - logger.colors.cyan( - t("messages.status.config_init_start", { path: finalPath }), - ), - ); - await saveConfig(configToSave, finalPath); - spinner.succeed( - logger.colors.green(t("messages.success.config_initialized")), - ); - } else { - spinner.info(logger.colors.yellow(t("commands.config.init.aborted"))); - } -} - -async function handleLocalInit(spinner: TSpinner): Promise { - const allConfigFiles = [...CONFIG_FILE_NAMES]; - const currentPath = process.cwd(); - const monorepoRoot = await findMonorepoRoot(); - const projectRoot = await findProjectRoot(); - - let finalPath: string | null = null; - let shouldOverwrite = true; - const rootDir = monorepoRoot || projectRoot || currentPath; - - const existingConfigPath = await findUp({ - files: allConfigFiles, - cwd: rootDir, - limit: rootDir, - }); - - if (existingConfigPath) { - finalPath = existingConfigPath; - shouldOverwrite = await promptForStandardOverwrite(finalPath); - } else { - finalPath = path.join(rootDir, allConfigFiles[1]); - } - - if (shouldOverwrite && finalPath) { - const configToSave = await getUpdatedConfig(); - spinner.start( - logger.colors.cyan( - t("messages.status.config_init_start", { path: finalPath }), - ), - ); - await saveConfig(configToSave, finalPath); - spinner.succeed( - logger.colors.green(t("messages.success.config_initialized")), - ); - } else { - spinner.info(logger.colors.yellow(t("commands.config.init.aborted"))); - } -} - -export function setupInitCommand(options: SetupCommandOptions): void { - const { program } = options; - program - .command("init") - .alias("i") - .description(t("commands.config.init.command.description")) - .option("-l, --local", t("commands.config.init.option.local"), false) - .option("-g, --global", t("commands.config.init.option.global"), false) - .action(async (cmdOptions: { local: boolean; global: boolean }) => { - const isLocal: boolean = cmdOptions.local; - const isGlobal: boolean = cmdOptions.global; - const spinner: TSpinner = logger.spinner(); - - try { - if (isLocal && isGlobal) { - throw new ConfigError(t("errors.config.init_local_and_global")); - } - - if (isGlobal) { - await handleGlobalInit(spinner); - } else { - await handleLocalInit(spinner); - } - } catch (error) { - handleErrorAndExit(error, spinner); - } - }); -} diff --git a/packages/devkit/src/commands/list.ts b/packages/devkit/src/commands/list.ts index 987059e..28b05a4 100644 --- a/packages/devkit/src/commands/list.ts +++ b/packages/devkit/src/commands/list.ts @@ -15,6 +15,7 @@ import { getAnnotatedTemplates, type AnnotatedTemplate, } from "#core/template/annotator.js"; +import { mapLanguageAliasToCanonicalKey } from "#core/config/language.js"; type ListCommandOptions = { global?: boolean; @@ -72,7 +73,10 @@ export function setupListCommand(options: SetupCommandOptions): void { ); } - if (language) validateProgrammingLanguage(language); + if (language) { + language = mapLanguageAliasToCanonicalKey(language); + validateProgrammingLanguage(language); + } const annotatedTemplates: AnnotatedTemplate[] = await getAnnotatedTemplates({ diff --git a/packages/devkit/src/commands/new.ts b/packages/devkit/src/commands/new.ts index 236b285..035d69d 100644 --- a/packages/devkit/src/commands/new.ts +++ b/packages/devkit/src/commands/new.ts @@ -5,9 +5,10 @@ import { handleErrorAndExit } from "#utils/errors/handler.js"; import { logger, type TSpinner } from "#utils/logger.js"; import { validateProgrammingLanguage } from "#utils/validations/config.js"; import { getMergedConfig } from "#core/config/merger.js"; +import { mapLanguageAliasToCanonicalKey } from "#core/config/language.js"; const getScaffolder = async (language: string) => { - if (language === "javascript") { + if (["javascript", "typescript", "nodejs"].includes(language)) { const { scaffoldProject } = await import("#scaffolding/javascript.js"); return scaffoldProject; } @@ -43,6 +44,7 @@ export function setupNewCommand(options: SetupCommandOptions) { .start(); try { + language = mapLanguageAliasToCanonicalKey(language); validateProgrammingLanguage(language); const config = await getMergedConfig(true); diff --git a/packages/devkit/src/core/config/language.ts b/packages/devkit/src/core/config/language.ts new file mode 100644 index 0000000..d1df2c4 --- /dev/null +++ b/packages/devkit/src/core/config/language.ts @@ -0,0 +1,30 @@ +import { + ProgrammingLanguageAlias, + SupportedProgrammingLanguageKeys, + ValuesOf, +} from "#utils/schema/schema.js"; + +type AliasValue = ValuesOf; +type AliasKey = keyof typeof ProgrammingLanguageAlias; + +export const mapLanguageAliasToCanonicalKey = ( + inputLang: string, +): SupportedProgrammingLanguageKeys => { + const lowerInput = inputLang.toLowerCase(); + + if ( + Object.prototype.hasOwnProperty.call(ProgrammingLanguageAlias, lowerInput) + ) { + return ProgrammingLanguageAlias[ + lowerInput as AliasKey + ] as SupportedProgrammingLanguageKeys; + } + + const canonicalKeys = Object.values(ProgrammingLanguageAlias) as AliasValue[]; + + if (canonicalKeys.includes(lowerInput as AliasValue)) { + return lowerInput as SupportedProgrammingLanguageKeys; + } + + return lowerInput as SupportedProgrammingLanguageKeys; +}; diff --git a/packages/devkit/src/core/prompts/prompts.ts b/packages/devkit/src/core/prompts/prompts.ts index 4dbce63..babaabb 100644 --- a/packages/devkit/src/core/prompts/prompts.ts +++ b/packages/devkit/src/core/prompts/prompts.ts @@ -1,7 +1,7 @@ import { select } from "@inquirer/prompts"; import { ProgrammingLanguage, - type SupportedProgrammingLanguageValues, + type SupportedProgrammingLanguageKeys, type SupportedPackageManager, type CacheStrategy, VALID_CACHE_STRATEGIES, @@ -17,8 +17,8 @@ import { logger } from "#utils/logger.js"; */ export async function promptForLanguage( required = true, - defaultValue?: SupportedProgrammingLanguageValues, -): Promise { + defaultValue?: SupportedProgrammingLanguageKeys, +): Promise { const message = `${t("commands.template.add.prompts.language")} ${ required ? logger.colors.red("(required)") : logger.colors.dim("(optional)") }`; @@ -30,7 +30,7 @@ export async function promptForLanguage( message, choices, default: defaultValue, - })) as SupportedProgrammingLanguageValues; + })) as SupportedProgrammingLanguageKeys; } /** diff --git a/packages/devkit/src/core/template/template-utils.ts b/packages/devkit/src/core/template/template-utils.ts index 6a5d772..1ae65d0 100644 --- a/packages/devkit/src/core/template/template-utils.ts +++ b/packages/devkit/src/core/template/template-utils.ts @@ -1,11 +1,11 @@ import { FILE_NAMES, - type SupportedProgrammingLanguageValues, + type SupportedProgrammingLanguageKeys, } from "#utils/schema/schema.js"; import fs from "#utils/fs/file.js"; export function getFilesToFilter( - language: SupportedProgrammingLanguageValues, + language: SupportedProgrammingLanguageKeys, ): string[] { const commonFiles = Object.values(FILE_NAMES.common); const languageSpecificFiles = FILE_NAMES[language].lockFiles; diff --git a/packages/devkit/src/utils/schema/schema.ts b/packages/devkit/src/utils/schema/schema.ts index 48f11f5..fe77d14 100644 --- a/packages/devkit/src/utils/schema/schema.ts +++ b/packages/devkit/src/utils/schema/schema.ts @@ -2,6 +2,8 @@ import type { Command } from "commander"; export const ProgrammingLanguage = { Javascript: "Javascript", + Typescript: "Typescript", + Nodejs: "Nodejs", } as const; export const JavascriptPackageManagers = { @@ -50,10 +52,20 @@ export type LowercaseValues = ? Lowercase : T; -export type SupportedProgrammingLanguageValues = LowercaseValues< +export type SupportedProgrammingLanguageKeys = LowercaseValues< ValuesOf >; +export const ProgrammingLanguageAlias = { + js: "javascript", + ts: "typescript", + node: "nodejs", +} as const; + +export type ValidProgrammingLanguageInput = + | SupportedProgrammingLanguageKeys + | ValuesOf; + export interface TemplateConfig { description: string; location: string; @@ -67,7 +79,7 @@ export interface LanguageConfig { } export interface CliConfig { - templates: Record; + templates: Record; settings: { defaultPackageManager: SupportedPackageManager; cacheStrategy: CacheStrategy; @@ -103,88 +115,105 @@ export interface ReadConfigOptions { useFallback?: boolean; } +const genericNodejsTemplate: TemplateConfig = { + description: + "A generic, unopinionated Node.js/Typescript project boilerplate.", + location: "https://github.com/IT-WIBRC/devkit-node-boilerplate.git", + alias: "node", + cacheStrategy: "daily", +}; + +const baseJsTemplates: Record = { + nodejs: genericNodejsTemplate, + vue: { + description: "An official Vue.js project.", + location: "{pm} create vue@latest", + cacheStrategy: "always-refresh", + }, + nuxt: { + description: "An official Nuxt.js project.", + location: "{pm} create nuxt@latest", + alias: "nx", + }, + nest: { + description: "An official Nest.js project.", + location: "{pm} install -g @nestjs/cli && nest new", + }, + nextjs: { + description: "An official Next.js project.", + location: "{pm} create next-app@latest", + alias: "next", + }, + express: { + description: "A simple Express.js boilerplate from its generator.", + location: "https://github.com/expressjs/express-generator.git", + alias: "ex", + }, + fastify: { + description: "A highly performant Fastify web framework boilerplate.", + location: "https://github.com/fastify/fastify-cli.git", + alias: "fy", + }, + koa: { + description: "A Koa.js web framework boilerplate.", + location: "https://github.com/koajs/koa-generator.git", + }, + adonis: { + description: "A full-stack Node.js framework (AdonisJS).", + location: "{pm} create adonisjs", + alias: "ad", + }, + sails: { + description: "A real-time, MVC framework (Sails.js).", + location: "{pm} install -g sails && sails new", + }, + angular: { + description: "An official Angular project.", + location: "{pm} install -g @angular/cli && ng new", + alias: "ng", + }, + "angular-vite": { + description: "An Angular project using Vite via AnalogJS.", + location: "{pm} create analog@latest", + alias: "ng-v", + }, + react: { + description: "A React project using the recommended Vite setup.", + location: "{pm} create vite@latest -- --template react", + alias: "rt", + }, + svelte: { + description: "A Svelte project using SvelteKit.", + location: "{pm} create svelte@latest", + }, + qwik: { + description: "An official Qwik project.", + location: "{pm} create qwik@latest", + }, + astro: { + description: "A new Astro project.", + location: "{pm} create astro@latest", + }, + solid: { + description: "An official SolidJS project.", + location: "{pm} create solid@latest", + }, + remix: { + description: "An official Remix project.", + location: "{pm} create remix@latest", + }, +}; + export const defaultCliConfig: CliConfig = { templates: { javascript: { - templates: { - vue: { - description: "An official Vue.js project.", - location: "{pm} create vue@latest", - cacheStrategy: "always-refresh", - }, - nuxt: { - description: "An official Nuxt.js project.", - location: "{pm} create nuxt@latest", - alias: "nx", - }, - nest: { - description: "An official Nest.js project.", - location: "{pm} install -g @nestjs/cli && nest new", - }, - nextjs: { - description: "An official Next.js project.", - location: "{pm} create next-app@latest", - alias: "next", - }, - express: { - description: "A simple Express.js boilerplate from its generator.", - location: "https://github.com/expressjs/express-generator.git", - alias: "ex", - }, - fastify: { - description: "A highly performant Fastify web framework boilerplate.", - location: "https://github.com/fastify/fastify-cli.git", - alias: "fy", - }, - koa: { - description: "A Koa.js web framework boilerplate.", - location: "https://github.com/koajs/koa-generator.git", - }, - adonis: { - description: "A full-stack Node.js framework (AdonisJS).", - location: "{pm} create adonisjs", - alias: "ad", - }, - sails: { - description: "A real-time, MVC framework (Sails.js).", - location: "{pm} install -g sails && sails new", - }, - angular: { - description: "An official Angular project.", - location: "{pm} install -g @angular/cli && ng new", - alias: "ng", - }, - "angular-vite": { - description: "An Angular project using Vite via AnalogJS.", - location: "{pm} create analog@latest", - alias: "ng-v", - }, - react: { - description: "A React project using the recommended Vite setup.", - location: "{pm} create vite@latest -- --template react", - alias: "rt", - }, - svelte: { - description: "A Svelte project using SvelteKit.", - location: "{pm} create svelte@latest", - }, - qwik: { - description: "An official Qwik project.", - location: "{pm} create qwik@latest", - }, - astro: { - description: "A new Astro project.", - location: "{pm} create astro@latest", - }, - solid: { - description: "An official SolidJS project.", - location: "{pm} create solid@latest", - }, - remix: { - description: "An official Remix project.", - location: "{pm} create remix@latest", - }, - }, + templates: baseJsTemplates, + }, + typescript: { + templates: baseJsTemplates, + }, + nodejs: { + templates: baseJsTemplates, }, }, settings: { @@ -206,6 +235,9 @@ export type DeepKeys = T extends object }[keyof T] : ""; +const jsFiles = { + lockFiles: ["package-lock.json", "bun.lockb", "yarn.lock", "pnpm-lock.yaml"], +}; export const FILE_NAMES = { packageJson: "package.json", node_modules: "node_modules", @@ -213,11 +245,12 @@ export const FILE_NAMES = { git: ".git", }, javascript: { - lockFiles: [ - "package-lock.json", - "bun.lockb", - "yarn.lock", - "pnpm-lock.yaml", - ], + ...jsFiles, + }, + typescript: { + ...jsFiles, + }, + nodejs: { + ...jsFiles, }, } as const; diff --git a/packages/devkit/src/utils/validations/config.ts b/packages/devkit/src/utils/validations/config.ts index c00a5fe..9f930c7 100644 --- a/packages/devkit/src/utils/validations/config.ts +++ b/packages/devkit/src/utils/validations/config.ts @@ -7,7 +7,7 @@ import { TextLanguages, type TextLanguageValues, ProgrammingLanguage, - type SupportedProgrammingLanguageValues, + type SupportedProgrammingLanguageKeys, type SupportedPackageManager, type DisplayModesValues, } from "#utils/schema/schema.js"; @@ -58,11 +58,11 @@ export function validateLanguage( export function validateProgrammingLanguage( value: string, -): asserts value is SupportedProgrammingLanguageValues { +): asserts value is SupportedProgrammingLanguageKeys { const validLanguages = Object.values(ProgrammingLanguage).map((value) => value.toLowerCase(), ); - if (!validLanguages.includes(value as SupportedProgrammingLanguageValues)) { + if (!validLanguages.includes(value as SupportedProgrammingLanguageKeys)) { throw new DevkitError( t("errors.validation.invalid_value", { key: "Programming Language",