From c26004657707c9afe5be67680432f1f7b6be2c92 Mon Sep 17 00:00:00 2001 From: IT-WIBRC Date: Fri, 3 Oct 2025 18:58:02 +0100 Subject: [PATCH] feat: add --include-defaults flag to list commands and document {pm} token --- .changeset/silent-worms-hear.md | 5 + packages/devkit/README.md | 117 ++-- packages/devkit/TODO.md | 12 +- .../integrations/config/index.spec.ts | 6 +- .../integrations/config/list.spec.ts | 164 +++-- .../__tests__/integrations/list.spec.ts | 348 +++++++--- .../units/commands/config/add.spec.ts | 280 ++++---- .../units/commands/config/index.spec.ts | 150 ++--- .../units/commands/config/list.spec.ts | 414 +++++++----- .../units/commands/config/logic.spec.ts | 301 +++++---- .../units/commands/config/remove.spec.ts | 237 +++---- .../__tests__/units/commands/index.spec.ts | 172 +++-- .../__tests__/units/commands/list.spec.ts | 604 ++++++------------ .../__tests__/units/commands/new.spec.ts | 134 ++-- .../units/core/config/finder.spec.ts | 227 +++++++ .../units/core/config/loader.spec.ts | 151 +++++ .../units/core/config/merger.spec.ts | 201 ++++++ .../core/{configs => config}/reader.spec.ts | 0 .../core/{configs => config}/search.spec.ts | 0 .../core/{configs => config}/writer.spec.ts | 0 .../units/core/configs/finder.spec.ts | 193 ------ .../units/core/configs/loader.spec.ts | 181 ------ .../__tests__/units/core/info/info.spec.ts | 50 +- .../units/core/template/annotator.spec.ts | 281 ++++++++ .../units/core/template/printer.spec.ts | 269 ++++---- .../utils/i18n/translation-loader.spec.ts | 4 +- packages/devkit/locales/en.json | 21 +- packages/devkit/locales/fr.json | 11 +- packages/devkit/src/commands/config/add.ts | 24 +- packages/devkit/src/commands/config/index.ts | 23 +- packages/devkit/src/commands/config/list.ts | 92 ++- packages/devkit/src/commands/config/logic.ts | 48 +- packages/devkit/src/commands/config/remove.ts | 39 +- packages/devkit/src/commands/index.ts | 56 +- packages/devkit/src/commands/list.ts | 139 ++-- packages/devkit/src/commands/new.ts | 4 +- packages/devkit/src/core/config/finder.ts | 81 +-- packages/devkit/src/core/config/loader.ts | 63 +- packages/devkit/src/core/config/merger.ts | 39 ++ packages/devkit/src/core/info/info.ts | 9 +- .../devkit/src/core/template/annotator.ts | 68 ++ packages/devkit/src/core/template/printer.ts | 126 ++-- .../src/utils/i18n/translation-loader.ts | 4 + packages/devkit/src/utils/schema/schema.ts | 1 - packages/devkit/vitest.setup.ts | 8 +- 45 files changed, 3210 insertions(+), 2147 deletions(-) create mode 100644 .changeset/silent-worms-hear.md create mode 100644 packages/devkit/__tests__/units/core/config/finder.spec.ts create mode 100644 packages/devkit/__tests__/units/core/config/loader.spec.ts create mode 100644 packages/devkit/__tests__/units/core/config/merger.spec.ts rename packages/devkit/__tests__/units/core/{configs => config}/reader.spec.ts (100%) rename packages/devkit/__tests__/units/core/{configs => config}/search.spec.ts (100%) rename packages/devkit/__tests__/units/core/{configs => config}/writer.spec.ts (100%) delete mode 100644 packages/devkit/__tests__/units/core/configs/finder.spec.ts delete mode 100644 packages/devkit/__tests__/units/core/configs/loader.spec.ts create mode 100644 packages/devkit/__tests__/units/core/template/annotator.spec.ts create mode 100644 packages/devkit/src/core/config/merger.ts create mode 100644 packages/devkit/src/core/template/annotator.ts diff --git a/.changeset/silent-worms-hear.md b/.changeset/silent-worms-hear.md new file mode 100644 index 0000000..99e9479 --- /dev/null +++ b/.changeset/silent-worms-hear.md @@ -0,0 +1,5 @@ +--- +"scaffolder-toolkit": minor +--- + +feat: Add --include-defaults to list commands; enhance template config with {pm} token diff --git a/packages/devkit/README.md b/packages/devkit/README.md index c71951c..4281cc9 100644 --- a/packages/devkit/README.md +++ b/packages/devkit/README.md @@ -176,6 +176,7 @@ dk list --where name:vue The `dk list` command now uses the following options to control which templates are displayed: +- **`-d, --include-defaults`**: **Include Scaffolder's built-in, default templates in the list.** By default, only templates defined in your local or global config files are shown. - **`--local`**: Only list templates from the local configuration file (`.devkit.json`). - **`--global`**: Only list templates from the global configuration file (`~/.devkitrc`). - **`--all`**: List templates from both the local and global configurations, merging them into a single list. @@ -187,6 +188,9 @@ The `dk list` command now uses the following options to control which templates Here are some examples of how to use the new options: ```bash +# List all templates, including the Scaffolder's built-in defaults +dk list --include-defaults + # List templates only from the local configuration file dk list --local @@ -202,8 +206,8 @@ 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/ -# List templates in a compact table format -dk list --mode table +# List templates in a compact table format, including defaults +dk list --mode table -d ``` --- @@ -239,6 +243,7 @@ dk config --set defaultPackageManager npm --global The `dk config list` command provides a detailed, comprehensive view of your configuration. Unlike the top-level `dk list` command, this subcommand shows both the **settings** and **templates** from the active configuration files. It's useful for debugging and getting a full overview of your current setup. +- The **`-d, --include-defaults`** option includes Scaffolder's default settings and templates in the output. - The **`--global`** option forces the command to only display settings and templates from your global configuration file (`~/.devkitrc`), ignoring any local configuration. - The **`--all`** option displays a merged view of your local and global configurations, showing the final effective settings. @@ -251,8 +256,8 @@ dk config list # List configuration only from the global file dk config list --global -# List a merged view of the local and global configurations -dk config list --all +# List a merged view of the local and global configurations, including defaults +dk config list --all -d ``` #### Add a new template @@ -354,32 +359,59 @@ Scaffolder now loads settings with a clear priority to give you maximum control 3. **System Language Detection**: If a language setting is not found in either the local or global configuration, `dk` will **automatically detect your system's language** and load the corresponding translations. 4. **Default**: If none of the above are found, the language will default to English (`en`). -### Creating a New Template (The Full Workflow) +### Template Configuration -Scaffolder allows you to create and use your own templates in three simple steps. +Scaffolder provides a set of default templates and we'll progressively add more. -#### Step 1: Create the Template Project +#### Template `location` and the `{pm}` Placeholder -First, build your template. This is a standard project directory containing all the files you want to use. You can use any type of project, from a simple boilerplate to a complex custom setup. +The `location` property for a custom template can be an **absolute path**, a **relative path**, a GitHub URL, or a command. -#### Step 2: Add the Template to Your Config +For command-based templates (like those using `create-react-app` or `create-nuxt-app`), you can use the special placeholder **`{pm}`** within the `location` string. -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. +| Placeholder | Description | +| :---------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`{pm}`** | This token is dynamically replaced at runtime with the **effective package manager** (e.g., `npm`, `pnpm`, `bun`, or `yarn`) defined in your CLI's settings. | -> **Note:** All custom templates must be added to the `javascript` section of your configuration file. +This ensures that the template command always uses your preferred package manager without needing to hardcode it. -```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 +> **Note:** Cache strategies are only applied when using a GitHub URL. + +You can also define an **`alias`** to make it easier to reference a specific template. An alias is a simple shortcut for a template's name. + +```json +{ + "templates": { + "javascript": { + "templates": { + "my-local-template": { + "description": "A template from my local machine", + "location": "/Users/myuser/projects/my-local-template" + }, + "from-github": { + "description": "A template from a GitHub repository", + "location": "https://github.com/my-user/my-template-repo.git", + "alias": "gh-template", + "cacheStrategy": "daily" + }, + "from-create-command": { + "description": "Uses the native `create` command with the configured package manager", + "location": "{pm} create nuxt@latest", + "packageManager": "bun" + } + } + } + } +} ``` -#### Step 3: Use the Template +### Using a custom template with an alias -After running the `dk config add` command, you can scaffold a new project from your template using `dk new`. +Once an alias is configured, you can use it in place of the full template name for faster commands. ```bash -# Create a new project from the template we just added -dk new javascript my-awesome-project -t custom-js-app +# Create a new project using the alias 'gh-template' +dk new javascript my-new-project-name -t gh-template ``` ### Create and configure a project file @@ -502,47 +534,32 @@ Example for Windows: } ``` -### Template Configuration +### Creating a New Template (The Full Workflow) -Scaffolder provides a set of default templates and we'll progressively add more. The `location` property for a custom template can be an **absolute path**, a **relative path**, a GitHub URL, or a command. Note that cache strategies are only applied when using a GitHub URL. +Scaffolder allows you to create and use your own templates in three simple steps. -> **Note:** Currently, the configuration and all templates are for Node.js projects and must be nested within the `templates.javascript` object. +#### Step 1: Create the Template Project -You can also define an **`alias`** to make it easier to reference a specific template. An alias is a simple shortcut for a template's name. +First, build your template. This is a standard project directory containing all the files you want to use. You can use any type of project, from a simple boilerplate to a complex custom setup. -```json -{ - "templates": { - "javascript": { - "templates": { - "my-local-template": { - "description": "A template from my local machine", - "location": "/Users/myuser/projects/my-local-template" - }, - "from-github": { - "description": "A template from a GitHub repository", - "location": "https://github.com/my-user/my-template-repo.git", - "alias": "gh-template", - "cacheStrategy": "daily" - }, - "from-create-command": { - "description": "Uses the native `create` command", - "location": "{pm} create nuxt@latest", - "packageManager": "bun" - } - } - } - } -} +#### Step 2: Add the Template to Your Config + +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. + +```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 ``` -### Using a custom template with an alias +#### Step 3: Use the Template -Once an alias is configured, you can use it in place of the full template name for faster commands. +After running the `dk config add` command, you can scaffold a new project from your template using `dk new`. ```bash -# Create a new project using the alias 'gh-template' -dk new javascript my-new-project-name -t gh-template +# Create a new project from the template we just added +dk new javascript my-awesome-project -t custom-js-app ``` --- diff --git a/packages/devkit/TODO.md b/packages/devkit/TODO.md index a850f81..1948b1d 100644 --- a/packages/devkit/TODO.md +++ b/packages/devkit/TODO.md @@ -59,14 +59,15 @@ This document tracks all planned and completed tasks for the Dev Kit project. - [x] Better json structure for languages translation - [x] **Enhance `list` Command**: Add support for **different display modes** (e.g., table or tree structure). `tree` as default - [x] **Enhance `list` Command**: Add options to **filter by properties** (e.g., `packageManager`, `alias`, etc.). -- [ ] **Enhance `list` Command**: Add flag to also see default config `--with-defaults`. +- [x] **Enhance `list` Command**: Add flag to also see default config `--include-defaults`. +- [x] Explain the usage of `{pm}` role inside the config in the documentation. +- [x] Add a `--settings, -s` to the `dk list` command to display the current configuration settings only - [ ] Add wildcard support for template name in the `dk config update` and `dk config remove` commands. +- [ ] 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 - [ ] Add a configuration validation step when updating the config file to ensure all required fields are present and correctly formatted. -- [ ] **Dynamic Help Text**: Programmatically generate help text for options with constrained values (e.g., `--cache-strategy`) to ensure it's always up to date. -- [ ] **Skip Confirmation**: Add a global `-y`/`--yes` and `-n/--no` option to skip confirmation prompts in commands like `dk init`. -- [ ] **CLI Self-Update**: Implement a command to allow users to update the CLI itself. `dk upgrade` - [ ] **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 @@ -80,8 +81,11 @@ 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`. +- [ ] **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) +- [ ] Remove the `dk config list` command. --- diff --git a/packages/devkit/__tests__/integrations/config/index.spec.ts b/packages/devkit/__tests__/integrations/config/index.spec.ts index 29892e2..49a9b25 100644 --- a/packages/devkit/__tests__/integrations/config/index.spec.ts +++ b/packages/devkit/__tests__/integrations/config/index.spec.ts @@ -100,7 +100,7 @@ describe("dk config", () => { expect(exitCode).toBe(0); expect(all).toContain("language: fr"); - expect(all).toContain("Configuration mise à jour avec succès !"); + expect(all).toContain("Paramètres de configuration récupérés avec succès."); }); it("should get multiple settings from the local config", async () => { @@ -114,7 +114,7 @@ describe("dk config", () => { expect(exitCode).toBe(0); expect(all).toContain("language: fr"); expect(all).toContain("cacheStrategy: always-refresh"); - expect(all).toContain("Configuration mise à jour avec succès !"); + expect(all).toContain("Paramètres de configuration récupérés avec succès."); }); it("should get a setting from the global config with --global flag", async () => { @@ -128,7 +128,7 @@ describe("dk config", () => { expect(exitCode).toBe(0); expect(all).toContain("language: en"); - expect(all).toContain("Configuration mise à jour avec succès !"); + expect(all).toContain("Paramètres de configuration récupérés avec succès."); }); it("should set a single setting in the local config", async () => { diff --git a/packages/devkit/__tests__/integrations/config/list.spec.ts b/packages/devkit/__tests__/integrations/config/list.spec.ts index e81a890..4dac95c 100644 --- a/packages/devkit/__tests__/integrations/config/list.spec.ts +++ b/packages/devkit/__tests__/integrations/config/list.spec.ts @@ -111,54 +111,124 @@ describe("dk config list", () => { expect(exitCode).toBe(0); expect(all).toContain("Using local configuration."); - expect(all).toContain("JAVASCRIPT"); - expect(all).toContain("NODE"); - expect(all).not.toContain("PYTHON"); + expect(all).toContain("Javascript"); + expect(all).toContain("Node"); + expect(all).not.toContain("Python"); }); - it("should fall back to the `default` config if no local config exists", async () => { - await fs.writeJson( - path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), - globalConfig, - ); + describe("Include defaults option", () => { + it("should fall back to the `default` config if no local config exists and `--include-defaults` is used", async () => { + await fs.writeJson( + path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + globalConfig, + ); - const { all, exitCode } = await execute( - "bun", - [CLI_PATH, "config", "list"], - { - all: true, - env: { HOME: globalConfigDir }, - }, - ); + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "config", "list", "--include-defaults"], + { + all: true, + env: { HOME: globalConfigDir }, + }, + ); - expect(exitCode).toBe(0); - expect(all).toContain( - "No local configuration found. Using templates from global configuration.", - ); - expect(all).toContain("PYTHON"); - }); + expect(exitCode).toBe(0); + expect(all).toContain("Available Templates:"); + expect(all).toContain("remix"); + }); - it("should list templates from both local and global configurations when --all is used", async () => { - await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); - await fs.writeJson( - path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), - globalConfig, - ); + it("should fall back to the `default` config if no global config exists and `--include-defaults` is used", async () => { + await fs.writeJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + localConfig, + ); - const { all, exitCode } = await execute( - "bun", - [CLI_PATH, "config", "list", "--all"], - { - all: true, - env: { HOME: globalConfigDir }, - }, - ); + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "config", "list", "--global", "--include-defaults"], + { + all: true, + env: { HOME: globalConfigDir }, + }, + ); - expect(exitCode).toBe(0); - expect(all).toContain("Using local and global configurations."); - expect(all).toContain("JAVASCRIPT"); - expect(all).toContain("NODE"); - expect(all).toContain("PYTHON"); + expect(exitCode).toBe(0); + expect(all).toContain("Available Templates:"); + expect(all).toContain("remix"); + }); + + it("should use both the `default` config and the local config if exists and `--include-defaults` is used", async () => { + await fs.writeJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + localConfig, + ); + + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "config", "list", "--include-defaults"], + { + all: true, + env: { HOME: globalConfigDir }, + }, + ); + + expect(exitCode).toBe(0); + expect(all).toContain("Available Templates:"); + expect(all).toContain("Javascript"); + expect(all).toContain("remix"); + expect(all).toContain("Node"); + expect(all).toContain("node-api"); + }); + + it("should list templates from both local and global configurations when --all is used", async () => { + await fs.writeJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + localConfig, + ); + await fs.writeJson( + path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + globalConfig, + ); + + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "config", "list", "--all"], + { + all: true, + env: { HOME: globalConfigDir }, + }, + ); + + expect(exitCode).toBe(0); + expect(all).toContain("Using local and global configurations."); + expect(all).toContain("Javascript"); + expect(all).toContain("Node"); + expect(all).toContain("Python"); + }); + + it("should use both the `default` config and the global config if exists and `--include-defaults` is used", async () => { + await fs.writeJson( + path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + globalConfig, + ); + + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "config", "list", "--global", "--include-defaults"], + { + all: true, + env: { HOME: globalConfigDir }, + }, + ); + + expect(exitCode).toBe(0); + expect(all).toContain("Available Templates:"); + expect(all).toContain("Javascript"); + expect(all).toContain("remix"); + expect(all).toContain("Python"); + expect(all).toContain("django"); + expect(all).not.toContain("node-api"); + }); }); it("should only list global config when --global is used", async () => { @@ -179,9 +249,9 @@ describe("dk config list", () => { expect(exitCode).toBe(0); expect(all).toContain("Using global configuration."); - expect(all).toContain("PYTHON"); - expect(all).not.toContain("JAVASCRIPT"); - expect(all).not.toContain("NODE"); + expect(all).toContain("Python"); + expect(all).not.toContain("Javascript"); + expect(all).not.toContain("Node"); }); it("should show an error when --global is used and no global config exists", async () => { @@ -241,8 +311,8 @@ describe("dk config list", () => { expect(exitCode).toBe(0); expect(all).toContain("No templates found in the configuration file."); - expect(all).not.toContain("JAVASCRIPT"); - expect(all).not.toContain("NODE"); - expect(all).not.toContain("PYTHON"); + expect(all).not.toContain("Javascript"); + expect(all).not.toContain("Node"); + expect(all).not.toContain("Python"); }); }); diff --git a/packages/devkit/__tests__/integrations/list.spec.ts b/packages/devkit/__tests__/integrations/list.spec.ts index c17d2f7..6c217d9 100644 --- a/packages/devkit/__tests__/integrations/list.spec.ts +++ b/packages/devkit/__tests__/integrations/list.spec.ts @@ -7,7 +7,6 @@ import { afterEach, beforeAll, } from "vitest"; -import { execa } from "execa"; import path from "path"; import os from "os"; import { @@ -16,6 +15,7 @@ import { CONFIG_FILE_NAMES, defaultCliConfig, type CliConfig, + execute, } from "./common.js"; const LOCAL_CONFIG_FILE_NAME = CONFIG_FILE_NAMES[1]; @@ -27,6 +27,10 @@ let globalConfigDir: string; const localConfig: CliConfig = { ...defaultCliConfig, + settings: { + ...defaultCliConfig.settings, + language: "en", + }, templates: { javascript: { templates: { @@ -59,6 +63,10 @@ const localConfig: CliConfig = { const globalConfig: CliConfig = { ...defaultCliConfig, + settings: { + ...defaultCliConfig.settings, + language: "fr", + }, templates: { python: { templates: { @@ -102,17 +110,17 @@ describe("dk list", () => { globalConfig, ); - const { all, exitCode } = await execa("bun", [CLI_PATH, "list"], { + const { all, exitCode } = await execute("bun", [CLI_PATH, "list"], { all: true, env: { HOME: globalConfigDir }, }); expect(exitCode).toBe(0); - expect(all).toContain("Using local configuration."); + expect(all).toContain("Configuration sources loaded successfully."); expect(all).toContain("Available Templates:"); - expect(all).toContain("JAVASCRIPT"); - expect(all).toContain("NODE"); - expect(all).not.toContain("PYTHON"); + expect(all).toContain("Javascript"); + expect(all).toContain("Node"); + expect(all).not.toContain("Python"); }); it("should list templates from both local and global configurations when --all is used", async () => { @@ -122,19 +130,21 @@ describe("dk list", () => { globalConfig, ); - const { all, exitCode } = await execa("bun", [CLI_PATH, "list", "--all"], { - all: true, - env: { HOME: globalConfigDir }, - }); + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "list", "--all"], + { + all: true, + env: { HOME: globalConfigDir }, + }, + ); expect(exitCode).toBe(0); - expect(all).toContain( - "Using templates from both local and global configurations.", - ); + expect(all).toContain("Configuration sources loaded successfully."); expect(all).toContain("Available Templates:"); - expect(all).toContain("JAVASCRIPT"); - expect(all).toContain("NODE"); - expect(all).toContain("PYTHON"); + expect(all).toContain("Javascript"); + expect(all).toContain("Node"); + expect(all).toContain("Python"); }); it("should only list templates from global config when --global is used", async () => { @@ -144,27 +154,30 @@ describe("dk list", () => { globalConfig, ); - const { all, exitCode } = await execa( + const { all, exitCode } = await execute( "bun", - [CLI_PATH, "config", "--global", "list"], + [CLI_PATH, "list", "--global"], { all: true, - env: { HOME: globalConfigDir }, + env: { + HOME: globalConfigDir, + CWD: tempDir, + }, }, ); expect(exitCode).toBe(0); - expect(all).toContain("Using global configuration."); + expect(all).toContain("Configuration sources loaded successfully."); expect(all).toContain("Available Templates:"); - expect(all).toContain("PYTHON"); - expect(all).not.toContain("JAVASCRIPT"); - expect(all).not.toContain("NODE"); + expect(all).toContain("Python"); + expect(all).not.toContain("Javascript"); + expect(all).not.toContain("Node"); }); - it("should filter templates by language when a language argument is provided", async () => { + it("should filter templates by language argument", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); - const { all, exitCode } = await execa( + const { all, exitCode } = await execute( "bun", [CLI_PATH, "list", "javascript"], { @@ -174,17 +187,16 @@ describe("dk list", () => { ); expect(exitCode).toBe(0); - expect(all).toContain("Using local configuration."); - expect(all).toContain("JAVASCRIPT"); + expect(all).toContain("Javascript"); expect(all).toContain("react-ts"); expect(all).toContain("vue-basic"); - expect(all).not.toContain("NODE"); + expect(all).not.toContain("Node"); }); - it("should filter templates by name using the new --where syntax", async () => { + it("should filter templates by name using the --where syntax", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); - const { all, exitCode } = await execa( + const { all, exitCode } = await execute( "bun", [CLI_PATH, "list", "--where", "name:vue"], { @@ -194,16 +206,15 @@ describe("dk list", () => { ); expect(exitCode).toBe(0); - expect(all).toContain("JAVASCRIPT"); expect(all).toContain("vue-basic"); expect(all).not.toContain("react-ts"); - expect(all).not.toContain("NODE"); + expect(all).not.toContain("node-api"); }); - it("should filter templates by alias using the new --where syntax and exact regex match", async () => { + it("should filter templates by alias using the --where syntax and exact regex match", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); - const { all, exitCode } = await execa( + const { all, exitCode } = await execute( "bun", [CLI_PATH, "list", "--where", "alias:/^rt$/"], { @@ -213,7 +224,6 @@ describe("dk list", () => { ); expect(exitCode).toBe(0); - expect(all).toContain("JAVASCRIPT"); expect(all).toContain("react-ts"); expect(all).not.toContain("vue-basic"); }); @@ -221,7 +231,7 @@ describe("dk list", () => { it("should filter templates by substring in packageManager, matching both npm and pnpm", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); - const { all, exitCode } = await execa( + const { all, exitCode } = await execute( "bun", [CLI_PATH, "list", "--where", "pm:npm"], { @@ -236,12 +246,12 @@ describe("dk list", () => { expect(all).not.toContain("node-api"); }); - it("should filter templates by strict packageManager using regex", async () => { + it("should filter templates using multiple clauses (Logical AND)", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); - const { all, exitCode } = await execa( + const { all, exitCode } = await execute( "bun", - [CLI_PATH, "list", "--where", "pm:/^npm$/"], + [CLI_PATH, "list", "--where", "alias:vb", "desc:vue"], { all: true, env: { HOME: globalConfigDir }, @@ -249,16 +259,20 @@ describe("dk list", () => { ); expect(exitCode).toBe(0); - expect(all).toContain("react-ts"); - expect(all).not.toContain("vue-basic"); + expect(all).toContain("vue-basic"); + expect(all).not.toContain("react-ts"); }); - it("should filter templates using multiple clauses (Logical AND)", async () => { + it("should display settings when --settings is used (default mode: local)", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); + await fs.writeJson( + path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + globalConfig, + ); - const { all, exitCode } = await execa( + const { all, exitCode } = await execute( "bun", - [CLI_PATH, "list", "--where", "alias:vb", "desc:vue"], + [CLI_PATH, "list", "--settings"], { all: true, env: { HOME: globalConfigDir }, @@ -266,62 +280,138 @@ describe("dk list", () => { ); expect(exitCode).toBe(0); - expect(all).toContain("vue-basic"); - expect(all).not.toContain("react-ts"); - expect(all).not.toContain("node-api"); + expect(all).toContain("Settings:"); + expect(all).toContain("language"); + expect(all).toContain("es"); + expect(all).toContain("Available Templates:"); }); - it("should show an error if a language is provided but no templates are found for it", async () => { + it("should display settings from merged config when --settings and --all are used", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); - const { all, exitCode } = await execa("bun", [CLI_PATH, "list", "rust"], { - all: true, - env: { HOME: globalConfigDir }, - reject: false, + await fs.writeJson( + path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + globalConfig, + ); + + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "list", "--settings", "--all"], + { + all: true, + env: { HOME: globalConfigDir }, + }, + ); + + expect(exitCode).toBe(0); + expect(all).toContain("Settings:"); + expect(all).toContain("language"); + expect(all).toContain("es"); + expect(all).toContain("Available Templates:"); + }); + + it("should include defaults when --include-defaults is used", async () => { + await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), { + settings: {}, + templates: {}, }); - expect(exitCode).toBe(1); + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "list", "--include-defaults"], + { + all: true, + env: { HOME: globalConfigDir }, + }, + ); + + expect(exitCode).toBe(0); + expect(all).toContain("Available Templates:"); + expect(all).not.toContain("No templates found in the configuration file."); + }); + + it("should show a warning if language is provided but no templates are found for it", async () => { + await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), { + ...localConfig, + templates: { + javascript: { + templates: {}, + }, + }, + }); + + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "list", "javascript"], + { + all: true, + env: { HOME: globalConfigDir }, + }, + ); + + expect(exitCode).toBe(0); expect(all).toContain( - "::[DEV]>> Devkit encountered an unexpected internal issue: Invalid value for Programming Language. Valid options are: javascript", + "No templates found for the 'javascript' language in the config.", ); + expect(all).not.toContain("Available Templates:"); + }); + + it("should show an error if an invalid language is provided (validation error)", async () => { + await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "list", "rust$"], + { + all: true, + env: { HOME: globalConfigDir }, + reject: false, + }, + ); + + expect(exitCode).toBe(1); + expect(all).toContain("Invalid value for Programming Language."); }); - it("should handle a config file with an empty templates section", async () => { - const emptyConfig = { ...localConfig, templates: {} }; - await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), emptyConfig); + it("should handle a config file with an empty templates section (warns)", async () => { + const emptyLocalConfig = { ...localConfig, templates: {} }; + await fs.writeJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + emptyLocalConfig, + ); - const { all, exitCode } = await execa("bun", [CLI_PATH, "list", "--all"], { + const { all, exitCode } = await execute("bun", [CLI_PATH, "list"], { all: true, env: { HOME: globalConfigDir }, }); expect(exitCode).toBe(0); - expect(all).toContain( - "Using templates from local configuration only, as no global configuration was found.", - ); - expect(all).toContain("No templates found in the configuration file."); + expect(all).toContain("No templates found in the configuration."); + expect(all).not.toContain("Available Templates:"); }); - it("should handle both local and global configs being empty", async () => { + it("should handle both local and global configs being empty (warns)", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), { + settings: {}, templates: {}, }); await fs.writeJson(path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), { + settings: {}, templates: {}, }); - const { all, exitCode } = await execa("bun", [CLI_PATH, "list", "--all"], { - all: true, - env: { HOME: globalConfigDir }, - }); + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "list", "--all"], + { + all: true, + env: { HOME: globalConfigDir }, + }, + ); expect(exitCode).toBe(0); - expect(all).toContain("No templates found in the configuration file."); - expect(all).toContain( - "Using templates from both local and global configurations.", - ); + expect(all).toContain("No templates found in the configuration."); }); - describe("dk list (`Table` mode)", () => { + describe("dk list (--mode table)", () => { it("should list templates from local config by default when it exists", async () => { await fs.writeJson( path.join(tempDir, LOCAL_CONFIG_FILE_NAME), @@ -332,7 +422,7 @@ describe("dk list", () => { globalConfig, ); - const { all, exitCode } = await execa( + const { all, exitCode } = await execute( "bun", [CLI_PATH, "list", "--mode", "table"], { @@ -342,8 +432,9 @@ describe("dk list", () => { ); expect(exitCode).toBe(0); - expect(all).toContain("Using local configuration."); + expect(all).toContain("Configuration sources loaded successfully."); expect(all).toContain("Available Templates:"); + expect(all).toContain("Language"); expect(all).toContain("Javascript"); expect(all).toContain("Node"); expect(all).not.toContain("Python"); @@ -351,13 +442,15 @@ describe("dk list", () => { it("should handle both local and global configs being empty", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), { + settings: {}, templates: {}, }); await fs.writeJson(path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), { + settings: {}, templates: {}, }); - const { all, exitCode } = await execa( + const { all, exitCode } = await execute( "bun", [CLI_PATH, "list", "--all", "--mode", "table"], { @@ -367,10 +460,7 @@ describe("dk list", () => { ); expect(exitCode).toBe(0); - expect(all).toContain("No templates found in the configuration file."); - expect(all).toContain( - "Using templates from both local and global configurations.", - ); + expect(all).toContain("No templates found in the configuration."); }); it("should filter templates by name when --where is used in table mode", async () => { @@ -383,7 +473,7 @@ describe("dk list", () => { globalConfig, ); - const { all, exitCode } = await execa( + const { all, exitCode } = await execute( "bun", [CLI_PATH, "list", "--where", "name:vue", "--mode", "table"], { @@ -393,12 +483,104 @@ describe("dk list", () => { ); expect(exitCode).toBe(0); - expect(all).toContain("Using local configuration."); expect(all).toContain("Javascript"); expect(all).toContain("vue-basic"); expect(all).not.toContain("react-ts"); - expect(all).not.toContain("NODE"); - expect(all).not.toContain("PYTHON"); + expect(all).not.toContain("Node"); + expect(all).not.toContain("Python"); + }); + }); + + describe("Include defaults option", () => { + it("should fall back to the `default` config if no local config exists and `--include-defaults` is used", async () => { + await fs.writeJson( + path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + globalConfig, + ); + + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "list", "--include-defaults"], + { + all: true, + env: { HOME: globalConfigDir }, + }, + ); + + expect(exitCode).toBe(0); + expect(all).toContain( + "Modèles disponibles :(y compris les modèles par défaut)", + ); + expect(all).toContain("remix"); + }); + + it("should fall back to the `default` config if no global config exists and `--include-defaults` is used", async () => { + await fs.writeJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + localConfig, + ); + + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "list", "--global", "--include-defaults"], + { + all: true, + env: { HOME: globalConfigDir }, + }, + ); + + expect(exitCode).toBe(0); + expect(all).toContain("Available Templates:"); + expect(all).toContain("remix"); + }); + + it("should use both the `default` config and the local config if exists and `--include-defaults` is used", async () => { + await fs.writeJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + localConfig, + ); + + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "list", "--include-defaults"], + { + all: true, + env: { HOME: globalConfigDir }, + }, + ); + + expect(exitCode).toBe(0); + expect(all).toContain("Available Templates:"); + expect(all).toContain("Javascript"); + expect(all).toContain("remix"); + expect(all).toContain("Node"); + expect(all).toContain("node-api"); + }); + + it("should use both the `default` config and the global config if exists and `--include-defaults` is used", async () => { + await fs.writeJson( + path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + globalConfig, + ); + + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "list", "--global", "--include-defaults"], + { + all: true, + env: { HOME: globalConfigDir }, + }, + ); + + expect(exitCode).toBe(0); + expect(all).toContain( + "Modèles disponibles :(y compris les modèles par défaut)", + ); + expect(all).toContain("Javascript"); + expect(all).toContain("remix"); + expect(all).toContain("Python"); + expect(all).toContain("django"); + expect(all).not.toContain("node-api"); }); }); }); diff --git a/packages/devkit/__tests__/units/commands/config/add.spec.ts b/packages/devkit/__tests__/units/commands/config/add.spec.ts index dbec6ea..94239ed 100644 --- a/packages/devkit/__tests__/units/commands/config/add.spec.ts +++ b/packages/devkit/__tests__/units/commands/config/add.spec.ts @@ -1,16 +1,20 @@ import { vi, describe, it, expect, beforeEach } from "vitest"; import { setupAddCommand } from "../../../../src/commands/config/add.js"; -import { mockSpinner, mocktFn } from "../../../../vitest.setup.js"; +import { mockLogger, mockSpinner, mocktFn } from "../../../../vitest.setup.js"; import { DevkitError } from "../../../../src/utils/errors/base.js"; +import type { + CliConfig, + TextLanguageValues, +} from "../../../../src/utils/schema/schema.js"; const { mockHandleErrorAndExit, - mockReadAndMergeConfigs, + mockReadConfigSources, mockValidateAndSaveTemplate, mockValidateProgrammingLanguage, } = vi.hoisted(() => ({ mockHandleErrorAndExit: vi.fn(), - mockReadAndMergeConfigs: vi.fn(), + mockReadConfigSources: vi.fn(), mockValidateAndSaveTemplate: vi.fn(), mockValidateProgrammingLanguage: vi.fn(), })); @@ -22,7 +26,7 @@ vi.mock("#utils/errors/handler.js", () => ({ })); vi.mock("#core/config/loader.js", () => ({ - readAndMergeConfigs: mockReadAndMergeConfigs, + readConfigSources: mockReadConfigSources, })); vi.mock("#utils/validations/config.js", () => ({ @@ -33,16 +37,36 @@ vi.mock("../../../../src/commands/config/validate-and-save.js", () => ({ validateAndSaveTemplate: mockValidateAndSaveTemplate, })); +const MOCK_DEFAULT_CONFIG: CliConfig = { + settings: { + language: "en", + cacheStrategy: "daily", + defaultPackageManager: "npm", + }, + templates: {}, +}; +const MOCK_LOCAL_CONFIG: CliConfig = { + settings: { + language: "fr", + cacheStrategy: "never-refresh", + defaultPackageManager: "pnpm", + }, + templates: {}, +}; +const MOCK_GLOBAL_CONFIG: CliConfig = { + settings: { + language: "es" as TextLanguageValues, + cacheStrategy: "always-refresh", + defaultPackageManager: "yarn", + }, + templates: {}, +}; + describe("setupAddCommand", () => { let mockConfigCommand: any; - const ADD_DESC_KEY = "commands.template.add.description"; - const DESC_OPT_KEY = "commands.template.add.options.description"; - const LOC_OPT_KEY = "commands.template.add.prompts.location"; - const ALIAS_OPT_KEY = "commands.template.add.options.alias"; - const CACHE_OPT_KEY = "commands.template.add.options.cache"; - const PM_OPT_KEY = "commands.template.add.options.package_manager"; const MISSING_REQUIRED_KEY = "errors.command.missing_required_options"; + const TEMPLATE_ADDING_KEY = "messages.status.template_adding"; beforeEach(() => { vi.clearAllMocks(); @@ -57,6 +81,13 @@ describe("setupAddCommand", () => { return mockConfigCommand; }), }; + mockReadConfigSources.mockResolvedValue({ + local: MOCK_LOCAL_CONFIG, + global: MOCK_GLOBAL_CONFIG, + default: MOCK_DEFAULT_CONFIG, + configFound: true, + }); + mockValidateProgrammingLanguage.mockReturnValue(true); }); it("should set up the add command with correct options and arguments", () => { @@ -64,89 +95,140 @@ describe("setupAddCommand", () => { expect(mockConfigCommand.command).toHaveBeenCalledWith( "add ", ); - expect(mockConfigCommand.alias).toHaveBeenCalledWith("a"); - expect(mockConfigCommand.description).toHaveBeenCalledWith( - mocktFn(ADD_DESC_KEY), - ); - expect(mockConfigCommand.option).toHaveBeenCalledWith( - "-d, --description ", - mocktFn(DESC_OPT_KEY), - "", - ); - expect(mockConfigCommand.option).toHaveBeenCalledWith( - "-o, --location ", - mocktFn(LOC_OPT_KEY), - "", - ); - expect(mockConfigCommand.option).toHaveBeenCalledWith( - "-a, --alias ", - mocktFn(ALIAS_OPT_KEY), - "", - ); - - expect(mockConfigCommand.option).toHaveBeenCalledWith( - "-c, --cache-strategy ", - mocktFn(CACHE_OPT_KEY), - "", - ); - - expect(mockConfigCommand.option).toHaveBeenCalledWith( - "-p, --package-manager ", - mocktFn(PM_OPT_KEY), - "", - ); }); - describe("action handler", () => { + describe("action handler (Targeting Logic)", () => { const defaultCmdOptions = { description: "A simple template", location: "http://example.com/template", alias: "st", cacheStrategy: "network_only", packageManager: "npm", - global: false, }; - it("should process and save a new template with all options", async () => { - const mockConfig = { settings: {}, templates: {} }; - mockReadAndMergeConfigs.mockResolvedValueOnce({ - config: mockConfig, - source: "local", - }); - mockValidateProgrammingLanguage.mockReturnValueOnce(true); + const mockParentCommand = { + parent: { + opts: vi.fn(() => ({ global: false })), + }, + }; + it("should process and save a new template targeting the LOCAL config by default", async () => { setupAddCommand(mockConfigCommand); - await actionFn("typescript", "my-template", defaultCmdOptions); + await actionFn( + "typescript", + "my-template", + defaultCmdOptions, + mockParentCommand, + ); - expect(mockSpinner.start).toHaveBeenCalledOnce(); - expect(mockReadAndMergeConfigs).toHaveBeenCalledWith({ + expect(mockSpinner.start).toHaveBeenCalled(); + expect(mockLogger.spinner).toHaveBeenCalled(); + expect(mockLogger.spinner).toHaveBeenCalledWith( + expect.stringContaining( + mocktFn(TEMPLATE_ADDING_KEY, { templateName: "my-template" }), + ), + ); + + expect(mockReadConfigSources).toHaveBeenCalledWith({ forceGlobal: false, + forceLocal: true, }); - expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith( - "typescript", + + expect(mockValidateAndSaveTemplate).toHaveBeenCalledWith( + expect.objectContaining({ language: "typescript" }), + MOCK_LOCAL_CONFIG, + false, + mockSpinner, + ); + }); + + it("should target the GLOBAL config when the parent command's `--global` flag is set", async () => { + setupAddCommand(mockConfigCommand); + mockParentCommand.parent.opts.mockReturnValue({ global: true }); + + await actionFn( + "python", + "django-app", + defaultCmdOptions, + mockParentCommand as any, + ); + + expect(mockReadConfigSources).toHaveBeenCalledWith({ + forceGlobal: true, + forceLocal: false, + }); + + expect(mockValidateAndSaveTemplate).toHaveBeenCalledWith( + expect.objectContaining({ language: "python" }), + MOCK_GLOBAL_CONFIG, + true, + mockSpinner, + ); + }); + + it("should fallback to DEFAULT config if LOCAL is null and not global mode", async () => { + mockReadConfigSources.mockResolvedValue({ + local: null, + global: MOCK_GLOBAL_CONFIG, + default: MOCK_DEFAULT_CONFIG, + configFound: false, + }); + + setupAddCommand(mockConfigCommand); + mockParentCommand.parent.opts.mockReturnValue({ global: false }); + + await actionFn( + "javascript", + "js-app", + defaultCmdOptions, + mockParentCommand as any, ); + expect(mockValidateAndSaveTemplate).toHaveBeenCalledWith( - { - language: "typescript", - templateName: "my-template", - description: defaultCmdOptions.description, - location: defaultCmdOptions.location, - alias: defaultCmdOptions.alias, - cacheStrategy: defaultCmdOptions.cacheStrategy, - packageManager: defaultCmdOptions.packageManager, - }, - mockConfig, + expect.any(Object), + MOCK_DEFAULT_CONFIG, false, mockSpinner, ); }); - it("should throw DevkitError if description is missing", async () => { + it("should fallback to DEFAULT config if GLOBAL is null and in global mode", async () => { + mockReadConfigSources.mockResolvedValue({ + local: MOCK_LOCAL_CONFIG, + global: null, + default: MOCK_DEFAULT_CONFIG, + configFound: false, + }); + + setupAddCommand(mockConfigCommand); + mockParentCommand.parent.opts.mockReturnValue({ global: true }); + + await actionFn( + "javascript", + "js-app", + defaultCmdOptions, + mockParentCommand as any, + ); + + expect(mockValidateAndSaveTemplate).toHaveBeenCalledWith( + expect.any(Object), + MOCK_DEFAULT_CONFIG, + true, + mockSpinner, + ); + }); + + it("should throw DevkitError if description is missing (required field validation)", async () => { setupAddCommand(mockConfigCommand); const cmdOptions = { ...defaultCmdOptions, description: "" }; - await actionFn("typescript", "my-template", cmdOptions); + await actionFn( + "typescript", + "my-template", + cmdOptions, + mockParentCommand as any, + ); expect(mockHandleErrorAndExit).toHaveBeenCalledOnce(); expect(mockHandleErrorAndExit).toHaveBeenCalledWith( @@ -157,13 +239,19 @@ describe("setupAddCommand", () => { ), mockSpinner, ); + expect(mockReadConfigSources).not.toHaveBeenCalled(); }); - it("should throw DevkitError if location is missing", async () => { + it("should throw DevkitError if location is missing (required field validation)", async () => { setupAddCommand(mockConfigCommand); const cmdOptions = { ...defaultCmdOptions, location: "" }; - await actionFn("typescript", "my-template", cmdOptions); + await actionFn( + "typescript", + "my-template", + cmdOptions, + mockParentCommand as any, + ); expect(mockHandleErrorAndExit).toHaveBeenCalledWith( new DevkitError( @@ -173,42 +261,10 @@ describe("setupAddCommand", () => { ), mockSpinner, ); + expect(mockReadConfigSources).not.toHaveBeenCalled(); }); - it("should handle `global` flag correctly", async () => { - const mockConfig = { settings: {}, templates: {} }; - mockReadAndMergeConfigs.mockResolvedValue({ - config: mockConfig, - source: "global", - }); - mockValidateProgrammingLanguage.mockReturnValue(true); - - setupAddCommand(mockConfigCommand); - await actionFn( - "python", - "django-app", - { - ...defaultCmdOptions, - }, - { - parent: { - opts: vi.fn(() => ({ global: true })), - }, - }, - ); - - expect(mockReadAndMergeConfigs).toHaveBeenCalledWith({ - forceGlobal: true, - }); - expect(mockValidateAndSaveTemplate).toHaveBeenCalledWith( - expect.any(Object), - mockConfig, - true, - mockSpinner, - ); - }); - - it("should handle an invalid language gracefully", async () => { + 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", ); @@ -217,9 +273,14 @@ describe("setupAddCommand", () => { }); setupAddCommand(mockConfigCommand); - await actionFn("invalid-lang", "my-template", defaultCmdOptions); + await actionFn( + "invalid-lang", + "my-template", + defaultCmdOptions, + mockParentCommand as any, + ); - expect(mockReadAndMergeConfigs).not.toHaveBeenCalled(); + expect(mockReadConfigSources).not.toHaveBeenCalled(); expect(mockHandleErrorAndExit).toHaveBeenCalledOnce(); expect(mockHandleErrorAndExit).toHaveBeenCalledWith( @@ -230,10 +291,15 @@ describe("setupAddCommand", () => { it("should handle unexpected errors gracefully", async () => { const mockError = new Error("Unexpected error"); - mockReadAndMergeConfigs.mockRejectedValue(mockError); + mockReadConfigSources.mockRejectedValue(mockError); setupAddCommand(mockConfigCommand); - await actionFn("typescript", "my-template", defaultCmdOptions); + await actionFn( + "typescript", + "my-template", + defaultCmdOptions, + mockParentCommand as any, + ); expect(mockHandleErrorAndExit).toHaveBeenCalledWith( mockError, diff --git a/packages/devkit/__tests__/units/commands/config/index.spec.ts b/packages/devkit/__tests__/units/commands/config/index.spec.ts index 7fb3439..407038f 100644 --- a/packages/devkit/__tests__/units/commands/config/index.spec.ts +++ b/packages/devkit/__tests__/units/commands/config/index.spec.ts @@ -1,9 +1,27 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { setupConfigCommand } from "../../../../src/commands/config/index.js"; import { mockSpinner, mocktFn, mockLogger } from "../../../../vitest.setup.js"; +import type { CliConfig } from "../../../integrations/common.js"; + +const MOCK_LOCAL_CONFIG: CliConfig = { + settings: { language: "en", packageManager: "npm" } as any, + templates: {}, +}; + +const MOCK_GLOBAL_CONFIG: CliConfig = { + settings: { language: "fr", packageManager: "yarn" } as any, + templates: {}, +}; + +const MOCK_CONFIG_SOURCES = { + local: MOCK_LOCAL_CONFIG, + global: MOCK_GLOBAL_CONFIG, + default: null, + configFound: true, +}; const { - mockReadAndMergeConfigs, + mockReadConfigSources, mockHandleNonInteractiveSettingsUpdate, mockHandleErrorAndExit, mockSetupAddCommand, @@ -11,7 +29,7 @@ const { mockSetupUpdateCommand, mockSetupListCommand, } = vi.hoisted(() => ({ - mockReadAndMergeConfigs: vi.fn(), + mockReadConfigSources: vi.fn(), mockHandleNonInteractiveSettingsUpdate: vi.fn(), mockHandleErrorAndExit: vi.fn(), mockSetupAddCommand: vi.fn(), @@ -29,7 +47,7 @@ vi.mock("../../../../src/utils/errors/handler.js", () => ({ })); vi.mock("#core/config/loader.js", () => ({ - readAndMergeConfigs: mockReadAndMergeConfigs, + readConfigSources: mockReadConfigSources, })); vi.mock("../../../../src/commands/config/add.js", () => ({ @@ -55,11 +73,9 @@ describe("setupConfigCommand", () => { let mockAction: (keys: string[], cmdOptions: any) => Promise; const DESC_KEY = "commands.config.command.description"; - const GLOBAL_OPT_KEY = "commands.config.set.option.global"; - const SET_BULK_OPT_KEY = "commands.config.set.option.bulk"; const NO_COMMAND_WARN_KEY = "warnings.no_command_provided"; const SET_SUCCESS_KEY = "messages.success.config_updated"; - const GET_SUCCESS_KEY = "messages.success.config_updated"; + const GET_SUCCESS_KEY = "messages.success.config_read"; const INVALID_FORMAT_KEY = "errors.command.set_invalid_format"; const GET_NOT_FOUND_KEY = "errors.config.get_key_not_found"; const CONFIG_LOADING_KEY = "messages.status.config_loading"; @@ -76,6 +92,7 @@ describe("setupConfigCommand", () => { return mockProgram; }), }; + mockReadConfigSources.mockResolvedValue(MOCK_CONFIG_SOURCES); }); it("should set up the config command with correct options and subcommands", () => { @@ -83,18 +100,7 @@ describe("setupConfigCommand", () => { expect(mockProgram.command).toHaveBeenCalledWith("config [keys...]"); expect(mockProgram.alias).toHaveBeenCalledWith("conf"); - // Updated translation key expect(mockProgram.description).toHaveBeenCalledWith(mocktFn(DESC_KEY)); - expect(mockProgram.option).toHaveBeenCalledWith( - "-g, --global", - mocktFn(GLOBAL_OPT_KEY), - false, - ); - expect(mockProgram.option).toHaveBeenCalledWith( - "-s, --set ", - mocktFn(SET_BULK_OPT_KEY), - false, - ); expect(mockSetupAddCommand).toHaveBeenCalledWith(mockProgram); expect(mockSetupRemoveCommand).toHaveBeenCalledWith(mockProgram); expect(mockSetupUpdateCommand).toHaveBeenCalledWith(mockProgram); @@ -102,54 +108,48 @@ describe("setupConfigCommand", () => { }); describe("action handler", () => { - it("should default to warning if no keys or options are provided (non-interactive mode)", async () => { - const mockConfig = { settings: {}, templates: {} }; - mockReadAndMergeConfigs.mockResolvedValue({ - config: mockConfig, - source: "local", - }); - + it("should default to warning if no keys or options are provided", async () => { setupConfigCommand(mockProgram); await mockAction([], { global: false }); expect(mockSpinner.start).toHaveBeenCalledWith( mockLogger.colors.cyan(mocktFn(CONFIG_LOADING_KEY)), ); - expect(mockReadAndMergeConfigs).toHaveBeenCalledWith({ + + expect(mockReadConfigSources).toHaveBeenCalledWith({ forceGlobal: false, + forceLocal: true, }); - expect(mockSpinner.warn).toHaveBeenCalledOnce(); - expect(mockSpinner.warn).toHaveBeenCalledWith( mocktFn(NO_COMMAND_WARN_KEY), ); }); - it("should call handleNonInteractiveSettingsUpdate for --set flag and succeed", async () => { - mockReadAndMergeConfigs.mockResolvedValue({ - config: {}, - source: "local", - }); - + it("should call handleNonInteractiveSettingsUpdate for --set flag and respect the --global flag", async () => { setupConfigCommand(mockProgram); const cmdOptions = { set: ["language", "typescript", "pm", "bun"], - global: false, + global: true, }; await mockAction([], cmdOptions); + expect(mockReadConfigSources).toHaveBeenCalledWith({ + forceGlobal: true, + forceLocal: false, + }); + expect(mockHandleNonInteractiveSettingsUpdate).toHaveBeenCalledWith( "language", "typescript", - false, + true, ); expect(mockHandleNonInteractiveSettingsUpdate).toHaveBeenCalledWith( "pm", "bun", - false, + true, ); expect(mockSpinner.succeed).toHaveBeenCalledWith( @@ -158,11 +158,6 @@ describe("setupConfigCommand", () => { }); it("should fail for invalid --set format (odd number of arguments)", async () => { - mockReadAndMergeConfigs.mockResolvedValue({ - config: {}, - source: "local", - }); - setupConfigCommand(mockProgram); const cmdOptions = { set: ["language"], global: false }; @@ -170,26 +165,41 @@ describe("setupConfigCommand", () => { expect(mockHandleNonInteractiveSettingsUpdate).not.toHaveBeenCalled(); + expect(mockReadConfigSources).toHaveBeenCalledWith({ + forceGlobal: false, + forceLocal: true, + }); + expect(mockSpinner.fail).toHaveBeenCalledWith( mockLogger.colors.redBright(mocktFn(INVALID_FORMAT_KEY)), ); }); - it("should print a single config value when a key is provided (GET functionality)", async () => { - const mockConfig = { - settings: { language: "typescript" }, - templates: {}, - }; - mockReadAndMergeConfigs.mockResolvedValue({ - config: mockConfig, - source: "local", + it("should print a single config value when a key is provided (GET functionality) from Local by default", async () => { + mockReadConfigSources.mockResolvedValue({ + ...MOCK_CONFIG_SOURCES, + local: { + settings: {}, + templates: { + javascript: {}, + }, + } as unknown as CliConfig, }); setupConfigCommand(mockProgram); - await mockAction(["language"], {}); + await mockAction(["language"], { global: false }); + + expect(mockReadConfigSources).toHaveBeenCalledWith({ + forceGlobal: false, + forceLocal: true, + }); expect(mockLogger.log).toHaveBeenCalledWith( - mockLogger.colors.yellowBold("language") + ": " + "typescript", + mockLogger.colors.yellowBold( + mocktFn("errors.config.get_key_not_found", { + key: "language", + }), + ), ); expect(mockSpinner.succeed).toHaveBeenCalledWith( @@ -197,27 +207,22 @@ describe("setupConfigCommand", () => { ); }); - it("should print multiple config values when multiple keys are provided (GET functionality)", async () => { - const mockConfig = { - settings: { - language: "typescript", - packageManager: "bun", - }, - templates: {}, - }; - mockReadAndMergeConfigs.mockResolvedValue({ - config: mockConfig, - source: "local", + it("should print a single config value when a key is provided (GET functionality) from Global when --global is set", async () => { + mockReadConfigSources.mockResolvedValue({ + ...MOCK_CONFIG_SOURCES, + global: { settings: { language: "fr" }, templates: {} } as CliConfig, }); setupConfigCommand(mockProgram); - await mockAction(["language", "packageManager"], {}); + await mockAction(["language"], { global: true }); + + expect(mockReadConfigSources).toHaveBeenCalledWith({ + forceGlobal: true, + forceLocal: false, + }); expect(mockLogger.log).toHaveBeenCalledWith( - mockLogger.colors.yellowBold("language") + ": " + "typescript", - ); - expect(mockLogger.log).toHaveBeenCalledWith( - mockLogger.colors.yellowBold("packageManager") + ": " + "bun", + mockLogger.colors.yellowBold("language") + ": " + "fr", ); expect(mockSpinner.succeed).toHaveBeenCalledWith( @@ -226,10 +231,9 @@ describe("setupConfigCommand", () => { }); it("should handle a non-existent key gracefully (GET functionality)", async () => { - const mockConfig = { settings: {}, templates: {} }; - mockReadAndMergeConfigs.mockResolvedValue({ - config: mockConfig, - source: "local", + mockReadConfigSources.mockResolvedValue({ + ...MOCK_CONFIG_SOURCES, + local: { settings: {}, templates: {} } as CliConfig, }); setupConfigCommand(mockProgram); @@ -247,9 +251,9 @@ describe("setupConfigCommand", () => { ); }); - it("should handle errors gracefully", async () => { + it("should handle errors gracefully during config loading", async () => { const mockError = new Error("Config read failed"); - mockReadAndMergeConfigs.mockRejectedValue(mockError); + mockReadConfigSources.mockRejectedValue(mockError); setupConfigCommand(mockProgram); await mockAction([], {}); diff --git a/packages/devkit/__tests__/units/commands/config/list.spec.ts b/packages/devkit/__tests__/units/commands/config/list.spec.ts index de1be40..8521a7e 100644 --- a/packages/devkit/__tests__/units/commands/config/list.spec.ts +++ b/packages/devkit/__tests__/units/commands/config/list.spec.ts @@ -2,15 +2,59 @@ import { vi, describe, it, expect, beforeEach } from "vitest"; import { setupListCommand } from "../../../../src/commands/config/list.js"; import { mockSpinner, mockLogger, mocktFn } from "../../../../vitest.setup.js"; import { DevkitError } from "../../../../src/utils/errors/base.js"; +import type { CliConfig } from "../../../../src/utils/schema/schema.js"; + +type AnnotatedTemplate = { + _language: string; + name: string; + description: string; + location: string; +}; + +const MOCK_SETTINGS = { + language: "typescript", + packageManager: "npm", +}; + +const MOCK_CONFIG: CliConfig = { + settings: MOCK_SETTINGS as any, + templates: {}, +}; + +const MOCK_ANNOTATED_TEMPLATES: AnnotatedTemplate[] = [ + { + _language: "javascript", + name: "vue-basic", + description: "A basic Vue template", + location: "https://github.com/vuejs/vue", + }, + { + _language: "typescript", + name: "ts-node", + description: "A simple TS project", + location: "https://github.com/microsoft/TypeScript-Node-Starter", + }, +]; + +const MOCK_CONFIG_SOURCES = { + local: MOCK_CONFIG, + global: MOCK_CONFIG, + default: MOCK_CONFIG, + configFound: true, +}; const { mockHandleErrorAndExit, - mockReadAndMergeConfigs, + mockReadConfigSources, + mockGetMergedConfig, + mockGetAnnotatedTemplates, mockPrintSettings, mockPrintTemplates, } = vi.hoisted(() => ({ mockHandleErrorAndExit: vi.fn(), - mockReadAndMergeConfigs: vi.fn(), + mockReadConfigSources: vi.fn(), + mockGetMergedConfig: vi.fn(), + mockGetAnnotatedTemplates: vi.fn(), mockPrintSettings: vi.fn(), mockPrintTemplates: vi.fn(), })); @@ -25,7 +69,15 @@ vi.mock("#utils/errors/handler.js", () => ({ })); vi.mock("#core/config/loader.js", () => ({ - readAndMergeConfigs: mockReadAndMergeConfigs, + readConfigSources: mockReadConfigSources, +})); + +vi.mock("#core/config/merger.js", () => ({ + getMergedConfig: mockGetMergedConfig, +})); + +vi.mock("#core/template/annotator.js", () => ({ + getAnnotatedTemplates: mockGetAnnotatedTemplates, })); vi.mock("#core/template/printer.js", () => ({ @@ -40,39 +92,19 @@ describe("setupListCommand", () => { const CONFIG_LIST_DESC = "commands.config.list.command.description"; const CONFIG_LIST_ALL_OPT = "commands.config.list.options.all"; + const CONFIG_LIST_DEFAULTS_OPT = "commands.list.options.include_defaults"; + const CONFIG_SOURCE_LOCAL = "messages.status.config_source_local"; const CONFIG_SOURCE_GLOBAL = "messages.status.config_source_global"; const CONFIG_SOURCE_MERGED = "messages.status.config_source_local_and_global"; - const TEMPLATES_NOT_FOUND = "warnings.template_not_found"; + const DEFAULTS_SUFFIX = "messages.status.including_defaults_suffix"; + const TEMPLATES_NOT_FOUND = "warnings.template.not_found"; + const ERR_LOCAL_NOT_FOUND = "errors.config.local_not_found"; const ERR_GLOBAL_NOT_FOUND = "errors.config.global_not_found"; const ERR_MUTUALLY_EXCLUSIVE = "errors.command.mutually_exclusive_options"; const SETTINGS_HEADER = "commands.config.list.settings_header"; const TEMPLATES_HEADER = "commands.config.list.templates_header"; - - const sampleConfig = { - settings: { - language: "typescript", - packageManager: "npm", - }, - templates: { - javascript: { - templates: { - "vue-basic": { - description: "A basic Vue template", - location: "https://github.com/vuejs/vue", - }, - }, - }, - typescript: { - templates: { - "ts-node": { - description: "A simple TS project", - location: "https://github.com/microsoft/TypeScript-Node-Starter", - }, - }, - }, - }, - }; + const NO_CONFIG_FOUND = "warnings.no_config_found"; beforeEach(() => { vi.clearAllMocks(); @@ -88,9 +120,13 @@ describe("setupListCommand", () => { return mockConfigCommand; }), }; + + mockReadConfigSources.mockResolvedValue(MOCK_CONFIG_SOURCES); + mockGetMergedConfig.mockResolvedValue(MOCK_CONFIG); + mockGetAnnotatedTemplates.mockResolvedValue(MOCK_ANNOTATED_TEMPLATES); }); - it("should set up the list command with correct options and arguments", () => { + it("should set up the list command with correct options, including the new --include-defaults flag", () => { setupListCommand(mockConfigCommand); expect(mockConfigCommand.command).toHaveBeenCalledWith("list"); expect(mockConfigCommand.alias).toHaveBeenCalledWith("ls"); @@ -102,155 +138,207 @@ describe("setupListCommand", () => { "-a, --all", mocktFn(CONFIG_LIST_ALL_OPT), ); + expect(mockConfigCommand.option).toHaveBeenCalledWith( + "-d, --include-defaults", + mocktFn(CONFIG_LIST_DEFAULTS_OPT), + false, + ); }); - describe("action handler", () => { - it("should display a local configuration by default", async () => { - mockReadAndMergeConfigs.mockResolvedValueOnce({ - config: structuredClone(sampleConfig), - source: "local", - }); - - setupListCommand(mockConfigCommand); - await actionFn({}, { parent: mockParent }); - - expect(mockSpinner.start).toHaveBeenCalled(); - - expect(mockSpinner.info).toHaveBeenCalledWith( - mocktFn(CONFIG_SOURCE_LOCAL), - ); - - expect(consoleLogSpy).toHaveBeenCalledWith( - mockLogger.colors.bold("\n" + mocktFn(SETTINGS_HEADER)), - ); - expect(mockPrintSettings).toHaveBeenCalledOnce(); - expect(mockPrintSettings).toHaveBeenCalledWith(sampleConfig.settings); - - expect(consoleLogSpy).toHaveBeenCalledWith( - mockLogger.colors.bold("\n" + mocktFn(TEMPLATES_HEADER)), - ); - - expect(mockPrintTemplates).toHaveBeenCalledTimes(2); - expect(mockPrintTemplates).toHaveBeenCalledWith([ - ["javascript", sampleConfig.templates.javascript.templates], - ]); - expect(mockPrintTemplates).toHaveBeenCalledWith([ - ["typescript", sampleConfig.templates.typescript.templates], - ]); - expect(mockSpinner.stop).toHaveBeenCalled(); + it("should display local configuration and templates by default", async () => { + setupListCommand(mockConfigCommand); + await actionFn({}, { parent: mockParent }); + + expect(mockReadConfigSources).toHaveBeenCalledWith({ + forceGlobal: false, + mergeAll: false, + }); + expect(mockGetMergedConfig).toHaveBeenCalledWith(false); + expect(mockGetAnnotatedTemplates).toHaveBeenCalledWith({ + forceGlobal: false, + mergeAll: false, + includeDefaults: false, }); - it("should display a global configuration with --global flag", async () => { - mockReadAndMergeConfigs.mockResolvedValueOnce({ - config: structuredClone(sampleConfig), - source: "global", - }); - mockParent.opts.mockReturnValue({ global: true }); - - setupListCommand(mockConfigCommand); - await actionFn({}, { parent: mockParent }); - - expect(mockSpinner.start).toHaveBeenCalled(); - - expect(mockSpinner.info).toHaveBeenCalledWith( - mocktFn(CONFIG_SOURCE_GLOBAL), - ); - expect(mockPrintSettings).toHaveBeenCalledWith(sampleConfig.settings); - expect(mockPrintTemplates).toHaveBeenCalledWith([ - ["javascript", sampleConfig.templates.javascript.templates], - ]); + expect(mockSpinner.info).toHaveBeenCalledWith(mocktFn(CONFIG_SOURCE_LOCAL)); + + expect(consoleLogSpy).toHaveBeenCalledWith( + mockLogger.colors.bold("\n" + mocktFn(SETTINGS_HEADER)), + ); + expect(mockPrintSettings).toHaveBeenCalledWith(MOCK_SETTINGS); + expect(mockPrintTemplates).toHaveBeenCalledWith( + MOCK_ANNOTATED_TEMPLATES, + [], + "tree", + ); + expect(mockSpinner.stop).toHaveBeenCalledTimes(2); + }); + + it("should display both local and global configs with --all flag", async () => { + setupListCommand(mockConfigCommand); + await actionFn({ all: true }, { parent: mockParent }); + + expect(mockReadConfigSources).toHaveBeenCalledWith({ + forceGlobal: false, + mergeAll: true, + }); + expect(mockGetMergedConfig).toHaveBeenCalledWith(true); + expect(mockGetAnnotatedTemplates).toHaveBeenCalledWith({ + forceGlobal: false, + mergeAll: true, + includeDefaults: false, }); - it("should display both local and global configs with --all flag", async () => { - mockReadAndMergeConfigs.mockResolvedValueOnce({ - config: structuredClone(sampleConfig), - source: "merged", - }); - - setupListCommand(mockConfigCommand); - await actionFn({ all: true }, { parent: mockParent }); - - expect(mockSpinner.start).toHaveBeenCalled(); - - expect(mockSpinner.info).toHaveBeenCalledWith( - mocktFn(CONFIG_SOURCE_MERGED), - ); - expect(mockPrintSettings).toHaveBeenCalledWith(sampleConfig.settings); - expect(mockPrintTemplates).toHaveBeenCalledWith([ - ["javascript", sampleConfig.templates.javascript.templates], - ]); - expect(mockPrintTemplates).toHaveBeenCalledWith([ - ["typescript", sampleConfig.templates.typescript.templates], - ]); + expect(mockSpinner.info).toHaveBeenCalledWith( + mocktFn(CONFIG_SOURCE_MERGED), + ); + }); + + it("should display global configuration when --global flag is used", async () => { + mockParent.opts.mockReturnValue({ global: true }); + + setupListCommand(mockConfigCommand); + await actionFn({}, { parent: mockParent }); + + expect(mockReadConfigSources).toHaveBeenCalledWith({ + forceGlobal: true, + mergeAll: false, + }); + expect(mockGetMergedConfig).toHaveBeenCalledWith(true); + expect(mockGetAnnotatedTemplates).toHaveBeenCalledWith({ + forceGlobal: true, + mergeAll: false, + includeDefaults: false, }); - it("should handle no templates found gracefully", async () => { - mockReadAndMergeConfigs.mockResolvedValueOnce({ - config: { settings: {}, templates: {} }, - source: "local", - }); - - setupListCommand(mockConfigCommand); - await actionFn({}, { parent: mockParent }); - - expect(mockSpinner.start).toHaveBeenCalled(); - expect(mockSpinner.info).toHaveBeenCalledWith( - mocktFn(CONFIG_SOURCE_LOCAL), - ); - expect(mockPrintSettings).toHaveBeenCalledWith({}); - expect(consoleLogSpy).toHaveBeenCalledWith( - mockLogger.colors.yellow(mocktFn(TEMPLATES_NOT_FOUND)), - ); - expect(mockPrintTemplates).not.toHaveBeenCalled(); + expect(mockSpinner.info).toHaveBeenCalledWith( + mocktFn(CONFIG_SOURCE_GLOBAL), + ); + }); + + it("should include defaults and append suffix when --include-defaults flag is used (Default/Local mode)", async () => { + setupListCommand(mockConfigCommand); + await actionFn({ includeDefaults: true }, { parent: mockParent }); + + expect(mockReadConfigSources).toHaveBeenCalledWith({ + forceGlobal: false, + mergeAll: false, }); + expect(mockGetMergedConfig).toHaveBeenCalledWith(true); + expect(mockGetAnnotatedTemplates).toHaveBeenCalledWith({ + forceGlobal: false, + mergeAll: false, + includeDefaults: true, + }); + + expect(mockSpinner.info).toHaveBeenCalledWith( + mocktFn(CONFIG_SOURCE_LOCAL) + mocktFn(DEFAULTS_SUFFIX), + ); + }); + + it("should include defaults and append suffix when --include-defaults is used with --global", async () => { + mockParent.opts.mockReturnValue({ global: true }); + + setupListCommand(mockConfigCommand); + await actionFn({ includeDefaults: true }, { parent: mockParent }); + + expect(mockSpinner.info).toHaveBeenCalledWith( + mocktFn(CONFIG_SOURCE_GLOBAL) + mocktFn(DEFAULTS_SUFFIX), + ); + }); - it("should handle the case when global config is requested but not found", async () => { - mockReadAndMergeConfigs.mockResolvedValueOnce({ - config: structuredClone(sampleConfig), - source: "local", - }); - mockParent.opts.mockReturnValue({ global: true }); - - setupListCommand(mockConfigCommand); - await actionFn({}, { parent: mockParent }); - - expect(mockSpinner.start).toHaveBeenCalled(); - expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - new DevkitError(mocktFn(ERR_GLOBAL_NOT_FOUND)), - mockSpinner, - ); + it("should throw error if global flag is used but global config is missing AND defaults are NOT included", async () => { + mockReadConfigSources.mockResolvedValue({ + local: MOCK_CONFIG, + global: null, + default: MOCK_CONFIG, + configFound: true, }); + mockParent.opts.mockReturnValue({ global: true }); - it("should throw a DevkitError if both --global and --all flags are used", async () => { - setupListCommand(mockConfigCommand); - await actionFn( - { all: true }, - { parent: { opts: () => ({ global: true }) } }, - ); - - expect(mockSpinner.start).toHaveBeenCalled(); - expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - new DevkitError( - mocktFn(ERR_MUTUALLY_EXCLUSIVE, { - options: "global, all", - }), - ), - mockSpinner, - ); + setupListCommand(mockConfigCommand); + await actionFn({}, { parent: mockParent }); + + expect(mockHandleErrorAndExit).toHaveBeenCalledWith( + new DevkitError(mocktFn(ERR_GLOBAL_NOT_FOUND)), + mockSpinner, + ); + }); + + it("should NOT throw error if global flag is used, global config is missing, BUT defaults ARE included", async () => { + mockReadConfigSources.mockResolvedValue({ + local: MOCK_CONFIG, + global: null, + default: MOCK_CONFIG, + configFound: true, }); + mockParent.opts.mockReturnValue({ global: true }); - it("should handle unexpected errors gracefully", async () => { - const mockError = new Error("Unexpected error"); - mockReadAndMergeConfigs.mockRejectedValue(mockError); + setupListCommand(mockConfigCommand); + await actionFn({ includeDefaults: true }, { parent: mockParent }); - setupListCommand(mockConfigCommand); - await actionFn({}, { parent: mockParent }); + expect(mockSpinner.info).toHaveBeenCalledWith( + mocktFn(CONFIG_SOURCE_GLOBAL) + mocktFn(DEFAULTS_SUFFIX), + ); + expect(mockHandleErrorAndExit).not.toHaveBeenCalled(); + }); - expect(mockSpinner.start).toHaveBeenCalled(); - expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - mockError, - mockSpinner, - ); + it("should throw error if no flag is used (default mode) but local config is missing AND defaults are NOT included", async () => { + mockReadConfigSources.mockResolvedValue({ + local: null, + global: MOCK_CONFIG, + default: MOCK_CONFIG, + configFound: true, }); + mockParent.opts.mockReturnValue({ global: false }); // !isGlobal: true + + setupListCommand(mockConfigCommand); + await actionFn({}, { parent: mockParent }); + + expect(mockHandleErrorAndExit).toHaveBeenCalledWith( + new DevkitError(mocktFn(ERR_LOCAL_NOT_FOUND)), + mockSpinner, + ); + }); + + it("should handle no templates found gracefully", async () => { + mockGetAnnotatedTemplates.mockResolvedValue([]); + + setupListCommand(mockConfigCommand); + await actionFn({}, { parent: mockParent }); + + expect(mockPrintSettings).toHaveBeenCalledWith(MOCK_SETTINGS); + + expect(consoleLogSpy).toHaveBeenCalledWith( + mockLogger.colors.yellow(mocktFn(TEMPLATES_NOT_FOUND)), + ); + expect(mockPrintTemplates).not.toHaveBeenCalled(); + }); + + it("should throw a DevkitError if both --global and --all flags are used", async () => { + setupListCommand(mockConfigCommand); + await actionFn( + { all: true }, + { parent: { opts: () => ({ global: true }) } }, + ); + + expect(mockHandleErrorAndExit).toHaveBeenCalledWith( + new DevkitError( + mocktFn(ERR_MUTUALLY_EXCLUSIVE, { + options: "global, all", + }), + ), + mockSpinner, + ); + }); + + it("should handle unexpected errors gracefully", async () => { + const mockError = new Error("Unexpected error"); + mockReadConfigSources.mockRejectedValue(mockError); + + setupListCommand(mockConfigCommand); + await actionFn({}, { parent: mockParent }); + + expect(mockHandleErrorAndExit).toHaveBeenCalledWith(mockError, mockSpinner); }); }); diff --git a/packages/devkit/__tests__/units/commands/config/logic.spec.ts b/packages/devkit/__tests__/units/commands/config/logic.spec.ts index 470ccc6..5de5ce5 100644 --- a/packages/devkit/__tests__/units/commands/config/logic.spec.ts +++ b/packages/devkit/__tests__/units/commands/config/logic.spec.ts @@ -6,9 +6,10 @@ import { import { VALID_CACHE_STRATEGIES } from "../../../../src/utils/schema/schema.js"; import { DevkitError } from "../../../../src/utils/errors/base.js"; import { mocktFn } from "../../../../vitest.setup.js"; +import type { CliConfig } from "../../../../src/utils/schema/schema.js"; const { - mockReadAndMergeConfigs, + mockReadConfigSources, mockSaveGlobalConfig, mockSaveLocalConfig, mockValidateConfigValue, @@ -19,7 +20,7 @@ const { mockValidateCacheStrategy, mockValidateProgrammingLanguage, } = vi.hoisted(() => ({ - mockReadAndMergeConfigs: vi.fn(), + mockReadConfigSources: vi.fn(), mockSaveGlobalConfig: vi.fn(), mockSaveLocalConfig: vi.fn(), mockValidateConfigValue: vi.fn(), @@ -32,7 +33,7 @@ const { })); vi.mock("#core/config/loader.js", () => ({ - readAndMergeConfigs: mockReadAndMergeConfigs, + readConfigSources: mockReadConfigSources, })); vi.mock("#core/config/writer.js", () => ({ @@ -60,31 +61,45 @@ vi.mock("deepmerge", () => ({ default: vi.fn((target, source) => ({ ...target, ...source })), })); -describe("Non-interactive Config Logic", () => { - const baseConfig = { - settings: { - language: "en", - defaultPackageManager: "npm", - }, - templates: { - typescript: { - templates: { - web: { - description: "A web template", - location: "https://example.com/web", - alias: "w", - cacheStrategy: "network_only", - packageManager: "npm", - }, - cli: { - description: "A CLI template", - location: "https://example.com/cli", - }, +const baseConfig: CliConfig = { + settings: { + language: "en", + defaultPackageManager: "npm", + cacheStrategy: "daily", + }, + templates: { + typescript: { + templates: { + web: { + description: "A web template", + location: "https://example.com/web", + alias: "w", + cacheStrategy: "always-refresh", + packageManager: "npm", + }, + cli: { + description: "A CLI template", + location: "https://example.com/cli", }, }, }, - }; + }, +} as const; + +const createMockSources = ( + targetType: "local" | "global" | "default", +): ReturnType => { + const local = targetType === "local" ? structuredClone(baseConfig) : null; + const global = targetType === "global" ? structuredClone(baseConfig) : null; + return Promise.resolve({ + local, + global, + default: structuredClone(baseConfig), + configFound: targetType !== "default", + }); +}; +describe("Non-interactive Config Logic", () => { beforeEach(() => { vi.clearAllMocks(); mockValidateAlias.mockReturnValue(undefined); @@ -96,17 +111,16 @@ describe("Non-interactive Config Logic", () => { describe("handleNonInteractiveSettingsUpdate", () => { it("should update a global setting successfully", async () => { - mockReadAndMergeConfigs.mockResolvedValueOnce({ - config: structuredClone(baseConfig), - source: "global", - }); + mockReadConfigSources.mockImplementation(() => + createMockSources("global"), + ); mockSaveGlobalConfig.mockResolvedValueOnce(undefined); await handleNonInteractiveSettingsUpdate("language", "fr", true); - expect(mockReadAndMergeConfigs).toHaveBeenCalledOnce(); - expect(mockReadAndMergeConfigs).toHaveBeenCalledWith({ + expect(mockReadConfigSources).toHaveBeenCalledWith({ forceGlobal: true, + forceLocal: false, }); expect(mockValidateConfigValue).toHaveBeenCalledOnce(); @@ -118,17 +132,16 @@ describe("Non-interactive Config Logic", () => { }); it("should update a local setting successfully", async () => { - mockReadAndMergeConfigs.mockResolvedValueOnce({ - config: structuredClone(baseConfig), - source: "local", - }); + mockReadConfigSources.mockImplementation(() => + createMockSources("local"), + ); mockSaveLocalConfig.mockResolvedValueOnce(undefined); await handleNonInteractiveSettingsUpdate("language", "fr", false); - expect(mockReadAndMergeConfigs).toHaveBeenCalledOnce(); - expect(mockReadAndMergeConfigs).toHaveBeenCalledWith({ + expect(mockReadConfigSources).toHaveBeenCalledWith({ forceGlobal: false, + forceLocal: true, }); expect(mockValidateConfigValue).toHaveBeenCalledOnce(); @@ -139,10 +152,12 @@ describe("Non-interactive Config Logic", () => { expect(updatedConfig.settings.language).toBe("fr"); }); - it("should throw an error if local config is not found", async () => { - mockReadAndMergeConfigs.mockResolvedValue({ - config: structuredClone(baseConfig), - source: "default", + it("should throw an error if local config is not found (isGlobal=false)", async () => { + mockReadConfigSources.mockResolvedValue({ + local: null, + global: structuredClone(baseConfig), + default: structuredClone(baseConfig), + configFound: true, }); await expect( @@ -151,19 +166,36 @@ describe("Non-interactive Config Logic", () => { new DevkitError(mocktFn("errors.config.local_not_found")), ); - expect(mockReadAndMergeConfigs).toHaveBeenCalledOnce(); - expect(mockReadAndMergeConfigs).toHaveBeenCalledWith({ + expect(mockReadConfigSources).toHaveBeenCalledWith({ forceGlobal: false, + forceLocal: true, }); + expect(mockValidateConfigValue).not.toHaveBeenCalledOnce(); + }); + it("should throw an error if global config is not found (isGlobal=true)", async () => { + mockReadConfigSources.mockResolvedValue({ + local: structuredClone(baseConfig), + global: null, + default: structuredClone(baseConfig), + configFound: true, + }); + + await expect( + handleNonInteractiveSettingsUpdate("language", "fr", true), + ).rejects.toThrow(new DevkitError(mocktFn("errors.config.not_found"))); + + expect(mockReadConfigSources).toHaveBeenCalledWith({ + forceGlobal: true, + forceLocal: false, + }); expect(mockValidateConfigValue).not.toHaveBeenCalledOnce(); }); it("should use the canonical key for an alias", async () => { - mockReadAndMergeConfigs.mockResolvedValue({ - config: structuredClone(baseConfig), - source: "local", - }); + mockReadConfigSources.mockImplementation(() => + createMockSources("local"), + ); mockSaveLocalConfig.mockResolvedValue(undefined); await handleNonInteractiveSettingsUpdate("packageManager", "bun", false); @@ -171,6 +203,7 @@ describe("Non-interactive Config Logic", () => { expect(mockSaveLocalConfig).toHaveBeenCalledOnce(); const updatedConfig = mockSaveLocalConfig.mock.calls[0]![0]; expect(updatedConfig.settings.defaultPackageManager).toBe("bun"); + expect(mockValidateConfigValue).toHaveBeenCalledWith( "defaultPackageManager", "bun", @@ -179,12 +212,14 @@ describe("Non-interactive Config Logic", () => { }); describe("handleNonInteractiveTemplateUpdate", () => { - it("should update a single template property successfully (calls validation)", async () => { - mockReadAndMergeConfigs.mockResolvedValue({ - config: structuredClone(baseConfig), - source: "local", - }); + beforeEach(() => { + mockReadConfigSources.mockImplementation(() => + createMockSources("local"), + ); + mockSaveLocalConfig.mockResolvedValue(undefined); + }); + it("should update a single template property successfully (calls validation)", async () => { await handleNonInteractiveTemplateUpdate( "typescript", "web", @@ -201,12 +236,22 @@ describe("Non-interactive Config Logic", () => { ); }); - it("should delete a template property if value is 'null' (alias)", async () => { - mockReadAndMergeConfigs.mockResolvedValue({ - config: structuredClone(baseConfig), - source: "local", - }); + it("should update a template property by alias name successfully", async () => { + await handleNonInteractiveTemplateUpdate( + "typescript", + "w", + { description: "Updated via alias" }, + false, + ); + + expect(mockSaveLocalConfig).toHaveBeenCalled(); + const updatedConfig = mockSaveLocalConfig.mock.calls[0]![0]; + expect(updatedConfig.templates.typescript.templates.web.description).toBe( + "Updated via alias", + ); + }); + it("should delete a template property if value is 'null' (alias)", async () => { await handleNonInteractiveTemplateUpdate( "typescript", "web", @@ -223,13 +268,24 @@ describe("Non-interactive Config Logic", () => { ).toBeUndefined(); }); - it("should rename a template successfully", async () => { - const initialConfig = structuredClone(baseConfig); - mockReadAndMergeConfigs.mockResolvedValue({ - config: initialConfig, - source: "local", - }); + it("should delete a template property if value is 'null' (cacheStrategy)", async () => { + await handleNonInteractiveTemplateUpdate( + "typescript", + "web", + { cacheStrategy: "null" }, + false, + ); + + expect(mockValidateCacheStrategy).not.toHaveBeenCalled(); + expect(mockSaveLocalConfig).toHaveBeenCalled(); + const updatedConfig = mockSaveLocalConfig.mock.calls[0]![0]; + expect( + updatedConfig.templates.typescript.templates.web.cacheStrategy, + ).toBeUndefined(); + }); + + it("should rename a template successfully", async () => { await handleNonInteractiveTemplateUpdate( "typescript", "web", @@ -246,15 +302,16 @@ describe("Non-interactive Config Logic", () => { }); it("should throw an error if local config is not found", async () => { - mockReadAndMergeConfigs.mockResolvedValue({ - config: structuredClone(baseConfig), - source: "default", + mockReadConfigSources.mockResolvedValue({ + local: null, + global: structuredClone(baseConfig), + default: structuredClone(baseConfig), + configFound: true, }); - mockValidateProgrammingLanguage.mockReturnValueOnce(undefined); await expect( handleNonInteractiveTemplateUpdate( - "javascript", + "typescript", "web", { newName: "new-name" }, false, @@ -263,23 +320,45 @@ describe("Non-interactive Config Logic", () => { new DevkitError(mocktFn("errors.config.local_not_found")), ); - expect(mockReadAndMergeConfigs).toHaveBeenCalledOnce(); - expect(mockReadAndMergeConfigs).toHaveBeenCalledWith({ + expect(mockReadConfigSources).toHaveBeenCalledWith({ forceGlobal: false, + forceLocal: true, }); expect(mockValidateConfigValue).not.toHaveBeenCalledOnce(); }); - it("should throw an error if there is no template inside fro this programming language", async () => { - mockReadAndMergeConfigs.mockResolvedValue({ - config: { - ...structuredClone(baseConfig), - templates: {}, - }, - source: "local", + it("should throw an error if global config is not found (isGlobal=true)", async () => { + mockReadConfigSources.mockResolvedValue({ + local: structuredClone(baseConfig), + global: null, + default: structuredClone(baseConfig), + configFound: true, + }); + + await expect( + handleNonInteractiveTemplateUpdate( + "typescript", + "web", + { newName: "new-name" }, + true, + ), + ).rejects.toThrow(new DevkitError(mocktFn("errors.config.not_found"))); + + expect(mockReadConfigSources).toHaveBeenCalledWith({ + forceGlobal: true, + forceLocal: false, + }); + expect(mockValidateConfigValue).not.toHaveBeenCalledOnce(); + }); + + it("should throw an error if the programming language is not found in templates (but exists in config)", async () => { + mockReadConfigSources.mockResolvedValue({ + local: { ...structuredClone(baseConfig), templates: {} }, + global: null, + default: structuredClone(baseConfig), + configFound: true, }); - mockValidateProgrammingLanguage.mockReturnValueOnce(undefined); await expect( handleNonInteractiveTemplateUpdate( @@ -290,26 +369,12 @@ describe("Non-interactive Config Logic", () => { ), ).rejects.toThrow( new DevkitError( - mocktFn("errors.template.language_not_found", { - language: "javascript", - }), + mocktFn("errors.template.not_found", { template: "web" }), ), ); - - expect(mockReadAndMergeConfigs).toHaveBeenCalledOnce(); - expect(mockReadAndMergeConfigs).toHaveBeenCalledWith({ - forceGlobal: false, - }); - - expect(mockValidateConfigValue).not.toHaveBeenCalledOnce(); }); - it("should throw an error for an invalid template name", async () => { - mockReadAndMergeConfigs.mockResolvedValue({ - config: structuredClone(baseConfig), - source: "local", - }); - + it("should throw an error for an invalid template name (template not found)", async () => { await expect( handleNonInteractiveTemplateUpdate("typescript", "unknown", {}, false), ).rejects.toThrow( @@ -320,11 +385,6 @@ describe("Non-interactive Config Logic", () => { }); it("should throw an error for an invalid cache strategy value", async () => { - mockReadAndMergeConfigs.mockResolvedValue({ - config: structuredClone(baseConfig), - source: "local", - }); - const cacheStrategyError = new DevkitError( mocktFn("errors.validation.invalid_cache_strategy", { value: "invalid_strategy", @@ -349,14 +409,9 @@ describe("Non-interactive Config Logic", () => { }); it("should throw an error for an invalid location value", async () => { - mockReadAndMergeConfigs.mockResolvedValue({ - config: structuredClone(baseConfig), - source: "local", - }); - const locationError = new DevkitError( mocktFn("errors.validation.invalid_location", { - value: "location", + value: "invalid_url", }), ); mockValidateLocation.mockImplementationOnce(() => { @@ -367,23 +422,14 @@ describe("Non-interactive Config Logic", () => { handleNonInteractiveTemplateUpdate( "typescript", "web", - { - description: "A great description", - cacheStrategy: "daily" as "null", - location: "location", - }, + { location: "invalid_url" }, false, ), ).rejects.toThrow(locationError); - expect(mockValidateLocation).toHaveBeenCalledWith("location"); + expect(mockValidateLocation).toHaveBeenCalledWith("invalid_url"); }); it("should throw an error for an invalid alias value", async () => { - mockReadAndMergeConfigs.mockResolvedValue({ - config: structuredClone(baseConfig), - source: "local", - }); - const aliasError = new DevkitError( mocktFn("errors.validation.invalid_alias", { alias: "a", @@ -397,12 +443,7 @@ describe("Non-interactive Config Logic", () => { handleNonInteractiveTemplateUpdate( "typescript", "web", - { - description: "A great description", - cacheStrategy: "daily" as "null", - location: "location", - alias: "a", - }, + { alias: "a" }, false, ), ).rejects.toThrow(aliasError); @@ -410,11 +451,6 @@ describe("Non-interactive Config Logic", () => { }); it("should throw an error for an invalid 'packageManager' value", async () => { - mockReadAndMergeConfigs.mockResolvedValue({ - config: structuredClone(baseConfig), - source: "local", - }); - const pmError = new DevkitError( mocktFn("errors.validation.invalid_pm", { packageManager: "pm", @@ -428,13 +464,7 @@ describe("Non-interactive Config Logic", () => { handleNonInteractiveTemplateUpdate( "typescript", "web", - { - description: "A great description", - cacheStrategy: "daily" as "null", - location: "location", - alias: "a", - packageManager: "pm", - }, + { packageManager: "pm" }, false, ), ).rejects.toThrow(pmError); @@ -442,11 +472,6 @@ describe("Non-interactive Config Logic", () => { }); it("should throw an error if the new name already exists", async () => { - mockReadAndMergeConfigs.mockResolvedValue({ - config: structuredClone(baseConfig), - source: "local", - }); - await expect( handleNonInteractiveTemplateUpdate( "typescript", diff --git a/packages/devkit/__tests__/units/commands/config/remove.spec.ts b/packages/devkit/__tests__/units/commands/config/remove.spec.ts index 97c69b3..414de7a 100644 --- a/packages/devkit/__tests__/units/commands/config/remove.spec.ts +++ b/packages/devkit/__tests__/units/commands/config/remove.spec.ts @@ -2,16 +2,17 @@ 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 } from "../../../../src/utils/schema/schema.js"; const { mockHandleErrorAndExit, - mockReadAndMergeConfigs, + mockReadConfigSources, mockSaveGlobalConfig, mockSaveLocalConfig, mockValidateProgrammingLanguage, } = vi.hoisted(() => ({ mockHandleErrorAndExit: vi.fn(), - mockReadAndMergeConfigs: vi.fn(), + mockReadConfigSources: vi.fn(), mockSaveGlobalConfig: vi.fn(), mockSaveLocalConfig: vi.fn(), mockValidateProgrammingLanguage: vi.fn(), @@ -24,7 +25,7 @@ vi.mock("#utils/errors/handler.js", () => ({ })); vi.mock("#core/config/loader.js", () => ({ - readAndMergeConfigs: mockReadAndMergeConfigs, + readConfigSources: mockReadConfigSources, })); vi.mock("#core/config/writer.js", () => ({ @@ -39,34 +40,51 @@ vi.mock("#utils/validations/config.js", () => ({ const CMD_DESCRIPTION_KEY = "commands.template.remove.command.description"; const STATUS_REMOVING_KEY = "messages.status.template_removing"; const SUCCESS_REMOVED_KEY = "messages.success.template_removed"; -const WARNING_NOT_FOUND_KEY = "warnings.templates_not_found"; +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"; -describe("setupRemoveCommand", () => { - let mockConfigCommand: any; - - const sampleConfig = { - settings: {}, +const defaultTemplateConfig: CliConfig["templates"] = { + javascript: { 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", - }, - }, + "vue-basic": { + description: "A basic Vue template", + location: "https://github.com/vuejs/vue", + alias: "vb", }, - typescript: { - templates: {}, + "react-basic": { + description: "A basic React template", + location: "https://github.com/facebook/react", }, }, - }; + }, + typescript: { + templates: {}, + }, +}; + +const sampleConfig: CliConfig = { + settings: {} as CliConfig["settings"], + templates: defaultTemplateConfig, +}; + +const createMockSources = ( + targetType: "local" | "global" | "default", +): 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; beforeEach(() => { vi.clearAllMocks(); @@ -81,6 +99,7 @@ describe("setupRemoveCommand", () => { return mockConfigCommand; }), }; + mockValidateProgrammingLanguage.mockReturnValue(true); }); it("should set up the remove command with correct options and arguments", () => { @@ -88,7 +107,6 @@ describe("setupRemoveCommand", () => { expect(mockConfigCommand.command).toHaveBeenCalledWith( "remove ", ); - expect(mockConfigCommand.alias).toHaveBeenCalledWith("rm"); expect(mockConfigCommand.description).toHaveBeenCalledWith( mocktFn(CMD_DESCRIPTION_KEY), @@ -114,22 +132,25 @@ describe("setupRemoveCommand", () => { ); }; - it("should remove a template by its name", async () => { - const initialConfig = structuredClone(sampleConfig); - mockReadAndMergeConfigs.mockResolvedValueOnce({ - config: structuredClone(initialConfig), - source: "local", - }); - mockValidateProgrammingLanguage.mockReturnValue(true); + it("should remove a template by its name from local config", async () => { + mockReadConfigSources.mockImplementation(() => + createMockSources("local"), + ); setupRemoveCommand(mockConfigCommand); await callAction("javascript", ["vue-basic"], false); + expect(mockReadConfigSources).toHaveBeenCalledWith({ + forceGlobal: false, + forceLocal: true, + }); + expect(mockSpinner.start).toHaveBeenCalledWith( mockLogger.colors.cyan(mocktFn(STATUS_REMOVING_KEY)), ); - expect(mockSaveLocalConfig).toHaveBeenCalledWith({ - settings: {}, + expect(mockSaveLocalConfig).toHaveBeenCalledOnce(); + const expectedConfig: CliConfig = { + settings: {} as CliConfig["settings"], templates: { javascript: { templates: { @@ -143,7 +164,9 @@ describe("setupRemoveCommand", () => { templates: {}, }, }, - }); + }; + expect(mockSaveLocalConfig).toHaveBeenCalledWith(expectedConfig); + expect(mockSpinner.succeed).toHaveBeenCalledWith( mocktFn(SUCCESS_REMOVED_KEY, { count: "1", @@ -155,33 +178,14 @@ describe("setupRemoveCommand", () => { }); it("should remove a template by its alias", async () => { - const initialConfig = structuredClone(sampleConfig); - mockReadAndMergeConfigs.mockResolvedValueOnce({ - config: initialConfig, - source: "local", - }); - mockValidateProgrammingLanguage.mockReturnValue(true); + mockReadConfigSources.mockImplementation(() => + createMockSources("local"), + ); setupRemoveCommand(mockConfigCommand); await callAction("javascript", ["vb"], false); - expect(mockSaveLocalConfig).toHaveBeenCalledWith( - expect.objectContaining({ - templates: { - javascript: { - templates: { - "react-basic": { - description: "A basic React template", - location: "https://github.com/facebook/react", - }, - }, - }, - typescript: { - templates: {}, - }, - }, - }), - ); + expect(mockSaveLocalConfig).toHaveBeenCalledOnce(); expect(mockSpinner.succeed).toHaveBeenCalledWith( mocktFn(SUCCESS_REMOVED_KEY, { count: "1", @@ -192,12 +196,9 @@ describe("setupRemoveCommand", () => { }); it("should remove multiple templates at once (name and alias)", async () => { - const initialConfig = structuredClone(sampleConfig); - mockReadAndMergeConfigs.mockResolvedValueOnce({ - config: initialConfig, - source: "local", - }); - mockValidateProgrammingLanguage.mockReturnValue(true); + mockReadConfigSources.mockImplementation(() => + createMockSources("local"), + ); setupRemoveCommand(mockConfigCommand); await callAction("javascript", ["vue-basic", "react-basic"], false); @@ -224,33 +225,61 @@ describe("setupRemoveCommand", () => { }); it("should remove from global config when isGlobal is true", async () => { - const initialConfig = structuredClone(sampleConfig); - mockReadAndMergeConfigs.mockResolvedValueOnce({ - config: initialConfig, - source: "global", - }); - mockValidateProgrammingLanguage.mockReturnValue(true); + mockReadConfigSources.mockImplementation(() => + createMockSources("global"), + ); setupRemoveCommand(mockConfigCommand); await callAction("javascript", ["vue-basic"], true); + expect(mockReadConfigSources).toHaveBeenCalledWith({ + forceGlobal: true, + forceLocal: false, + }); expect(mockSaveGlobalConfig).toHaveBeenCalledOnce(); - expect(mockSaveGlobalConfig).toHaveBeenCalledWith( - expect.objectContaining({ - templates: { - javascript: { - templates: { - "react-basic": { - description: "A basic React template", - location: "https://github.com/facebook/react", - }, - }, - }, - typescript: { - templates: {}, - }, - }, - }), + }); + + 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(mockReadConfigSources).toHaveBeenCalledWith({ + forceGlobal: false, + forceLocal: true, + }); + + 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(mockReadConfigSources).toHaveBeenCalledWith({ + forceGlobal: true, + forceLocal: false, + }); + + expect(mockHandleErrorAndExit).toHaveBeenCalledWith( + new DevkitError(mocktFn(ERROR_GLOBAL_NOT_FOUND_KEY)), + mockSpinner, ); }); @@ -259,11 +288,12 @@ describe("setupRemoveCommand", () => { settings: {}, templates: {}, }; - mockReadAndMergeConfigs.mockResolvedValueOnce({ - config: structuredClone(initialConfig), - source: "local", + mockReadConfigSources.mockResolvedValue({ + local: initialConfig, + global: null, + default: structuredClone(sampleConfig), + configFound: true, }); - mockValidateProgrammingLanguage.mockReturnValue(true); setupRemoveCommand(mockConfigCommand); await callAction("python", ["basic-script"], false); @@ -278,12 +308,9 @@ describe("setupRemoveCommand", () => { }); it("should throw an error if none of the provided template names exist", async () => { - const initialConfig = structuredClone(sampleConfig); - mockReadAndMergeConfigs.mockResolvedValueOnce({ - config: initialConfig, - source: "local", - }); - mockValidateProgrammingLanguage.mockReturnValue(true); + mockReadConfigSources.mockImplementation(() => + createMockSources("local"), + ); setupRemoveCommand(mockConfigCommand); await callAction("javascript", ["non-existent", "another-one"], false); @@ -299,12 +326,9 @@ describe("setupRemoveCommand", () => { }); it("should remove existing templates and warn about non-existent ones", async () => { - const initialConfig = structuredClone(sampleConfig); - mockReadAndMergeConfigs.mockResolvedValueOnce({ - config: initialConfig, - source: "local", - }); - mockValidateProgrammingLanguage.mockReturnValue(true); + mockReadConfigSources.mockImplementation(() => + createMockSources("local"), + ); setupRemoveCommand(mockConfigCommand); await callAction("javascript", ["vue-basic", "non-existent"], false); @@ -315,10 +339,7 @@ describe("setupRemoveCommand", () => { templates: { javascript: { templates: { - "react-basic": { - description: "A basic React template", - location: "https://github.com/facebook/react", - }, + "react-basic": expect.any(Object), }, }, typescript: { @@ -345,7 +366,7 @@ describe("setupRemoveCommand", () => { it("should handle unexpected errors gracefully", async () => { const mockError = new Error("Config read failed"); - mockReadAndMergeConfigs.mockRejectedValue(mockError); + mockReadConfigSources.mockRejectedValue(mockError); setupRemoveCommand(mockConfigCommand); await callAction("javascript", ["vue-basic"], false); @@ -366,7 +387,7 @@ describe("setupRemoveCommand", () => { setupRemoveCommand(mockConfigCommand); await callAction("invalid-lang", ["vue-basic"], false); - expect(mockReadAndMergeConfigs).not.toHaveBeenCalled(); + expect(mockReadConfigSources).not.toHaveBeenCalled(); expect(mockHandleErrorAndExit).toHaveBeenCalledWith( mockError, mockSpinner, diff --git a/packages/devkit/__tests__/units/commands/index.spec.ts b/packages/devkit/__tests__/units/commands/index.spec.ts index f7f4a17..c0bd8c6 100644 --- a/packages/devkit/__tests__/units/commands/index.spec.ts +++ b/packages/devkit/__tests__/units/commands/index.spec.ts @@ -9,16 +9,20 @@ const { mockSetupConfigCommand, mockSetupListCommand, mockHandleErrorAndExit, - mockReadAndMergeConfigs, + mockReadConfigSources, mockSetupInfoCommand, + mockLoadTranslations, + mockT, } = vi.hoisted(() => ({ mockSetupInitCommand: vi.fn(), mockSetupNewCommand: vi.fn(), mockSetupConfigCommand: vi.fn(), mockSetupListCommand: vi.fn(), mockHandleErrorAndExit: vi.fn(), - mockReadAndMergeConfigs: vi.fn(), + mockReadConfigSources: vi.fn(), mockSetupInfoCommand: vi.fn(), + mockLoadTranslations: vi.fn().mockResolvedValue(undefined), + mockT: vi.fn((key) => key), })); vi.mock("#commands/init.js", () => ({ @@ -50,40 +54,65 @@ vi.mock("#core/info/project.js", () => ({ })); vi.mock("#core/config/loader.js", () => ({ - readAndMergeConfigs: mockReadAndMergeConfigs, + readConfigSources: mockReadConfigSources, +})); + +vi.mock("#utils/i18n/translation-loader.js", () => ({ + loadTranslations: mockLoadTranslations, +})); + +vi.mock("#utils/i18n/translator.js", () => ({ + t: mockT, })); const warnSpy = mockLogger.warning; const optsSpy = vi.spyOn(mockProgram, "opts"); const parseOptionsSpy = vi.spyOn(mockProgram, "parseOptions"); +const mockLocalConfig: Partial = { + settings: { + language: "fr", + cacheStrategy: "daily", + defaultPackageManager: "bun", + }, +}; +const mockGlobalConfig: Partial = { + settings: { + language: "en", + cacheStrategy: "never-refresh", + defaultPackageManager: "npm", + }, +}; +const mockDefaultConfig: CliConfig = { + settings: { + cacheStrategy: "daily", + defaultPackageManager: "bun", + language: "en", + }, + templates: {}, +} as const; + describe("index.ts (Entry point)", () => { beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); + mockProgram.parse.mockReturnValue(mockProgram); + + mockReadConfigSources.mockResolvedValue({ + local: mockLocalConfig, + global: mockGlobalConfig, + default: mockDefaultConfig, + configFound: true, + }); }); afterEach(() => { vi.useRealTimers(); }); - const mockedConfig: CliConfig = { - settings: { - cacheStrategy: "daily", - defaultPackageManager: "bun", - language: "en", - }, - templates: {}, - } as const; - describe("Initialization", () => { it("should initialize the CLI and set up commands correctly in non-verbose mode", async () => { - mockReadAndMergeConfigs.mockResolvedValueOnce({ - config: { ...mockedConfig }, - source: "local", - }); optsSpy.mockReturnValue({}); - mockProgram.parse.mockReturnValue(mockProgram); await setupAndParse(); await vi.runAllTimersAsync(); @@ -92,99 +121,132 @@ describe("index.ts (Entry point)", () => { expect(mockSpinner.start).toHaveBeenCalledWith(""); expect(mockSpinner.stop).toHaveBeenCalledOnce(); expect(mockSpinner.succeed).not.toHaveBeenCalled(); + + expect(mockLoadTranslations).toHaveBeenCalledWith("fr"); }); it("should display a success message and info spinner in verbose mode", async () => { - mockReadAndMergeConfigs.mockResolvedValueOnce({ - config: { ...mockedConfig }, - source: "local", - }); optsSpy.mockReturnValue({ verbose: true }); - mockProgram.parse.mockReturnValue(mockProgram); await setupAndParse(); await vi.runAllTimersAsync(); expect(parseOptionsSpy).toHaveBeenCalledOnce(); - expect(mockSpinner.start).toHaveBeenCalledWith("Initializing CLI..."); + expect(mockSpinner.start).toHaveBeenCalledWith( + expect.stringContaining("program.status.initializing"), + ); expect(mockSpinner.succeed).toHaveBeenCalledOnce(); + expect(mockSpinner.succeed).toHaveBeenCalledWith( + expect.stringContaining("messages.success.program_initialized"), + ); expect(mockSpinner.stop).toHaveBeenCalled(); + + expect(mockLoadTranslations).toHaveBeenCalledWith("fr"); }); - it("should display a warning if a default config is used (always visible)", async () => { - mockReadAndMergeConfigs.mockResolvedValue({ - config: { ...mockedConfig }, - source: "default", + it("should prioritize local language setting for translations, then global, then null", async () => { + mockReadConfigSources.mockResolvedValueOnce({ + local: { settings: { language: "fr" } }, + global: { settings: { language: "en" } }, + configFound: true, + }); + await setupAndParse(); + await vi.runAllTimersAsync(); + expect(mockLoadTranslations).toHaveBeenCalledWith("fr"); + + vi.clearAllMocks(); + mockReadConfigSources.mockResolvedValueOnce({ + local: null, + global: { settings: { language: "es" } }, + configFound: true, }); - optsSpy.mockReturnValue({}); mockProgram.parse.mockReturnValue(mockProgram); + await setupAndParse(); + await vi.runAllTimersAsync(); + expect(mockLoadTranslations).toHaveBeenCalledWith("es"); + + vi.clearAllMocks(); + mockReadConfigSources.mockResolvedValueOnce({ + local: null, + global: null, + configFound: false, + }); + mockProgram.parse.mockReturnValue(mockProgram); + await setupAndParse(); + await vi.runAllTimersAsync(); + expect(mockLoadTranslations).toHaveBeenCalledWith(null); + }); + + it("should display a warning if configFound is false (always visible)", async () => { + mockReadConfigSources.mockResolvedValue({ + local: null, + global: null, + default: mockDefaultConfig, + configFound: false, + }); + optsSpy.mockReturnValue({}); await setupAndParse(); await vi.runAllTimersAsync(); expect(warnSpy).toHaveBeenCalledOnce(); - expect(warnSpy).toHaveBeenCalledWith("\nwarnings.not_found\n"); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("warnings.not_found"), + ); }); }); describe("Command Setup and Execution", () => { - it("should set up all commands with the correct arguments", async () => { - mockReadAndMergeConfigs.mockResolvedValueOnce({ - config: { ...mockedConfig }, - source: "local", - }); + it("should set up all commands passing ONLY the program object", async () => { optsSpy.mockReturnValueOnce({}); - mockProgram.parse.mockReturnValue(mockProgram); await setupAndParse(); await vi.runAllTimersAsync(); + const expectedArg = { program: mockProgram }; + expect(mockSetupInitCommand).toHaveBeenCalledOnce(); - expect(mockSetupInitCommand).toHaveBeenCalledWith({ - config: mockedConfig, - program: mockProgram, - }); + expect(mockSetupInitCommand).toHaveBeenCalledWith(expectedArg); expect(mockSetupNewCommand).toHaveBeenCalledOnce(); - expect(mockSetupNewCommand).toHaveBeenCalledWith({ - config: mockedConfig, - program: mockProgram, - }); + expect(mockSetupNewCommand).toHaveBeenCalledWith(expectedArg); expect(mockSetupConfigCommand).toHaveBeenCalledOnce(); expect(mockSetupConfigCommand).toHaveBeenCalledWith(mockProgram); expect(mockSetupListCommand).toHaveBeenCalledOnce(); - expect(mockSetupListCommand).toHaveBeenCalledWith({ - config: mockedConfig, - program: mockProgram, - }); + expect(mockSetupListCommand).toHaveBeenCalledWith(expectedArg); expect(mockSetupInfoCommand).toHaveBeenCalledOnce(); - expect(mockSetupInfoCommand).toHaveBeenCalledWith({ - config: mockedConfig, - program: mockProgram, - }); + expect(mockSetupInfoCommand).toHaveBeenCalledWith(expectedArg); + + expect(mockProgram.name).toHaveBeenCalledWith("devkit"); + expect(mockProgram.alias).toHaveBeenCalledWith("dk"); + expect(mockProgram.version).toHaveBeenCalledWith( + "1.0.0", + "-V, --version", + "program.version.description", + ); + expect(mockProgram.parse).toHaveBeenCalledOnce(); }); }); describe("Error Handling", () => { it("should handle and exit gracefully on an initialization error", async () => { const testError = new Error("Config load failed"); - mockReadAndMergeConfigs.mockRejectedValue(testError); + mockReadConfigSources.mockRejectedValue(testError); optsSpy.mockReturnValue({}); - mockProgram.parse.mockReturnValue(mockProgram); await setupAndParse(); await vi.runAllTimersAsync(); - expect(mockReadAndMergeConfigs).toHaveBeenCalledOnce(); + expect(mockReadConfigSources).toHaveBeenCalledOnce(); expect(mockHandleErrorAndExit).toHaveBeenCalledWith( testError, mockSpinner, ); - expect(mockSpinner.succeed).not.toHaveBeenCalled(); expect(mockProgram.parse).not.toHaveBeenCalled(); + expect(mockLoadTranslations).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/devkit/__tests__/units/commands/list.spec.ts b/packages/devkit/__tests__/units/commands/list.spec.ts index 618a57b..5d5fd95 100644 --- a/packages/devkit/__tests__/units/commands/list.spec.ts +++ b/packages/devkit/__tests__/units/commands/list.spec.ts @@ -1,72 +1,88 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { setupListCommand } from "../../../src/commands/list"; -import { DevkitError } from "../../../src/utils/errors/base"; -import type { CliConfig } from "../../../src/utils/schema/schema"; +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"; -const sampleLocalConfig: CliConfig = { - settings: { - defaultPackageManager: "npm", - cacheStrategy: "daily", - language: "en", +type AnnotatedTemplate = { + _language: string; + name: string; + description: string; + location: string; + packageManager?: string; + cacheStrategy?: string; +}; + +const MOCK_ANNOTATED_TEMPLATES: AnnotatedTemplate[] = [ + { + _language: "javascript", + name: "javascript-node", + description: "Node.js project template", + location: "/path/to/local/templates/javascript-node", + packageManager: "npm", }, - templates: { - javascript: { - templates: { - "javascript-node": { - description: "Node.js project template", - location: "/path/to/local/templates/javascript-node", - }, - }, - }, + { + _language: "typescript", + name: "typescript-express", + description: "Express.js project template with TypeScript", + location: "/path/to/global/templates/typescript-express", + packageManager: "yarn", }, -}; +]; -const sampleGlobalConfig: CliConfig = { - ...sampleLocalConfig, - templates: { - typescript: { - templates: { - "typescript-express": { - description: "Express.js project template with TypeScript", - location: "/path/to/global/templates/typescript-express", - }, - }, - }, +const MOCK_CLI_CONFIG_WITH_SETTINGS: CliConfig = { + settings: { + defaultPackageManager: "npm" as const, + cacheStrategy: "daily" as const, + language: "en" as const, }, + templates: {}, }; const { - mockReadAndMergeConfigs, + mockGetAnnotatedTemplates, mockPrintTemplates, + mockPrintSettings, + mockGetMergedConfig, mockValidateProgrammingLanguage, mockValidateDisplayMode, mockHandleErrorAndExit, - mockProgram, } = vi.hoisted(() => { return { - mockReadAndMergeConfigs: vi.fn(), + mockGetAnnotatedTemplates: vi.fn(), mockPrintTemplates: vi.fn(), + mockPrintSettings: vi.fn(), + mockGetMergedConfig: vi.fn(), mockValidateProgrammingLanguage: vi.fn(), mockHandleErrorAndExit: vi.fn(), mockValidateDisplayMode: vi.fn(), - mockProgram: { - command: vi.fn().mockReturnThis(), - alias: vi.fn().mockReturnThis(), - description: vi.fn().mockReturnThis(), - argument: vi.fn().mockReturnThis(), - option: vi.fn().mockReturnThis(), - action: vi.fn().mockReturnThis(), - }, }; }); -vi.mock("#core/config/loader.js", () => ({ - readAndMergeConfigs: mockReadAndMergeConfigs, +let actionFn: Function; +const mockProgram = { + command: vi.fn().mockReturnThis(), + alias: vi.fn().mockReturnThis(), + description: vi.fn().mockReturnThis(), + argument: vi.fn().mockReturnThis(), + option: vi.fn().mockReturnThis(), + action: vi.fn((fn) => { + actionFn = fn; + return mockProgram; + }), +}; + +vi.mock("#core/template/annotator.js", () => ({ + getAnnotatedTemplates: mockGetAnnotatedTemplates, })); vi.mock("#core/template/printer.js", () => ({ printTemplates: mockPrintTemplates, + printSettings: mockPrintSettings, +})); + +vi.mock("#core/config/merger.js", () => ({ + getMergedConfig: mockGetMergedConfig, })); vi.mock("#utils/validations/config.js", () => ({ @@ -82,38 +98,29 @@ 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"; const ALL_OPTION_KEY = "commands.list.options.all"; +const SETTINGS_OPTION_KEY = "commands.list.options.settings"; +const INCLUDE_DEFAULTS_OPTION_KEY = "commands.list.options.include_defaults"; const WHERE_OPTION_KEY = "commands.list.command.where.option"; const MODE_OPTION_KEY = "commands.list.command.mode.option"; - -const USING_LOCAL_GLOBAL_KEY = "messages.config_source.using_local_and_global"; -const USING_GLOBAL_KEY = "messages.config_source.global"; -const USING_LOCAL_KEY = "messages.config_source.local"; -const GLOBAL_FALLBACK_KEY = "messages.config_source.global_fallback"; -const TEMPLATE_NOT_FOUND_KEY = "warnings.template_not_found"; 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"; describe("list command", () => { - let actionFn: Function; - beforeEach(() => { vi.clearAllMocks(); - mockProgram.command.mockReturnThis(); - mockProgram.alias.mockReturnThis(); - mockProgram.description.mockReturnThis(); - mockProgram.argument.mockReturnThis(); - mockProgram.option.mockReturnThis(); - mockProgram.action.mockImplementation((fn) => { - actionFn = fn; - }); + mockGetAnnotatedTemplates.mockResolvedValueOnce(MOCK_ANNOTATED_TEMPLATES); + mockGetMergedConfig.mockResolvedValueOnce(MOCK_CLI_CONFIG_WITH_SETTINGS); + mockValidateProgrammingLanguage.mockReturnValueOnce(true); }); - it("should define the list command correctly", () => { + it("should define the list command correctly with new options", () => { setupListCommand({ program: mockProgram }); expect(mockProgram.command).toHaveBeenCalledWith("list"); expect(mockProgram.alias).toHaveBeenCalledWith("ls"); expect(mockProgram.description).toHaveBeenCalledWith(CMD_DESCRIPTION_KEY); + expect(mockProgram.argument).toHaveBeenCalledWith( "[language]", LANG_ARGUMENT_KEY, @@ -127,6 +134,10 @@ describe("list command", () => { "-a, --all", ALL_OPTION_KEY, ); + expect(mockProgram.option).toHaveBeenCalledWith( + "-s, --settings", + SETTINGS_OPTION_KEY, + ); expect(mockProgram.option).toHaveBeenCalledWith( "-w, --where ", WHERE_OPTION_KEY, @@ -136,362 +147,163 @@ describe("list command", () => { MODE_OPTION_KEY, "tree", ); - expect(mockProgram.option).not.toHaveBeenCalledWith( - "-f, --filter ", - expect.any(String), - ); - expect(mockProgram.option).not.toHaveBeenCalledWith( - "-l, --local", - expect.any(String), + expect(mockProgram.option).toHaveBeenCalledWith( + "-d, --include-defaults", + INCLUDE_DEFAULTS_OPTION_KEY, + false, ); }); - describe("display modes", () => { - it("should display both local and global templates with --all flag", async () => { - mockReadAndMergeConfigs.mockResolvedValueOnce({ - config: structuredClone({ - templates: { - ...sampleLocalConfig.templates, - ...sampleGlobalConfig.templates, - }, - }), - source: "merged", - }); - mockValidateProgrammingLanguage.mockReturnValueOnce(true); - mockValidateDisplayMode.mockReturnValueOnce(true); - - setupListCommand({ program: mockProgram }); - await actionFn("", { all: true, mode: "tree" }); - - expect(mockValidateProgrammingLanguage).not.toHaveBeenCalled(); - expect(mockSpinner.start).toHaveBeenCalled(); - expect(mockSpinner.stop).toHaveBeenCalledTimes(2); - expect(mockSpinner.info).toHaveBeenCalledWith(USING_LOCAL_GLOBAL_KEY); - expect(mockPrintTemplates).toHaveBeenCalledWith( - [ - [ - "javascript", - { - "javascript-node": { - description: "Node.js project template", - location: "/path/to/local/templates/javascript-node", - }, - }, - ], - [ - "typescript", - { - "typescript-express": { - description: "Express.js project template with TypeScript", - location: "/path/to/global/templates/typescript-express", - }, - }, - ], - ], - [], - "tree", - ); - }); + it("should call getAnnotatedTemplates with correct flags for default behavior (no options)", async () => { + setupListCommand({ program: mockProgram }); + await actionFn("", { mode: "tree" }); - it("should display only global templates with --global flag", async () => { - mockReadAndMergeConfigs.mockResolvedValueOnce({ - config: structuredClone(sampleGlobalConfig), - source: "global", - }); - mockValidateProgrammingLanguage.mockReturnValueOnce(true); - - setupListCommand({ program: mockProgram }); - await actionFn("", { global: true, mode: "tree" }); - - expect(mockValidateProgrammingLanguage).not.toHaveBeenCalled(); - expect(mockSpinner.start).toHaveBeenCalled(); - expect(mockSpinner.stop).toHaveBeenCalledTimes(2); - expect(mockSpinner.info).toHaveBeenCalledWith(USING_GLOBAL_KEY); - expect(mockPrintTemplates).toHaveBeenCalledWith( - [ - [ - "typescript", - { - "typescript-express": { - description: "Express.js project template with TypeScript", - location: "/path/to/global/templates/typescript-express", - }, - }, - ], - ], - [], - "tree", - ); + expect(mockGetAnnotatedTemplates).toHaveBeenCalledOnce(); + expect(mockGetAnnotatedTemplates).toHaveBeenCalledWith({ + forceGlobal: false, + mergeAll: false, + includeDefaults: false, }); - it("should display only local templates by default when local config exists", async () => { - mockReadAndMergeConfigs.mockResolvedValueOnce({ - config: structuredClone(sampleLocalConfig), - source: "local", - }); - mockValidateProgrammingLanguage.mockReturnValueOnce(true); - mockValidateDisplayMode.mockReturnValueOnce(true); - - setupListCommand({ program: mockProgram, config: sampleLocalConfig }); - await actionFn("", { mode: "table" }); - - expect(mockValidateProgrammingLanguage).not.toHaveBeenCalled(); - expect(mockSpinner.start).toHaveBeenCalled(); - expect(mockSpinner.stop).toHaveBeenCalledTimes(2); - expect(mockSpinner.info).toHaveBeenCalledWith(USING_LOCAL_KEY); - expect(mockPrintTemplates).toHaveBeenCalledWith( - [ - [ - "javascript", - { - "javascript-node": { - description: "Node.js project template", - location: "/path/to/local/templates/javascript-node", - }, - }, - ], - ], - [], - "table", - ); - }); + expect(mockPrintTemplates).toHaveBeenCalledOnce(); + expect(mockPrintTemplates).toHaveBeenCalledWith( + MOCK_ANNOTATED_TEMPLATES, + [], + "tree", + ); - it("should display templates for a specific language (e.g., javascript)", async () => { - mockReadAndMergeConfigs.mockResolvedValueOnce({ - config: structuredClone({ - templates: { - ...sampleLocalConfig.templates, - ...sampleGlobalConfig.templates, - }, - }), - source: "local", - }); - mockValidateProgrammingLanguage.mockReturnValueOnce(true); - - setupListCommand({ program: mockProgram }); - await actionFn("javascript", { - all: false, - global: false, - where: ["name:node"], - mode: "table", - }); - - expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith( - "javascript", - ); - expect(mockSpinner.start).toHaveBeenCalled(); - expect(mockSpinner.stop).toHaveBeenCalledTimes(2); - expect(mockSpinner.info).toHaveBeenCalledWith(USING_LOCAL_KEY); - expect(mockLogger.log).toHaveBeenCalledTimes(1); - expect(mockLogger.log).toHaveBeenCalledWith(`\n${HEADER_KEY}`); - - expect(mockPrintTemplates).toHaveBeenCalledOnce(); - expect(mockPrintTemplates).toHaveBeenCalledWith( - [ - [ - "javascript", - { - "javascript-node": { - description: "Node.js project template", - location: "/path/to/local/templates/javascript-node", - }, - }, - ], - ], - ["name:node"], - "table", - ); - }); + expect(mockLogger.log).toHaveBeenCalled(); + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining(`\n${HEADER_KEY}`), + ); + }); - it("should display global templates if no local templates are found for a language", async () => { - mockReadAndMergeConfigs.mockResolvedValue({ - config: structuredClone({ - templates: { - ...sampleLocalConfig.templates, - }, - }), - source: "global", - }); - mockValidateProgrammingLanguage.mockReturnValue(true); - - setupListCommand({ program: mockProgram }); - await actionFn("javascript", { - all: false, - global: false, - where: ["loc:local"], - mode: "tree", - }); - - expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith( - "javascript", - ); - expect(mockSpinner.start).toHaveBeenCalled(); - expect(mockSpinner.stop).toHaveBeenCalledTimes(2); - expect(mockSpinner.info).toHaveBeenCalledWith(GLOBAL_FALLBACK_KEY); - expect(mockPrintTemplates).toHaveBeenCalledOnce(); - expect(mockPrintTemplates).toHaveBeenCalledWith( - [ - [ - "javascript", - { - "javascript-node": { - description: "Node.js project template", - location: "/path/to/local/templates/javascript-node", - }, - }, - ], - ], - ["loc:local"], - "tree", - ); - }); + it("should pass mergeAll: true to annotator with --all flag", async () => { + setupListCommand({ program: mockProgram }); + await actionFn("", { all: true, mode: "table" }); - it("should display a message if no templates are found", async () => { - mockReadAndMergeConfigs.mockResolvedValue({ - config: structuredClone({ templates: {} }), - source: "empty", - }); - mockValidateProgrammingLanguage.mockReturnValue(true); - - setupListCommand({ program: mockProgram }); - await actionFn("nonexistent", { - all: false, - global: false, - where: [], - mode: "tree", - }); - - expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith( - "nonexistent", - ); - expect(mockSpinner.start).toHaveBeenCalled(); - expect(mockSpinner.succeed).toHaveBeenCalledWith( - mockLogger.colors.yellow( - `${TEMPLATE_NOT_FOUND_KEY}- options template:`, - ), - ); - expect(mockLogger.log).not.toHaveBeenCalled(); - expect(mockPrintTemplates).not.toHaveBeenCalled(); + expect(mockGetAnnotatedTemplates).toHaveBeenCalledWith({ + forceGlobal: false, + mergeAll: true, + includeDefaults: false, }); + expect(mockPrintTemplates).toHaveBeenCalledWith( + MOCK_ANNOTATED_TEMPLATES, + [], + "table", + ); + }); + + it("should pass forceGlobal: true to annotator with --global flag", async () => { + setupListCommand({ program: mockProgram }); + await actionFn("", { global: true, mode: "tree" }); - it("should throw an error when the mode is invalid", async () => { - mockReadAndMergeConfigs.mockResolvedValue({ - config: structuredClone({ - templates: { - ...sampleLocalConfig.templates, - }, - }), - source: "global", - }); - const modeError = new DevkitError("Invalid mode"); - mockValidateProgrammingLanguage.mockReturnValue(true); - mockValidateDisplayMode.mockImplementationOnce(() => { - throw modeError; - }); - - setupListCommand({ program: mockProgram }); - await actionFn("javascript", { - all: false, - global: false, - where: ["cache:daily"], - mode: "folder", - }); - - expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith( - "javascript", - ); - expect(mockSpinner.start).toHaveBeenCalled(); - expect(mockSpinner.stop).toHaveBeenCalledTimes(1); - expect(mockSpinner.info).toHaveBeenCalledWith(GLOBAL_FALLBACK_KEY); - expect(mockPrintTemplates).not.toHaveBeenCalled(); - expect(mockHandleErrorAndExit).toHaveBeenCalledOnce(); - expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - modeError, - mockSpinner, - ); + expect(mockGetAnnotatedTemplates).toHaveBeenCalledWith({ + forceGlobal: true, + mergeAll: false, + includeDefaults: false, }); }); - describe("where option", () => { - beforeEach(() => { - mockReadAndMergeConfigs.mockResolvedValue({ - config: structuredClone({ - templates: { - ...sampleLocalConfig.templates, - ...sampleGlobalConfig.templates, - }, - }), - source: "merged", - }); - mockValidateProgrammingLanguage.mockReturnValue(true); - }); + it("should filter templates by language argument", async () => { + setupListCommand({ program: mockProgram }); + await actionFn("javascript", { mode: "tree" }); - it("should pass the array of where clauses to printTemplates", async () => { - const whereClauses = ["pm:npm", "desc:express"]; - setupListCommand({ program: mockProgram }); - await actionFn("", { - all: true, - global: false, - where: whereClauses, - mode: "tree", - }); - - expect(mockValidateProgrammingLanguage).not.toHaveBeenCalled(); - expect(mockPrintTemplates).toHaveBeenCalledWith( - [ - [ - "javascript", - { - "javascript-node": { - description: "Node.js project template", - location: "/path/to/local/templates/javascript-node", - }, - }, - ], - [ - "typescript", - { - "typescript-express": { - description: "Express.js project template with TypeScript", - location: "/path/to/global/templates/typescript-express", - }, - }, - ], - ], - whereClauses, - "tree", - ); - }); + expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith("javascript"); + + expect(mockPrintTemplates).toHaveBeenCalledWith( + [MOCK_ANNOTATED_TEMPLATES[0]], + [], + "tree", + ); + }); + + it("should pass the 'where' clauses directly to printTemplates", async () => { + const whereClauses = ["pm:npm", "desc:project"]; + setupListCommand({ program: mockProgram }); + await actionFn("", { where: whereClauses, mode: "tree" }); + + expect(mockPrintTemplates).toHaveBeenCalledWith( + MOCK_ANNOTATED_TEMPLATES, + whereClauses, + "tree", + ); + }); - it("should pass an empty array to printTemplates when --where is not used", async () => { - setupListCommand({ program: mockProgram }); - await actionFn("", { - all: true, - global: false, - mode: "tree", - }); - - expect(mockPrintTemplates).toHaveBeenCalledWith( - expect.any(Array), - [], - "tree", - ); + it("should print settings when --settings is used", 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, + ); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining(`\n${SETTINGS_HEADER_KEY}`), + ); + + expect(mockPrintTemplates).toHaveBeenCalledTimes(1); + }); + + it("should pass includeDefaults: true to annotator with --include-defaults flag", async () => { + setupListCommand({ program: mockProgram }); + await actionFn("", { includeDefaults: true, mode: "tree" }); + + expect(mockGetAnnotatedTemplates).toHaveBeenCalledWith({ + forceGlobal: false, + mergeAll: false, + includeDefaults: true, }); }); - describe("error handling", () => { - it("should throw a DevkitError if both --global and --all flags are used", async () => { - setupListCommand({ program: mockProgram }); - await actionFn("", { global: true, all: true, mode: "table" }); - - expect(mockValidateProgrammingLanguage).not.toHaveBeenCalled(); - expect(mockSpinner.start).toHaveBeenCalled(); - expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - new DevkitError( - `${MUTUALLY_EXCLUSIVE_KEY}- options options:global, all`, - ), - mockSpinner, - ); + it("should throw a DevkitError if both --global and --all flags are used", async () => { + setupListCommand({ program: mockProgram }); + 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), + ); + }); + + it("should display a message if no templates are found (after language filter)", async () => { + setupListCommand({ program: mockProgram }); + + await actionFn("nonexistent", { mode: "tree" }); + + expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith("nonexistent"); + + expect(mockSpinner.succeed).toHaveBeenCalledOnce(); + expect(mockSpinner.succeed).toHaveBeenCalledWith( + "messages.success.config_loaded", + ); + + expect(mockPrintTemplates).not.toHaveBeenCalled(); + expect(mockLogger.log).not.toHaveBeenCalledWith( + expect.stringContaining(`\n${HEADER_KEY}`), + ); + }); + + it("should call handleErrorAndExit for errors during processing (e.g., invalid mode)", async () => { + const modeError = new DevkitError("Invalid mode"); + mockValidateDisplayMode.mockImplementationOnce(() => { + throw modeError; }); + + setupListCommand({ program: mockProgram }); + await actionFn("javascript", { mode: "folder" }); + + expect(mockValidateDisplayMode).toHaveBeenCalledOnce(); + expect(mockValidateDisplayMode).toHaveBeenCalledWith("folder"); + + expect(mockHandleErrorAndExit).toHaveBeenCalledOnce(); + expect(mockHandleErrorAndExit).toHaveBeenCalledWith( + modeError, + expect.any(Object), + ); }); }); diff --git a/packages/devkit/__tests__/units/commands/new.spec.ts b/packages/devkit/__tests__/units/commands/new.spec.ts index ffd5195..1927c06 100644 --- a/packages/devkit/__tests__/units/commands/new.spec.ts +++ b/packages/devkit/__tests__/units/commands/new.spec.ts @@ -1,20 +1,27 @@ import { vi, describe, it, expect, beforeEach } from "vitest"; import { setupNewCommand } from "../../../src/commands/new.js"; import { DevkitError } from "../../../src/utils/errors/base.js"; -import { mockSpinner } from "../../../vitest.setup.js"; +import { mockSpinner, mocktFn } from "../../../vitest.setup.js"; import type { CliConfig } from "../../../src/utils/schema/schema.js"; const { mockHandleErrorAndExit, mockScaffoldProject, mockValidateProgrammingLanguage, + mockGetMergedConfig, } = vi.hoisted(() => ({ mockHandleErrorAndExit: vi.fn(), mockScaffoldProject: vi.fn(), mockValidateProgrammingLanguage: vi.fn(), + mockGetMergedConfig: vi.fn(), })); let actionFn: (...options: unknown[]) => Promise; + +vi.mock("#core/config/merger.js", () => ({ + getMergedConfig: mockGetMergedConfig, +})); + vi.mock("#utils/errors/handler.js", () => ({ handleErrorAndExit: mockHandleErrorAndExit, })); @@ -30,7 +37,6 @@ vi.mock("#utils/validations/config.js", () => ({ const LANGUAGE_NOT_FOUND_KEY = "errors.scaffolding.language_not_found"; 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"; const LANG_ARGUMENT_KEY = "commands.new.project.language.argument"; const NAME_ARGUMENT_KEY = "commands.new.project.name.argument"; @@ -72,11 +78,11 @@ describe("setupNewCommand", () => { }, }; - const emptyConfig = { templates: {}, settings: {} }; - beforeEach(() => { vi.clearAllMocks(); actionFn = vi.fn(); + mockGetMergedConfig.mockResolvedValue(sampleConfig); + mockProgram = { command: vi.fn(() => mockProgram), alias: vi.fn(() => mockProgram), @@ -90,12 +96,9 @@ describe("setupNewCommand", () => { }; }); - it("should set up the new command correctly", () => { - setupNewCommand({ - program: mockProgram, - config: sampleConfig, - source: "local", - }); + it("should set up the new command correctly and use the translator 't'", () => { + setupNewCommand({ program: mockProgram }); + expect(mockProgram.command).toHaveBeenCalledWith("new"); expect(mockProgram.alias).toHaveBeenCalledWith("nw"); expect(mockProgram.description).toHaveBeenCalledWith(CMD_DESCRIPTION_KEY); @@ -113,120 +116,133 @@ describe("setupNewCommand", () => { ); }); - it("should scaffold a project using the specified template name", async () => { - setupNewCommand({ - program: mockProgram, - config: sampleConfig, - source: "local", - }); + it("should scaffold a project using the specified template name and its specific settings", async () => { + setupNewCommand({ program: mockProgram }); const language = "javascript"; const projectName = "react-project"; const templateName = "react-app"; const templateConfig = - sampleConfig.templates?.javascript?.templates[templateName]; + sampleConfig?.templates?.javascript?.templates[templateName]; const cmdOptions = { template: templateName }; await actionFn(language, projectName, cmdOptions); - expect(mockSpinner.start).toHaveBeenCalledOnce(); - expect(mockSpinner.stop).toHaveBeenCalledOnce(); + expect(mockGetMergedConfig).toHaveBeenCalledWith(true); + expect(mockScaffoldProject).toHaveBeenCalledWith({ projectName, templateConfig, - packageManager: "yarn", - cacheStrategy: "always-refresh", + packageManager: templateConfig?.packageManager, + cacheStrategy: templateConfig?.cacheStrategy, }); + + expect(mockSpinner.start).toHaveBeenCalledOnce(); + expect(mockSpinner.stop).toHaveBeenCalled(); expect(mockSpinner.succeed).toHaveBeenCalledWith( `${NEW_PROJECT_SUCCESS_KEY}- options projectName:react-project`, ); expect(mockHandleErrorAndExit).not.toHaveBeenCalled(); }); - it("should scaffold a project using a template alias", async () => { - setupNewCommand({ - program: mockProgram, - config: sampleConfig, - source: "local", - }); + it("should scaffold a project using a template alias and global default settings", async () => { + setupNewCommand({ program: mockProgram }); const language = "javascript"; const projectName = "vue-project"; const templateAlias = "vue"; const templateConfig = - sampleConfig.templates?.javascript?.templates["vue-alias"]; + sampleConfig?.templates?.javascript?.templates["vue-alias"]; const cmdOptions = { template: templateAlias }; await actionFn(language, projectName, cmdOptions); - expect(mockSpinner.start).toHaveBeenCalledOnce(); expect(mockScaffoldProject).toHaveBeenCalledWith({ projectName, templateConfig, - packageManager: "npm", - cacheStrategy: "daily", + packageManager: sampleConfig.settings.defaultPackageManager, + cacheStrategy: sampleConfig.settings.cacheStrategy, }); + expect(mockSpinner.succeed).toHaveBeenCalledWith( `${NEW_PROJECT_SUCCESS_KEY}- options projectName:vue-project`, ); - expect(mockSpinner.stop).toHaveBeenCalledOnce(); expect(mockHandleErrorAndExit).not.toHaveBeenCalled(); }); - it("should throw a DevkitError if the language config is not found", async () => { - setupNewCommand({ - program: mockProgram, - config: emptyConfig, - source: "local", - }); + it("should throw a DevkitError if the language config is not found in the merged config", async () => { + mockGetMergedConfig.mockResolvedValue(sampleConfig); + + setupNewCommand({ program: mockProgram }); const language = "python"; const projectName = "my-python-project"; const cmdOptions = { template: "my-template" }; - const expectedErrorMessage = `${LANGUAGE_NOT_FOUND_KEY}- options language:python`; - const languageError = new DevkitError(expectedErrorMessage); + const expectedErrorMessage = mocktFn(LANGUAGE_NOT_FOUND_KEY, { + language: "python", + }); await actionFn(language, projectName, cmdOptions); expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - languageError, - mockSpinner, + new DevkitError(expectedErrorMessage), + expect.any(Object), ); + expect(mockScaffoldProject).not.toHaveBeenCalled(); }); - it("should throw a DevkitError if the specified template is not found", async () => { - setupNewCommand({ - program: mockProgram, - config: sampleConfig, - source: "local", - }); + it("should throw a DevkitError if the specified template is not found by name or alias", async () => { + setupNewCommand({ program: mockProgram }); const language = "javascript"; const projectName = "my-project"; - const cmdOptions = { template: "non-existent-template" }; + const templateName = "non-existent-template"; + const cmdOptions = { template: templateName }; + + const expectedErrorMessage = mocktFn(TEMPLATE_NOT_FOUND_KEY, { + template: templateName, + }); await actionFn(language, projectName, cmdOptions); expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - new DevkitError( - `${TEMPLATE_NOT_FOUND_KEY}- options template:non-existent-template`, - ), - mockSpinner, + new DevkitError(expectedErrorMessage), + expect.any(Object), ); + expect(mockScaffoldProject).not.toHaveBeenCalled(); }); it("should handle an error during the project scaffolding process", async () => { const mockError = new Error("Scaffolding failed"); mockScaffoldProject.mockRejectedValue(mockError); - setupNewCommand({ - program: mockProgram, - config: sampleConfig, - source: "local", - }); + setupNewCommand({ program: mockProgram }); const language = "javascript"; const projectName = "my-project"; const cmdOptions = { template: "react-app" }; await actionFn(language, projectName, cmdOptions); - expect(mockHandleErrorAndExit).toHaveBeenCalledWith(mockError, mockSpinner); + expect(mockHandleErrorAndExit).toHaveBeenCalledWith( + mockError, + expect.any(Object), + ); + }); + + it("should throw a DevkitError if the language is not valid (before config lookup)", async () => { + mockValidateProgrammingLanguage.mockImplementation(() => { + throw new DevkitError("Invalid language"); + }); + + setupNewCommand({ program: mockProgram }); + const language = "badlang"; + const projectName = "my-project"; + const cmdOptions = { template: "react-app" }; + + await actionFn(language, projectName, cmdOptions); + + expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith(language); + expect(mockHandleErrorAndExit).toHaveBeenCalledWith( + expect.any(DevkitError), + expect.any(Object), + ); + expect(mockGetMergedConfig).not.toHaveBeenCalled(); }); }); diff --git a/packages/devkit/__tests__/units/core/config/finder.spec.ts b/packages/devkit/__tests__/units/core/config/finder.spec.ts new file mode 100644 index 0000000..6875d7e --- /dev/null +++ b/packages/devkit/__tests__/units/core/config/finder.spec.ts @@ -0,0 +1,227 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { + getConfigFilepath, + getConfigPathSources, +} from "../../../../src/core/config/finder.js"; +import { CONFIG_FILE_NAMES } from "../../../../src/utils/schema/schema.js"; +import path from "path"; + +const { + mockFindUp, + mockFindGlobalConfigFile, + mockFindLocalConfigFile, + mockPathExists, +} = vi.hoisted(() => ({ + mockFindUp: vi.fn(), + mockPathExists: vi.fn(), + mockFindGlobalConfigFile: vi.fn(), + mockFindLocalConfigFile: vi.fn(), +})); + +vi.mock("#utils/fs/find-up.js", () => ({ + findUp: mockFindUp, +})); + +vi.mock("../../../../src/core/config/search.js", () => ({ + findGlobalConfigFile: mockFindGlobalConfigFile, + findLocalConfigFile: mockFindLocalConfigFile, +})); + +vi.mock("#utils/fs/file.js", () => ({ + default: { + pathExists: mockPathExists, + }, +})); + +vi.spyOn(process, "cwd").mockReturnValue("/current/working/dir"); + +vi.spyOn(path, "join").mockImplementation((...args) => args.join("/")); + +describe("getConfigFilepath", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return the global config path when isGlobal is true", async () => { + mockFindGlobalConfigFile.mockResolvedValueOnce("/home/user/.devkitrc.json"); + const result = await getConfigFilepath(true); + expect(result).toBe("/home/user/.devkitrc.json"); + expect(mockFindGlobalConfigFile).toHaveBeenCalled(); + expect(mockFindUp).not.toHaveBeenCalled(); + }); + + it("should return an empty string if global config is not found", async () => { + mockFindGlobalConfigFile.mockResolvedValueOnce(null); + const result = await getConfigFilepath(true); + expect(result).toBe(""); + }); + + it("should return the local config path if found", async () => { + mockFindUp.mockResolvedValueOnce("/project/dir/custom-config.json"); + const result = await getConfigFilepath(); + expect(result).toBe("/project/dir/custom-config.json"); + expect(mockFindUp).toHaveBeenCalled(); + expect(mockFindGlobalConfigFile).not.toHaveBeenCalled(); + }); + + it("should return the default path if no local config is found", async () => { + mockFindUp.mockResolvedValueOnce(null); + const result = await getConfigFilepath(); + expect(result).toBe("/current/working/dir/.devkit.json"); + expect(mockFindUp).toHaveBeenCalled(); + expect(mockFindGlobalConfigFile).not.toHaveBeenCalled(); + }); +}); + +describe("getConfigPathSources", () => { + const localConfigPath = "/local/config.json"; + const globalConfigPath = "/global/config.json"; + + beforeEach(() => { + vi.restoreAllMocks(); + mockFindLocalConfigFile.mockResolvedValue(localConfigPath); + mockFindGlobalConfigFile.mockResolvedValue(globalConfigPath); + }); + + const mockConfigExistence = (hasLocal: boolean, hasGlobal: boolean) => { + mockPathExists.mockImplementation(async (path) => { + if (path === localConfigPath) return hasLocal; + if (path === globalConfigPath) return hasGlobal; + return false; + }); + }; + + it("should return BOTH paths when mergeAll is TRUE and both exist", async () => { + mockConfigExistence(true, true); + + const result = await getConfigPathSources({ mergeAll: true }); + + expect(result).toEqual({ + localPath: localConfigPath, + globalPath: globalConfigPath, + }); + }); + + it("should return ONLY localPath when mergeAll is TRUE and only local exists", async () => { + mockConfigExistence(true, false); + + const result = await getConfigPathSources({ mergeAll: true }); + + expect(result).toEqual({ + localPath: localConfigPath, + globalPath: null, + }); + }); + + it("should return ONLY globalPath when mergeAll is TRUE and only global exists", async () => { + mockConfigExistence(false, true); + + const result = await getConfigPathSources({ mergeAll: true }); + + expect(result).toEqual({ + localPath: null, + globalPath: globalConfigPath, + }); + }); + + it("should return NULL paths when mergeAll is TRUE and neither exists", async () => { + mockConfigExistence(false, false); + + const result = await getConfigPathSources({ mergeAll: true }); + + expect(result).toEqual({ + localPath: null, + globalPath: null, + }); + }); + + it("should return ONLY localPath when forceLocal is TRUE and local exists", async () => { + mockConfigExistence(true, true); + + const result = await getConfigPathSources({ forceLocal: true }); + + expect(result).toEqual({ + localPath: localConfigPath, + globalPath: null, + }); + }); + + it("should return NULL paths when forceLocal is TRUE and local does NOT exist", async () => { + mockConfigExistence(false, true); + + const result = await getConfigPathSources({ forceLocal: true }); + + expect(result).toEqual({ + localPath: null, + globalPath: null, + }); + }); + + it("should return ONLY globalPath when forceGlobal is TRUE and global exists", async () => { + mockConfigExistence(true, true); + + const result = await getConfigPathSources({ forceGlobal: true }); + + expect(result).toEqual({ + localPath: null, + globalPath: globalConfigPath, + }); + }); + + it("should return NULL paths when forceGlobal is TRUE and global does NOT exist", async () => { + mockConfigExistence(true, false); + + const result = await getConfigPathSources({ forceGlobal: true }); + + expect(result).toEqual({ + localPath: null, + globalPath: null, + }); + }); + + it("should return ONLY localPath when local exists and no options are set", async () => { + mockConfigExistence(true, true); + + const result = await getConfigPathSources({}); + + expect(result).toEqual({ + localPath: localConfigPath, + globalPath: null, + }); + }); + + it("should return ONLY globalPath when local does NOT exist and global exists", async () => { + mockConfigExistence(false, true); + + const result = await getConfigPathSources({}); + + expect(result).toEqual({ + localPath: null, + globalPath: globalConfigPath, + }); + }); + + it("should return NULL paths when neither local nor global exists and no options are set", async () => { + mockConfigExistence(false, false); + + const result = await getConfigPathSources({}); + + expect(result).toEqual({ + localPath: null, + globalPath: null, + }); + }); + + it("should return NULL paths if findLocalConfigFile/findGlobalConfigFile return null, even if pathExists is true", async () => { + mockFindLocalConfigFile.mockResolvedValue(null); + mockFindGlobalConfigFile.mockResolvedValue(null); + mockConfigExistence(true, true); + + const result = await getConfigPathSources({}); + + expect(result).toEqual({ + localPath: null, + globalPath: null, + }); + }); +}); diff --git a/packages/devkit/__tests__/units/core/config/loader.spec.ts b/packages/devkit/__tests__/units/core/config/loader.spec.ts new file mode 100644 index 0000000..cded70e --- /dev/null +++ b/packages/devkit/__tests__/units/core/config/loader.spec.ts @@ -0,0 +1,151 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { + readConfigSources, + type ConfigurationSources, +} from "../../../../src/core/config/loader.js"; +import { defaultCliConfig } from "../../../../src/utils/schema/schema.js"; + +const { mockFs, mockGetConfigPathSources } = vi.hoisted(() => ({ + mockFs: { + pathExists: vi.fn(), + readJson: vi.fn(), + }, + mockGetConfigPathSources: vi.fn(), +})); + +vi.mock("../../../../src/core/config/finder.js", () => ({ + getConfigPathSources: mockGetConfigPathSources, +})); + +vi.mock("#utils/fs/file.js", () => ({ + default: { + pathExists: mockFs.pathExists, + readJson: mockFs.readJson, + }, +})); + +const mockStructuredClone = vi.fn((obj) => JSON.parse(JSON.stringify(obj))); +vi.stubGlobal("structuredClone", mockStructuredClone); + +describe("Configuration Loader Functions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFs.pathExists.mockResolvedValue(true); + }); + + describe("readConfigSources", () => { + const localConfig = { settings: { language: "fr" } }; + const globalConfig = { settings: { defaultPackageManager: "pnpm" } }; + const localPath = "/project/.devkitrc"; + const globalPath = "/user/.devkitrc"; + + it("should load all three sources (Local, Global, Default) when both config files exist", async () => { + mockGetConfigPathSources.mockResolvedValue({ + localPath: localPath, + globalPath: globalPath, + }); + + mockFs.readJson + .mockResolvedValueOnce(localConfig) + .mockResolvedValueOnce(globalConfig); + + const options = {}; + const sources: ConfigurationSources = await readConfigSources(options); + + expect(mockGetConfigPathSources).toHaveBeenCalledWith(options); + + expect(mockFs.readJson).toHaveBeenCalledWith(localPath); + expect(mockFs.readJson).toHaveBeenCalledWith(globalPath); + expect(mockFs.readJson).toHaveBeenCalledTimes(2); + + expect(sources.local).toEqual(localConfig); + expect(sources.global).toEqual(globalConfig); + expect(sources.default).toEqual(defaultCliConfig); + expect(sources.configFound).toBe(true); + + expect(mockStructuredClone).toHaveBeenCalledTimes(3); + }); + + it("should load only Global and Default when Local path is null/config doesn't exist", async () => { + mockGetConfigPathSources.mockResolvedValue({ + localPath: null, + globalPath: globalPath, + }); + + mockFs.readJson.mockResolvedValueOnce(globalConfig); + + mockFs.pathExists.mockImplementation(async (path) => path === globalPath); + + const sources: ConfigurationSources = await readConfigSources(); + + expect(mockFs.readJson).toHaveBeenCalledWith(globalPath); + expect(mockFs.readJson).toHaveBeenCalledTimes(1); + + expect(sources.local).toBeNull(); + expect(sources.global).toEqual(globalConfig); + expect(sources.default).toEqual(defaultCliConfig); + expect(sources.configFound).toBe(true); + }); + + it("should return null for Local and Global when neither config file exists", async () => { + mockGetConfigPathSources.mockResolvedValue({ + localPath: localPath, + globalPath: globalPath, + }); + + mockFs.pathExists.mockResolvedValue(false); + + const sources: ConfigurationSources = await readConfigSources(); + + expect(mockFs.readJson).not.toHaveBeenCalled(); + + expect(sources.local).toBeNull(); + expect(sources.global).toBeNull(); + expect(sources.default).toEqual(defaultCliConfig); + expect(sources.configFound).toBe(false); + }); + + it("should handle JSON parsing errors gracefully and return null for the corrupted config", async () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + mockGetConfigPathSources.mockResolvedValue({ + localPath: localPath, + globalPath: globalPath, + }); + + mockFs.readJson + .mockRejectedValueOnce(new Error("Invalid JSON")) + .mockResolvedValueOnce(globalConfig); + + const sources: ConfigurationSources = await readConfigSources(); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + `Warning: Failed to parse configuration file at "${localPath}". The file may be invalid.`, + undefined, + ); + + expect(sources.local).toBeNull(); + expect(sources.global).toEqual(globalConfig); + expect(sources.configFound).toBe(true); + + consoleErrorSpy.mockRestore(); + }); + + it("should pass options (e.g., forceGlobal) to getConfigPathSources", async () => { + mockGetConfigPathSources.mockResolvedValue({ + localPath: null, + globalPath: globalPath, + }); + mockFs.readJson.mockResolvedValueOnce(globalConfig); + + const options = { forceGlobal: true }; + await readConfigSources(options); + + expect(mockGetConfigPathSources).toHaveBeenCalledWith({ + forceGlobal: true, + }); + }); + }); +}); diff --git a/packages/devkit/__tests__/units/core/config/merger.spec.ts b/packages/devkit/__tests__/units/core/config/merger.spec.ts new file mode 100644 index 0000000..b931e3a --- /dev/null +++ b/packages/devkit/__tests__/units/core/config/merger.spec.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getMergedConfig } from "../../../../src/core/config/merger.js"; +import { + type CliConfig, + type TemplateConfig, +} from "../../../../src/utils/schema/schema.js"; + +const mockReadConfigSources = vi.hoisted(() => { + return vi.fn(); +}); + +vi.mock("../../../../src/core/config/loader.js", () => ({ + readConfigSources: mockReadConfigSources, +})); + +const LOCATION_MOCK = "mock-location"; +const REQUIRED_SETTINGS_BASE = { + defaultPackageManager: "bun" as const, + cacheStrategy: "daily" as const, + language: "en" as const, +}; + +const createTemplate = ( + name: string, + desc: string, + opts?: Partial, +): TemplateConfig => ({ + description: desc, + location: `${LOCATION_MOCK}/${name}`, + ...opts, +}); + +const MOCK_CONFIG_DEFAULT: CliConfig = { + settings: { + ...REQUIRED_SETTINGS_BASE, + cacheStrategy: "never-refresh", + templateType: "standard", + } as any, + templates: { + javascript: { + templates: { + "default-js": createTemplate("default-js", "D - Default JS", { + cacheStrategy: "always-refresh", + }), + }, + }, + arrays: { + templates: { "def-arr": createTemplate("def-arr", "Default Array Test") }, + } as any, + }, +}; + +const MOCK_CONFIG_GLOBAL: CliConfig = { + settings: { + ...REQUIRED_SETTINGS_BASE, + language: "fr", + } as any, + templates: { + javascript: { + templates: { + "default-js": createTemplate("default-js", "G - Global JS", { + packageManager: "npm", + }), + "global-js": createTemplate("global-js", "G - Global only template"), + }, + }, + arrays: { + templates: { + "glob-arr": createTemplate("glob-arr", "Global Array Test"), + }, + } as any, + }, +}; + +const MOCK_CONFIG_LOCAL: CliConfig = { + settings: { + ...REQUIRED_SETTINGS_BASE, + cacheStrategy: "always-refresh", + } as any, + templates: { + javascript: { + templates: { + "default-js": createTemplate("default-js", "L - Local JS", { + alias: "LJS", + }), + "local-js": createTemplate("local-js", "L - Local only template"), + }, + }, + typescript: { + templates: { + "local-ts": createTemplate("local-ts", "L - Local TS only"), + }, + }, + arrays: { + templates: { "loc-arr": createTemplate("loc-arr", "Local Array Test") }, + }, + }, +}; + +const getMockSources = ( + local: CliConfig | null = MOCK_CONFIG_LOCAL, + global: CliConfig | null = MOCK_CONFIG_GLOBAL, + defaultConfig: CliConfig = MOCK_CONFIG_DEFAULT, +) => ({ + local, + global, + default: defaultConfig, + configFound: true, +}); + +describe("getMergedConfig", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should merge Default, Global, and Local configs with Local having the highest priority (mergeAll: true)", async () => { + mockReadConfigSources.mockResolvedValueOnce(getMockSources()); + + const config = await getMergedConfig(); + + expect(mockReadConfigSources).toHaveBeenCalledWith({ mergeAll: true }); + + expect(config.settings.cacheStrategy).toBe("always-refresh"); + expect(config.settings.language).toBe("en"); + expect(config.settings.language).toBe("en"); + + const templates = config.templates.javascript.templates; + + const defaultJs = templates["default-js"]; + expect(defaultJs.description).toBe("L - Local JS"); + expect(defaultJs.alias).toBe("LJS"); + expect(defaultJs.packageManager).toBe("npm"); + expect(defaultJs.cacheStrategy).toBe("always-refresh"); + + expect(templates["local-js"].description).toBe("L - Local only template"); + + expect(templates["global-js"].description).toBe("G - Global only template"); + + expect(config.templates.typescript.templates["local-ts"]).toBeDefined(); + + expect(Object.keys(config.templates.arrays.templates)).toHaveLength(3); + }); + + it("should pass mergeAll: false to readConfigSources", async () => { + mockReadConfigSources.mockResolvedValueOnce( + getMockSources(null, MOCK_CONFIG_GLOBAL, MOCK_CONFIG_DEFAULT), + ); + + await getMergedConfig(false); + + expect(mockReadConfigSources).toHaveBeenCalledWith({ mergeAll: false }); + }); + + it("should correctly merge Default and Global when Local config is missing (Global > Default)", async () => { + mockReadConfigSources.mockResolvedValueOnce( + getMockSources(null, MOCK_CONFIG_GLOBAL, MOCK_CONFIG_DEFAULT), + ); + + const config = await getMergedConfig(false); + + expect(config.settings.language).toBe("fr"); + expect(config.settings.cacheStrategy).toBe("daily"); + + const templates = config.templates.javascript.templates; + const defaultJs = templates["default-js"]; + expect(defaultJs.description).toBe("G - Global JS"); + expect(defaultJs.packageManager).toBe("npm"); + expect(defaultJs.alias).toBeUndefined(); + + expect(templates["local-js"]).toBeUndefined(); + expect(config.templates.typescript).toBeUndefined(); + }); + + it("should return only the Default config if Local and Global are missing", async () => { + mockReadConfigSources.mockResolvedValueOnce( + getMockSources(null, null, MOCK_CONFIG_DEFAULT), + ); + + const config = await getMergedConfig(false); + + expect(config.settings.cacheStrategy).toBe("never-refresh"); + expect(config.settings.language).toBe("en"); + expect( + config.templates.javascript.templates["default-js"].description, + ).toBe("D - Default JS"); + expect( + config.templates.javascript.templates["default-js"].alias, + ).toBeUndefined(); + expect(config.templates.typescript).toBeUndefined(); + }); + + it("should return an empty object if all config sources are null/missing", async () => { + mockReadConfigSources.mockResolvedValueOnce( + getMockSources(null, null, null as any), + ); + + const config = await getMergedConfig(false); + + expect(config).toEqual({}); + }); +}); diff --git a/packages/devkit/__tests__/units/core/configs/reader.spec.ts b/packages/devkit/__tests__/units/core/config/reader.spec.ts similarity index 100% rename from packages/devkit/__tests__/units/core/configs/reader.spec.ts rename to packages/devkit/__tests__/units/core/config/reader.spec.ts diff --git a/packages/devkit/__tests__/units/core/configs/search.spec.ts b/packages/devkit/__tests__/units/core/config/search.spec.ts similarity index 100% rename from packages/devkit/__tests__/units/core/configs/search.spec.ts rename to packages/devkit/__tests__/units/core/config/search.spec.ts diff --git a/packages/devkit/__tests__/units/core/configs/writer.spec.ts b/packages/devkit/__tests__/units/core/config/writer.spec.ts similarity index 100% rename from packages/devkit/__tests__/units/core/configs/writer.spec.ts rename to packages/devkit/__tests__/units/core/config/writer.spec.ts diff --git a/packages/devkit/__tests__/units/core/configs/finder.spec.ts b/packages/devkit/__tests__/units/core/configs/finder.spec.ts deleted file mode 100644 index 7c3d09d..0000000 --- a/packages/devkit/__tests__/units/core/configs/finder.spec.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { vi, describe, it, expect, beforeEach } from "vitest"; -import { - findConfigPaths, - getConfigFilepath, -} from "../../../../src/core/config/finder.js"; -import { CONFIG_FILE_NAMES } from "../../../../src/utils/schema/schema.js"; - -const { - mockFindUp, - mockFindGlobalConfigFile, - mockFindLocalConfigFile, - mockPathExists, -} = vi.hoisted(() => ({ - mockFindUp: vi.fn(), - mockPathExists: vi.fn(), - mockFindGlobalConfigFile: vi.fn(), - mockFindLocalConfigFile: vi.fn(), -})); - -vi.mock("#utils/fs/find-up.js", () => ({ - findUp: mockFindUp, -})); - -vi.mock("../../../../src/core/config/search.js", () => ({ - findGlobalConfigFile: mockFindGlobalConfigFile, - findLocalConfigFile: mockFindLocalConfigFile, -})); - -vi.mock("#utils/fs/file.js", () => ({ - default: { - pathExists: mockPathExists, - }, -})); - -vi.spyOn(process, "cwd").mockReturnValue("/current/working/dir"); - -describe("getConfigFilepath", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should return the global config path when isGlobal is true", async () => { - mockFindGlobalConfigFile.mockResolvedValueOnce("/home/user/.devkitrc.json"); - const result = await getConfigFilepath(true); - expect(result).toBe("/home/user/.devkitrc.json"); - expect(mockFindGlobalConfigFile).toHaveBeenCalled(); - expect(mockFindUp).not.toHaveBeenCalled(); - }); - - it("should return the local config path if found", async () => { - mockFindUp.mockResolvedValueOnce("/project/dir/custom-config.json"); - const result = await getConfigFilepath(); - expect(result).toBe("/project/dir/custom-config.json"); - expect(mockFindUp).toHaveBeenCalled(); - expect(mockFindGlobalConfigFile).not.toHaveBeenCalled(); - }); - - it("should return the default path if no local config is found", async () => { - mockFindUp.mockResolvedValueOnce(null); - const result = await getConfigFilepath(); - expect(result).toBe("/current/working/dir/" + CONFIG_FILE_NAMES[1]); - expect(mockFindUp).toHaveBeenCalled(); - expect(mockFindGlobalConfigFile).not.toHaveBeenCalled(); - }); -}); - -describe("findConfigPaths", () => { - const localConfigPath = "/local/config.json"; - const globalConfigPath = "/global/config.json"; - - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it("should return both the local and global config paths when `forceGlobal` and `forceLocal` are set to `TRUE` with the source as `merged`", async () => { - mockFindLocalConfigFile.mockResolvedValueOnce(localConfigPath); - mockFindGlobalConfigFile.mockResolvedValueOnce(globalConfigPath); - mockPathExists.mockResolvedValue(true).mockResolvedValue(true); - - const mergedConfig = await findConfigPaths({ - forceGlobal: true, - forceLocal: true, - }); - - expect(mergedConfig).toEqual({ - configFound: true, - source: "merged", - primary: localConfigPath, - secondary: globalConfigPath, - }); - }); - - it("should return both the local and global config paths when `mergeAll` is set to `TRUE` with the source as `merged`", async () => { - mockFindLocalConfigFile.mockResolvedValueOnce(localConfigPath); - mockFindGlobalConfigFile.mockResolvedValueOnce(globalConfigPath); - mockPathExists.mockResolvedValue(true).mockResolvedValue(true); - - const mergedConfig = await findConfigPaths({ - mergeAll: true, - }); - - expect(mergedConfig).toEqual({ - configFound: true, - source: "merged", - primary: localConfigPath, - secondary: globalConfigPath, - }); - }); - - it("should only return the local config when `forceLocal` is set to `TRUE`", async () => { - mockFindLocalConfigFile.mockResolvedValueOnce(localConfigPath); - mockPathExists.mockResolvedValueOnce(true); - - const finalConfigs = await findConfigPaths({ - forceLocal: true, - }); - expect(finalConfigs).toEqual({ - primary: localConfigPath, - secondary: null, - source: "local", - configFound: true, - }); - }); - - it("should only return the global config when `forceGlobal` is set to `TRUE`", async () => { - mockFindGlobalConfigFile.mockResolvedValueOnce(globalConfigPath); - mockPathExists.mockResolvedValueOnce(true); - const finalConfigs = await findConfigPaths({ - forceGlobal: true, - }); - expect(finalConfigs).toEqual({ - primary: globalConfigPath, - secondary: null, - source: "global", - configFound: true, - }); - }); - - it("should only return the local config path when found, global config path is not found and `mergeAll` is not set to `True`", async () => { - mockFindLocalConfigFile.mockResolvedValueOnce(localConfigPath); - mockFindGlobalConfigFile.mockResolvedValueOnce(null); - mockPathExists.mockResolvedValueOnce(true); - const finalConfigs = await findConfigPaths({ - mergeAll: true, - }); - expect(finalConfigs).toEqual({ - primary: localConfigPath, - secondary: null, - source: "local", - configFound: true, - }); - }); - - it("should only return the global config path when found, local config path is not found and `mergeAll` is not set to `True`", async () => { - mockFindLocalConfigFile.mockResolvedValueOnce(null); - mockFindGlobalConfigFile.mockResolvedValueOnce(globalConfigPath); - mockPathExists.mockResolvedValue(false).mockResolvedValue(true); - const finalConfigs = await findConfigPaths({ - mergeAll: true, - }); - expect(finalConfigs).toEqual({ - primary: globalConfigPath, - secondary: null, - source: "global", - configFound: true, - }); - }); - - it("should only return the local config path when found when no param is set", async () => { - mockFindLocalConfigFile.mockResolvedValueOnce(localConfigPath); - mockPathExists.mockResolvedValue(true); - const finalConfigs = await findConfigPaths({}); - expect(finalConfigs).toEqual({ - primary: localConfigPath, - secondary: null, - source: "local", - configFound: true, - }); - }); - - it("should only return the global config path when found when no param is set", async () => { - mockFindLocalConfigFile.mockResolvedValueOnce(null); - mockFindGlobalConfigFile.mockResolvedValueOnce(globalConfigPath); - mockPathExists.mockResolvedValue(false).mockResolvedValue(true); - const finalConfigs = await findConfigPaths({}); - expect(finalConfigs).toEqual({ - primary: globalConfigPath, - secondary: null, - source: "global", - configFound: true, - }); - }); -}); diff --git a/packages/devkit/__tests__/units/core/configs/loader.spec.ts b/packages/devkit/__tests__/units/core/configs/loader.spec.ts deleted file mode 100644 index 38574b8..0000000 --- a/packages/devkit/__tests__/units/core/configs/loader.spec.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { vi, describe, it, expect, beforeEach } from "vitest"; -import { readAndMergeConfigs } from "../../../../src/core/config/loader.js"; -import { defaultCliConfig } from "../../../../src/utils/schema/schema.js"; - -const { mockFs, mockDeepmerge, mockFindConfigPaths } = vi.hoisted(() => ({ - mockFs: { - pathExists: vi.fn(), - readJson: vi.fn(), - }, - mockDeepmerge: vi.fn(), - mockFindConfigPaths: vi.fn(), -})); - -vi.mock("../../../../src/core/config/finder.js", () => ({ - findConfigPaths: mockFindConfigPaths, -})); - -vi.mock("deepmerge", () => ({ - default: mockDeepmerge, -})); - -vi.mock("#utils/fs/file.js", () => ({ - default: { - pathExists: mockFs.pathExists, - readJson: mockFs.readJson, - }, -})); - -describe("Configuration Loader Functions", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockDeepmerge.mockImplementation((x, y) => ({ ...x, ...y })); - }); - - describe("readAndMergeConfigs", () => { - const localConfig = { settings: { language: "fr" } }; - const globalConfig = { settings: { defaultPackageManager: "pnpm" } }; - - it("should load only the local config when it exists", async () => { - mockFindConfigPaths.mockResolvedValue({ - primary: "/local/.devkitrc", - secondary: null, - source: "local", - configFound: true, - }); - mockFs.pathExists.mockResolvedValue(true); - mockFs.readJson.mockResolvedValue(localConfig); - - const { config, source } = await readAndMergeConfigs(); - - expect(mockFindConfigPaths).toHaveBeenCalledWith({}); - expect(mockFs.readJson).toHaveBeenCalledWith("/local/.devkitrc"); - expect(config).toEqual(localConfig); - expect(source).toBe("local"); - }); - - it("should load only the global config when local does not exist", async () => { - mockFindConfigPaths.mockResolvedValue({ - primary: "/global/.devkitrc", - secondary: null, - source: "global", - configFound: true, - }); - mockFs.pathExists.mockResolvedValue(true); - mockFs.readJson.mockResolvedValue(globalConfig); - - const { config, source } = await readAndMergeConfigs(); - - expect(mockFindConfigPaths).toHaveBeenCalledWith({}); - expect(mockFs.readJson).toHaveBeenCalledWith("/global/.devkitrc"); - expect(config).toEqual(globalConfig); - expect(source).toBe("global"); - }); - - it("should merge local and global configs when mergeAll is true", async () => { - mockFindConfigPaths.mockResolvedValue({ - primary: "/local/.devkitrc", - secondary: "/global/.devkitrc", - source: "local", - configFound: true, - }); - mockFs.pathExists.mockResolvedValue(true); - mockFs.readJson - .mockResolvedValueOnce(localConfig) - .mockResolvedValueOnce(globalConfig); - mockDeepmerge - .mockReturnValueOnce(localConfig) - .mockReturnValueOnce({ ...localConfig, ...globalConfig }); - - const { config, source } = await readAndMergeConfigs({ mergeAll: true }); - - expect(mockFindConfigPaths).toHaveBeenCalledWith({ mergeAll: true }); - expect(mockFs.readJson).toHaveBeenCalledTimes(2); - expect(mockFs.readJson).toHaveBeenCalledWith("/local/.devkitrc"); - expect(mockFs.readJson).toHaveBeenCalledWith("/global/.devkitrc"); - expect(config).toEqual({ ...localConfig, ...globalConfig }); - expect(source).toBe("local"); - }); - - it("should use fallback to default config when no config is found and useFallback is true", async () => { - mockFindConfigPaths.mockResolvedValue({ - primary: null, - secondary: null, - source: "default", - configFound: false, - }); - mockDeepmerge.mockReturnValue(defaultCliConfig); - - const { config, source } = await readAndMergeConfigs({ - useFallback: true, - }); - - expect(mockFindConfigPaths).toHaveBeenCalledWith({ useFallback: true }); - expect(mockFs.readJson).not.toHaveBeenCalled(); - expect(mockDeepmerge).toHaveBeenCalledWith(defaultCliConfig, {}); - expect(config).toEqual(defaultCliConfig); - expect(source).toBe("default"); - }); - - it("should return an empty config when no config is found and useFallback is false", async () => { - mockFindConfigPaths.mockResolvedValue({ - primary: null, - secondary: null, - source: "default", - configFound: false, - }); - - const { config, source } = await readAndMergeConfigs(); - - expect(mockFindConfigPaths).toHaveBeenCalledWith({}); - expect(mockFs.readJson).not.toHaveBeenCalled(); - expect(mockDeepmerge).not.toHaveBeenCalled(); - expect(config).toEqual({}); - expect(source).toBe("default"); - }); - - it("should handle JSON parsing errors gracefully", async () => { - const consoleErrorSpy = vi - .spyOn(console, "error") - .mockImplementation(() => {}); - mockFindConfigPaths.mockResolvedValue({ - primary: "/bad/.devkitrc", - secondary: null, - source: "local", - configFound: true, - }); - mockFs.pathExists.mockResolvedValue(true); - mockFs.readJson.mockRejectedValue(new Error("Invalid JSON")); - - const { config, source } = await readAndMergeConfigs(); - - expect(config).toEqual({}); - expect(source).toBe("local"); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Warning: Failed to parse configuration file at "/bad/.devkitrc". The file may be invalid.', - undefined, - ); - consoleErrorSpy.mockRestore(); - }); - - it("should force loading global config", async () => { - mockFindConfigPaths.mockResolvedValue({ - primary: "/global/.devkitrc", - secondary: null, - source: "global", - configFound: true, - }); - mockFs.pathExists.mockResolvedValue(true); - mockFs.readJson.mockResolvedValue(globalConfig); - - const { config, source } = await readAndMergeConfigs({ - forceGlobal: true, - }); - - expect(mockFindConfigPaths).toHaveBeenCalledWith({ forceGlobal: true }); - expect(mockFs.readJson).toHaveBeenCalledWith("/global/.devkitrc"); - expect(config).toEqual(globalConfig); - expect(source).toBe("global"); - }); - }); -}); diff --git a/packages/devkit/__tests__/units/core/info/info.spec.ts b/packages/devkit/__tests__/units/core/info/info.spec.ts index 3c6869a..ee964c6 100644 --- a/packages/devkit/__tests__/units/core/info/info.spec.ts +++ b/packages/devkit/__tests__/units/core/info/info.spec.ts @@ -7,7 +7,7 @@ import { collectSystemInfo } from "../../../../src/core/info/info.js"; import { mocktFn, mockExeca } from "../../../../vitest.setup.js"; const { - mockReadAndMergeConfigs, + mockGetMergedConfig, mockGetPackageManager, mockFindGlobalConfigFile, mockFindLocalConfigFile, @@ -31,7 +31,7 @@ const { const MOCKED_SHELL = "/bin/bash"; return { - mockReadAndMergeConfigs: vi.fn(), + mockGetMergedConfig: vi.fn(), mockGetPackageManager: vi.fn(), mockFindGlobalConfigFile: vi.fn(), mockFindLocalConfigFile: vi.fn(), @@ -52,8 +52,8 @@ const { }; }); -vi.mock("../../../../src/core/config/loader.js", () => ({ - readAndMergeConfigs: mockReadAndMergeConfigs, +vi.mock("../../../../src/core/config/merger.js", () => ({ + getMergedConfig: mockGetMergedConfig, })); vi.mock("#utils/package-manager/index.js", () => ({ @@ -91,9 +91,7 @@ const NEW_PM_NOT_FOUND_KEY = "errors.system.info_package_manager_not_found"; describe("collectSystemInfo", () => { beforeEach(() => { - mockReadAndMergeConfigs.mockResolvedValue({ - config: defaultCliConfig, - }); + mockGetMergedConfig.mockResolvedValue(defaultCliConfig); mockGetPackageManager.mockResolvedValue(null); mockExeca.mockResolvedValue({ stdout: "1.2.3" }); mockFindGlobalConfigFile.mockResolvedValue(null); @@ -117,10 +115,7 @@ describe("collectSystemInfo", () => { expect(info.globalConfig.path).toBe(NEW_GLOBAL_CONFIG_KEY); expect(info.localConfig.path).toBe(NEW_LOCAL_CONFIG_KEY); - expect(mockReadAndMergeConfigs).toHaveBeenCalledWith({ - mergeAll: false, - forceGlobal: false, - }); + expect(mockGetMergedConfig).toHaveBeenCalledWith(true); }); it("should report the exact path and 'exists: true' when both files are found", async () => { @@ -150,11 +145,6 @@ describe("collectSystemInfo", () => { expect(info.cliVersion).toBe(MOCKED_CLI_VERSION); expect(info.runtimeName).toBe("Node.js"); expect(info.runtimeVersion).toBe(MOCKED_NODE_VERSION); - - expect(info.homeDir).toBe(MOCKED_HOME_DIR); - expect(info.os).toBe(`${MOCKED_OS_TYPE} ${MOCKED_OS_RELEASE}`); - expect(info.arch).toBe(MOCKED_ARCH); - expect(info.shell).toBe(MOCKED_SHELL); }); it("should correctly collect and format static system and runtime info in a Bun environment", async () => { @@ -191,7 +181,7 @@ describe("collectSystemInfo", () => { }; it("Priority 1: Should use package manager from config settings (highest priority)", async () => { - mockReadAndMergeConfigs.mockResolvedValueOnce({ config: customConfig }); + mockGetMergedConfig.mockResolvedValueOnce(customConfig); mockGetPackageManager.mockResolvedValueOnce("pnpm"); mockExeca.mockImplementationOnce((cmd) => cmd === "yarn" @@ -206,9 +196,7 @@ describe("collectSystemInfo", () => { }); it("Priority 2: Should use detected package manager when config does not specify one", async () => { - mockReadAndMergeConfigs.mockResolvedValueOnce({ - config: {}, - }); + mockGetMergedConfig.mockResolvedValueOnce(undefined); mockGetPackageManager.mockResolvedValueOnce("pnpm"); mockExeca.mockImplementationOnce((cmd) => cmd === "pnpm" @@ -227,11 +215,23 @@ describe("collectSystemInfo", () => { vi.spyOn(process, "version", "get").mockReturnValue(MOCKED_NODE_VERSION); vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: MOCKED_SHELL }); vi.mock("os", () => ({ default: mockOs })); - - mockReadAndMergeConfigs.mockResolvedValueOnce({ - config: defaultCliConfig, - }); + vi.mock("../../../../src/core/config/merger.js", () => ({ + getMergedConfig: mockGetMergedConfig, + })); + vi.mock("#utils/package-manager/index.js", () => ({ + getPackageManager: mockGetPackageManager, + })); + vi.mock("../../../../src/core/config/search.js", () => ({ + findGlobalConfigFile: mockFindGlobalConfigFile, + findLocalConfigFile: mockFindLocalConfigFile, + })); + + mockExeca.mockResolvedValue({ stdout: "1.2.3" }); + mockFindGlobalConfigFile.mockResolvedValue(null); + mockFindLocalConfigFile.mockResolvedValue(null); + mockGetMergedConfig.mockResolvedValue(defaultCliConfig); mockGetPackageManager.mockResolvedValueOnce(null); + mockExeca.mockImplementationOnce((cmd) => cmd === "bun" ? Promise.resolve({ stdout: "10.2.4" }) @@ -252,7 +252,7 @@ describe("collectSystemInfo", () => { defaultPackageManager: "bun", }, }; - mockReadAndMergeConfigs.mockResolvedValueOnce({ config: bunConfig }); + mockGetMergedConfig.mockResolvedValueOnce(bunConfig); mockExeca.mockRejectedValueOnce(new Error("bun not found")); const info = await collectSystemInfo(MOCKED_CLI_VERSION); diff --git a/packages/devkit/__tests__/units/core/template/annotator.spec.ts b/packages/devkit/__tests__/units/core/template/annotator.spec.ts new file mode 100644 index 0000000..c0b55bb --- /dev/null +++ b/packages/devkit/__tests__/units/core/template/annotator.spec.ts @@ -0,0 +1,281 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getAnnotatedTemplates } from "../../../../src/core/template/annotator.js"; +import { + type CliConfig, + type TextLanguageValues, +} from "../../../../src/utils/schema/schema.js"; +import { type ConfigurationSources } from "../../../../src/core/config/loader.js"; + +const mockReadConfigSources = vi.hoisted(() => { + return vi.fn(); +}); + +vi.mock("../../../../src/core/config/loader.js", () => ({ + readConfigSources: mockReadConfigSources, +})); + +const REQUIRED_SETTINGS_BASE: CliConfig["settings"] = { + defaultPackageManager: "bun", + cacheStrategy: "daily", + language: "en", +}; + +const LOCATION = "/mock/location"; +const DESCRIPTION = "Mock description"; + +const MOCK_TEMPLATE_DEFAULT: CliConfig = { + settings: { + ...REQUIRED_SETTINGS_BASE, + cacheStrategy: "never-refresh", + }, + templates: { + javascript: { + templates: { + "default-js": { + location: LOCATION, + description: DESCRIPTION, + packageManager: "yarn", + }, + "shared-js": { + location: LOCATION, + description: "Shared JS default", + packageManager: "npm", + }, + }, + }, + typescript: { + templates: { + "shared-ts": { + location: LOCATION, + description: "Shared TS default", + packageManager: "npm", + }, + "default-ts": { + location: LOCATION, + description: DESCRIPTION, + alias: "dts", + }, + }, + }, + }, +}; + +const MOCK_TEMPLATE_GLOBAL: CliConfig = { + settings: { ...REQUIRED_SETTINGS_BASE, language: "fr" }, + templates: { + typescript: { + templates: { + "shared-ts": { + location: "/global/loc", + description: "Shared TS global", + }, + "global-ts": { location: "/global/loc", description: DESCRIPTION }, + }, + }, + python: { + templates: { + "global-py": { location: "/global/loc", description: DESCRIPTION }, + }, + }, + }, +}; + +const MOCK_TEMPLATE_LOCAL: CliConfig = { + settings: { + ...REQUIRED_SETTINGS_BASE, + language: "es" as TextLanguageValues, + }, + templates: { + typescript: { + templates: { + "shared-ts": { + location: "/local/loc", + description: "Shared TS local", + }, + "global-ts": { + location: "/local/loc", + description: "Global TS local override", + }, + "local-ts": { + location: "/local/loc", + description: DESCRIPTION, + }, + }, + }, + }, +}; + +const getMockSources = ( + local: CliConfig | null = MOCK_TEMPLATE_LOCAL, + global: CliConfig | null = MOCK_TEMPLATE_GLOBAL, + defaultConfig: CliConfig | null = MOCK_TEMPLATE_DEFAULT, +): ConfigurationSources => ({ + local, + global, + default: defaultConfig, + configFound: !!local || !!global || !!defaultConfig, +}); + +describe("getAnnotatedTemplates", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should include Local only, and exclude Global and Defaults by default (no flags)", async () => { + mockReadConfigSources.mockResolvedValue(getMockSources()); + + const options = { + forceGlobal: false, + mergeAll: false, + includeDefaults: false, + }; + const templates = await getAnnotatedTemplates(options); + + expect(templates).toHaveLength(3); + expect(templates.some((t) => t._name === "global-py")).toBe(false); + + const sharedTs = templates.find((t) => t._name === "shared-ts"); + expect(sharedTs?._source).toBe("local"); + }); + + it("should include Defaults, and be overridden by Local (Global is skipped)", async () => { + mockReadConfigSources.mockResolvedValue(getMockSources()); + + const options = { + forceGlobal: false, + mergeAll: false, + includeDefaults: true, + }; + const templates = await getAnnotatedTemplates(options); + + expect(templates).toHaveLength(6); + expect(templates.some((t) => t._name === "global-py")).toBe(false); + + const sharedTs = templates.find( + (t) => t._name === "shared-ts" && t._language === "typescript", + ); + expect(sharedTs?._source).toBe("local"); + + const sharedJs = templates.find( + (t) => t._name === "shared-js" && t._language === "javascript", + ); + expect(sharedJs?._source).toBe("default"); + }); + + it("should merge Global and Defaults, ignoring Local templates (forceGlobal: true, Local is present in sources but skipped)", async () => { + mockReadConfigSources.mockResolvedValue( + getMockSources(MOCK_TEMPLATE_LOCAL), + ); + + const options = { + forceGlobal: true, + mergeAll: false, + includeDefaults: true, + }; + const templates = await getAnnotatedTemplates(options); + + expect(templates).toHaveLength(6); + + expect(templates.some((t) => t._name === "local-ts")).toBe(false); + + const sharedTs = templates.find((t) => t._name === "shared-ts"); + expect(sharedTs?._source).toBe("global"); + + const globalPy = templates.find((t) => t._name === "global-py"); + expect(globalPy?._source).toBe("global"); + }); + + it("should merge all sources (Local > Global > Default) when mergeAll is true", async () => { + mockReadConfigSources.mockResolvedValue(getMockSources()); + + const options = { + forceGlobal: false, + mergeAll: true, + includeDefaults: true, + }; + const templates = await getAnnotatedTemplates(options); + + expect(templates).toHaveLength(7); + + const sharedTs = templates.find( + (t) => t._name === "shared-ts" && t._language === "typescript", + ); + expect(sharedTs?._source).toBe("local"); + + const globalPy = templates.find((t) => t._name === "global-py"); + expect(globalPy?._source).toBe("global"); + }); + + it("should only return Default templates when Local and Global configs are missing (Default mode)", async () => { + mockReadConfigSources.mockResolvedValue(getMockSources(null, null)); + + const options = { + forceGlobal: false, + mergeAll: false, + includeDefaults: true, + }; + + const templates = await getAnnotatedTemplates(options); + + expect(templates).toHaveLength(4); + + expect(templates.some((t) => t._source === "global")).toBe(false); + + const defaultTs = templates.find((t) => t._name === "default-ts"); + expect(defaultTs?._source).toBe("default"); + }); + + it("should return only Global templates (forceGlobal: true, no defaults)", async () => { + mockReadConfigSources.mockResolvedValue( + getMockSources(null, MOCK_TEMPLATE_GLOBAL, null), + ); + + const options = { + forceGlobal: true, + mergeAll: false, + includeDefaults: false, + }; + + const templates = await getAnnotatedTemplates(options); + + expect(templates).toHaveLength(3); + expect(templates.every((t) => t._source === "global")).toBe(true); + expect(templates.some((t) => t._name === "global-py")).toBe(true); + }); + + it("should handle null config sources and configs with empty templates section", async () => { + const emptyConfig: CliConfig = { + settings: REQUIRED_SETTINGS_BASE, + templates: {}, + }; + + mockReadConfigSources.mockResolvedValue( + getMockSources(null, MOCK_TEMPLATE_GLOBAL, emptyConfig), + ); + + const options = { + forceGlobal: true, + mergeAll: false, + includeDefaults: true, + }; + + const templates = await getAnnotatedTemplates(options); + + expect(templates).toHaveLength(3); + expect(templates.every((t) => t._source === "global")).toBe(true); + + const nullTemplatesConfig = { + settings: REQUIRED_SETTINGS_BASE, + templates: null, + } as unknown as CliConfig; + mockReadConfigSources.mockResolvedValue( + getMockSources(null, nullTemplatesConfig, nullTemplatesConfig), + ); + const templatesNull = await getAnnotatedTemplates({ + forceGlobal: true, + mergeAll: false, + includeDefaults: true, + }); + expect(templatesNull).toHaveLength(0); + }); +}); diff --git a/packages/devkit/__tests__/units/core/template/printer.spec.ts b/packages/devkit/__tests__/units/core/template/printer.spec.ts index e66a166..a077948 100644 --- a/packages/devkit/__tests__/units/core/template/printer.spec.ts +++ b/packages/devkit/__tests__/units/core/template/printer.spec.ts @@ -2,10 +2,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { printSettings, printTemplates, - type TemplateList, } from "../../../../src/core/template/printer.js"; import { mockLogger, mocktFn } from "../../../../vitest.setup.js"; -import type { LanguageConfig } from "../../../../src/utils/schema/schema.js"; +import { type AnnotatedTemplate } from "../../../../src/core/template/annotator.js"; const mockFilterTemplatesByWhereClause = vi.hoisted(() => { return vi.fn(); @@ -22,140 +21,139 @@ const NEW_PM_KEY = "commands.template.add.options.package_manager"; mockLogger.table = vi.fn(); +const ANNOTATED_TEMPLATES: AnnotatedTemplate[] = [ + { + _name: "node-ts-api", + _language: "typescript", + _source: "global", + description: "A simple Node.js API with TypeScript", + alias: "nta", + location: "https://github.com/devkit/node-ts-api", + cacheStrategy: "daily", + packageManager: "npm", + }, + { + _name: "react-component", + _language: "typescript", + _source: "local", + description: "A reusable React component", + location: "/local/path/to/template", + }, + { + _name: "express-api", + _language: "javascript", + _source: "default", + description: "A simple Express API", + location: "/local/path/to/default-js-template", + }, + { + _name: "next-app", + _language: "typescript", + _source: "local", + description: "A Next.js application template", + alias: "nextjs", + location: "/local/path/to/template", + }, +]; + +const settings = { + packageManager: "pnpm", + cacheStrategy: "daily", + language: "en", +}; + describe("print-utils", () => { beforeEach(() => { vi.clearAllMocks(); - mockFilterTemplatesByWhereClause.mockImplementation((templates) => - Object.entries(templates), + mockFilterTemplatesByWhereClause.mockImplementation((templateMap) => + Object.entries(templateMap), ); }); const c: any = mockLogger.colors; const t = mocktFn; - const templates: LanguageConfig["templates"] = { - "node-ts-api": { - description: "A simple Node.js API with TypeScript", - alias: "nta", - location: "https://github.com/devkit/node-ts-api", - cacheStrategy: "daily", - packageManager: "npm", - }, - "react-component": { - description: "A reusable React component", - location: "/local/path/to/template", - }, - "next-app": { - description: "A Next.js application template", - alias: "nextjs", - location: "/local/path/to/template", - }, - "simple-template": { - description: "A simple template", - location: "/local/path/to/template", - }, - }; - - const settings = { - packageManager: "pnpm", - cacheStrategy: "daily", - language: "en", - }; + const GLOBAL_TAG = c.magenta("(global)"); + const LOCAL_TAG = c.blue("(local)"); + const DEFAULT_TAG = c.dim("(default)"); - const filteredTemplatesReact = [ - ["react-component", templates["react-component"]] as TemplateList, - ]; + describe("printTemplates (Mode `Tree`: Default)", () => { + it("should print all templates without a filter, grouped by language", () => { + printTemplates(ANNOTATED_TEMPLATES, [], "tree"); - const filteredTemplatesNTA = [ - ["node-ts-api", templates["node-ts-api"]] as TemplateList, - ]; + expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledTimes(4); - describe("printTemplates (Mode `Tree`: Default)", () => { - it("should print all templates without a filter", () => { - mockFilterTemplatesByWhereClause.mockImplementationOnce( - (templates, clauses) => { - expect(clauses).toEqual([]); - return Object.entries(templates); - }, + expect(mockLogger.log).toHaveBeenCalledTimes(6); + + expect(mockLogger.log).toHaveBeenCalledWith( + `\n${c.boldBlue("Typescript")}:`, ); - printTemplates([["typescript", templates]]); - expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledOnce(); - expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledWith( - templates, - [], + expect(mockLogger.log).toHaveBeenCalledWith( + ` - ${c.green("node-ts-api")} ${GLOBAL_TAG} ${c.cyanDim(`(${t(NEW_ALIAS_KEY)}: nta)`)}${c.dim(`\n ${t(NEW_DESCRIPTION_KEY)}`)}: A simple Node.js API with TypeScript${c.dim("\n Location")}: https://github.com/devkit/node-ts-api${c.dim(`\n ${t(NEW_CACHE_KEY)}`)}: daily${c.dim(`\n ${t(NEW_PM_KEY)}`)}: npm\n`, ); - expect(mockLogger.log).toHaveBeenCalledTimes(5); expect(mockLogger.log).toHaveBeenCalledWith( - ` - ${c.green("node-ts-api")} ${c.cyanDim(`(${t(NEW_ALIAS_KEY)}: nta)`)}${c.dim(`\n ${t(NEW_DESCRIPTION_KEY)}`)}: A simple Node.js API with TypeScript${c.dim("\n Location")}: https://github.com/devkit/node-ts-api${c.dim(`\n ${t(NEW_CACHE_KEY)}`)}: daily${c.dim(`\n ${t(NEW_PM_KEY)}`)}: npm\n`, + `\n${c.boldBlue("Javascript")}:`, + ); + expect(mockLogger.log).toHaveBeenCalledWith( + ` - ${c.green("express-api")} ${DEFAULT_TAG} ${c.dim(`\n ${t(NEW_DESCRIPTION_KEY)}`)}: A simple Express API${c.dim("\n Location")}: /local/path/to/default-js-template\n`, ); }); it("should print only filtered templates by a filter clause array", () => { - const filterClauses = ["name:react"]; - mockFilterTemplatesByWhereClause.mockImplementationOnce( - (templates, clauses) => { - expect(clauses).toEqual(filterClauses); - return filteredTemplatesReact; - }, - ); + const filterClauses = ["alias:nextjs"]; + mockFilterTemplatesByWhereClause.mockImplementation((templateMap) => { + const templateName = Object.keys(templateMap)[0]; + return templateName === "next-app" ? Object.entries(templateMap) : []; + }); - printTemplates([["typescript", templates]], filterClauses); + printTemplates(ANNOTATED_TEMPLATES, filterClauses); + + expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledTimes(4); - expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledOnce(); - expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledWith( - templates, - filterClauses, - ); expect(mockLogger.log).toHaveBeenCalledTimes(2); expect(mockLogger.log).toHaveBeenCalledWith( - `\n${c.boldBlue("TYPESCRIPT")}:`, + `\n${c.boldBlue("Typescript")}:`, ); expect(mockLogger.log).toHaveBeenCalledWith( - ` - ${c.green("react-component")} ${c.dim(`\n ${t(NEW_DESCRIPTION_KEY)}`)}: A reusable React component${c.dim("\n Location")}: /local/path/to/template\n`, + ` - ${c.green("next-app")} ${LOCAL_TAG} ${c.cyanDim(`(${t(NEW_ALIAS_KEY)}: nextjs)`)}${c.dim(`\n ${t(NEW_DESCRIPTION_KEY)}`)}: A Next.js application template${c.dim("\n Location")}: /local/path/to/template\n`, ); }); - it("should not print anything if filter returns no templates", () => { + it("should call logger.warning with filter key if filter yields no templates", () => { const filterClauses = ["name:unrelated"]; - mockFilterTemplatesByWhereClause.mockReturnValueOnce([]); + mockFilterTemplatesByWhereClause.mockReturnValue([]); - printTemplates([["python", templates]], filterClauses); + printTemplates(ANNOTATED_TEMPLATES, filterClauses); - expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledOnce(); + expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledTimes(4); expect(mockLogger.log).not.toHaveBeenCalled(); + expect(mockLogger.warning).toHaveBeenCalledOnce(); + expect(mockLogger.warning).toHaveBeenCalledWith( + "warnings.template.not_found_with_filter", + ); }); - it("should not print anything if templates are empty", () => { - printTemplates([["rust", {}]]); + it("should call logger.warning without filter key if templates list is initially empty", () => { + printTemplates([], []); + expect(mockFilterTemplatesByWhereClause).not.toHaveBeenCalled(); expect(mockLogger.log).not.toHaveBeenCalled(); - expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledOnce(); + expect(mockLogger.warning).toHaveBeenCalledOnce(); + expect(mockLogger.warning).toHaveBeenCalledWith( + "warnings.template.not_found", + ); }); }); describe("printTemplates (Mode `Table`)", () => { - const templatesJs = { "express-api": { description: "Express" } }; - const templatesList = [ - ["typescript", templates], - ["javascript", templatesJs], - ]; - - it("should call logger.table with all templates across multiple languages without a filter", () => { - mockFilterTemplatesByWhereClause - .mockImplementationOnce((templates, clauses) => { - expect(clauses).toEqual([]); - return Object.entries(templates); - }) - .mockImplementationOnce((templates, clauses) => { - expect(clauses).toEqual([]); - return Object.entries(templates); - }); - - printTemplates(templatesList as TemplateList[], [], "table"); - - expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledTimes(2); + it("should call logger.table with all templates including the new Source column", () => { + mockFilterTemplatesByWhereClause.mockImplementation(() => [1]); + + printTemplates(ANNOTATED_TEMPLATES, [], "table"); + + expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledTimes(4); expect(mockLogger.table).toHaveBeenCalledTimes(1); const expectedTableData = [ @@ -163,6 +161,7 @@ describe("print-utils", () => { c.bold("Language"), c.bold("Name"), c.bold("Alias"), + c.bold("Source"), c.bold("Description"), c.bold("Location"), ], @@ -170,6 +169,7 @@ describe("print-utils", () => { c.boldBlue("Typescript"), c.green("node-ts-api"), "nta", + GLOBAL_TAG, "A simple Node.js API with TypeScript", "https://github.com/devkit/node-ts-api", ], @@ -177,49 +177,44 @@ describe("print-utils", () => { c.boldBlue("Typescript"), c.green("react-component"), c.dim("N/A"), + LOCAL_TAG, "A reusable React component", "/local/path/to/template", ], + [ + c.boldBlue("Javascript"), + c.green("express-api"), + c.dim("N/A"), + DEFAULT_TAG, + "A simple Express API", + "/local/path/to/default-js-template", + ], [ c.boldBlue("Typescript"), c.green("next-app"), "nextjs", + LOCAL_TAG, "A Next.js application template", "/local/path/to/template", ], - [ - c.boldBlue("Typescript"), - c.green("simple-template"), - c.dim("N/A"), - "A simple template", - "/local/path/to/template", - ], - [ - c.boldBlue("Javascript"), - c.green("express-api"), - c.dim("N/A"), - "Express", - c.dim("N/A"), - ], ]; expect(mockLogger.table).toHaveBeenCalledWith(expectedTableData); expect(mockLogger.log).not.toHaveBeenCalled(); }); - it("should print only filtered templates by a filter array in table mode", () => { - const filterClauses = ["alias:nextjs"]; - mockFilterTemplatesByWhereClause - .mockImplementationOnce(() => filteredTemplatesNTA) - .mockImplementationOnce(() => []); + it("should filter templates correctly in table mode", () => { + const filterClauses = ["language:javascript"]; + mockFilterTemplatesByWhereClause.mockImplementation((templateMap) => { + const templateName = Object.keys(templateMap)[0]; + return templateName === "express-api" + ? Object.entries(templateMap) + : []; + }); - printTemplates(templatesList as TemplateList[], filterClauses, "table"); + printTemplates(ANNOTATED_TEMPLATES, filterClauses, "table"); - expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledTimes(2); - expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledWith( - templates, - filterClauses, - ); + expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledTimes(4); expect(mockLogger.table).toHaveBeenCalledTimes(1); const expectedTableData = [ @@ -227,44 +222,22 @@ describe("print-utils", () => { c.bold("Language"), c.bold("Name"), c.bold("Alias"), + c.bold("Source"), c.bold("Description"), c.bold("Location"), ], [ - c.boldBlue("Typescript"), - c.green("node-ts-api"), - "nta", - "A simple Node.js API with TypeScript", - "https://github.com/devkit/node-ts-api", + c.boldBlue("Javascript"), + c.green("express-api"), + c.dim("N/A"), + DEFAULT_TAG, + "A simple Express API", + "/local/path/to/default-js-template", ], ]; expect(mockLogger.table).toHaveBeenCalledWith(expectedTableData); }); - - it("should call logger.warning with filter key if filter yields no templates", () => { - const filterClauses = ["name:unrelated-app"]; - mockFilterTemplatesByWhereClause.mockReturnValue([]); - - printTemplates([["typescript", templates]], filterClauses, "table"); - - expect(mockLogger.table).not.toHaveBeenCalled(); - expect(mockLogger.warning).toHaveBeenCalledOnce(); - expect(mockLogger.warning).toHaveBeenCalledWith( - "warnings.template_not_found_with_filter", - ); - }); - - it("should call logger.warning without filter key if templates list is empty", () => { - mockFilterTemplatesByWhereClause.mockReturnValue([]); - printTemplates([["rust", {}]], [], "table"); - - expect(mockLogger.table).not.toHaveBeenCalled(); - expect(mockLogger.warning).toHaveBeenCalledOnce(); - expect(mockLogger.warning).toHaveBeenCalledWith( - "warnings.template_not_found", - ); - }); }); describe("printSettings", () => { diff --git a/packages/devkit/__tests__/units/utils/i18n/translation-loader.spec.ts b/packages/devkit/__tests__/units/utils/i18n/translation-loader.spec.ts index 472ebc1..afdcb68 100644 --- a/packages/devkit/__tests__/units/utils/i18n/translation-loader.spec.ts +++ b/packages/devkit/__tests__/units/utils/i18n/translation-loader.spec.ts @@ -2,6 +2,7 @@ import { vi, describe, it, expect, beforeEach } from "vitest"; import { loadTranslations, translations, + activeLanguage, } from "../../../../src/utils/i18n/translation-loader.js"; import { DevkitError } from "../../../../src/utils/errors/base.js"; import path from "path"; @@ -37,7 +38,7 @@ describe("loadTranslations", () => { }); it("should load translations based on user config language", async () => { - mockOsLocale.mockResolvedValue("en_US"); + mockOsLocale.mockResolvedValueOnce("en_US"); mockFs.readJson.mockImplementation(async (filePath) => { if (filePath === mockFrJsonPath) { return { "test.key": "Bonjour" }; @@ -64,6 +65,7 @@ describe("loadTranslations", () => { expect(mockFs.readJson).toHaveBeenCalledWith(mockFrJsonPath); expect(translations).toEqual({ "test.key": "Bonjour" }); + expect(activeLanguage).toBe("fr"); }); it("should fall back to 'en' if the system locale is not supported", async () => { diff --git a/packages/devkit/locales/en.json b/packages/devkit/locales/en.json index ae3f010..252d432 100644 --- a/packages/devkit/locales/en.json +++ b/packages/devkit/locales/en.json @@ -54,7 +54,9 @@ "options": { "global": "List templates from the global configuration.", "local": "List templates from the local configuration.", - "all": "List templates from both local and global configurations." + "all": "List templates from both local and global configurations.", + "settings": "Include configuration settings in the output", + "include_defaults": "Include default, unconfigured templates and settings in the output" }, "output": { "header": "Available Templates:", @@ -244,7 +246,9 @@ "config_initialized": "Configuration file created successfully!", "info_collected": "System information collected.", "scaffolding_complete": "\n🚀 Project created successfully! 🎉", - "next_steps": "\n| Next steps:\n" + "next_steps": "\n| Next steps:\n", + "config_loaded": "Configuration sources loaded successfully.", + "config_read": "Configuration settings retrieved successfully." }, "status": { "scaffolding_project": "Scaffolding project '{projectName}' with the '{template}' template...", @@ -263,7 +267,8 @@ "config_source_local_and_global": "Using local and global configurations.", "config_source_local": "Using local configuration.", "config_source_global": "Using global configuration.", - "templates_using_global_fallback": "No local configuration found. Using templates from global configuration." + "templates_using_global_fallback": "No local configuration found. Using templates from global configuration.", + "including_defaults_suffix": "(including default templates)" }, "scaffolding": { "start": "Scaffolding {language} project: {project}", @@ -373,9 +378,13 @@ "not_found": "⚠️ No configuration file found. Using default settings.", "no_local_config": "No local project configuration found. Using global or default settings.", "global_not_initialized": "Global configuration not initialized. Run 'devkit config init' to create one.", - "template_not_found": "No templates found in the configuration file.", - "template_not_found_with_filter": "No templates matched the specified filter.", - "templates_not_found": "The following templates were not found: {templates}.", + "template": { + "not_found": "No templates found in the configuration file.", + "list_not_found": "The following templates were not found: {templates}.", + "not_found_with_filter": "No templates matched the specified filter.", + "not_found_for_language": "No templates found for the '{language}' language in the config.", + "not_found_in_config": "No templates found in the configuration." + }, "no_command_provided": "Warning: No command or option provided. Use `dk config --help` to see available commands.", "no_config_found": "⚠️ No configuration file found. Using default configuration.", "filter_delimiter_missing": "Ignoring filter clause missing a delimiter ('{delimiter1}' or '{delimiter2}'): {clause}", diff --git a/packages/devkit/locales/fr.json b/packages/devkit/locales/fr.json index 604921d..d17d08a 100644 --- a/packages/devkit/locales/fr.json +++ b/packages/devkit/locales/fr.json @@ -54,7 +54,9 @@ "options": { "global": "Lister les modèles de la configuration globale.", "local": "Lister les modèles de la configuration locale.", - "all": "Lister les modèles des configurations locale et globale." + "all": "Lister les modèles des configurations locale et globale.", + "settings": "Inclure les paramètres de configuration dans le résultat", + "include_defaults": "Inclure les modèles et paramètres par défaut non configurés dans le résultat" }, "output": { "header": "Modèles disponibles :", @@ -248,7 +250,9 @@ "config_initialized": "Fichier de configuration créé avec succès !", "info_collected": "Informations système collectées.", "scaffolding_complete": "\n🚀 Projet créé avec succès ! 🎉", - "next_steps": "\n| Prochaines étapes :\n" + "next_steps": "\n| Prochaines étapes :\n", + "config_loaded": "Sources de configuration chargées avec succès.", + "config_read": "Paramètres de configuration récupérés avec succès." }, "status": { "scaffolding_project": "Échafaudage du projet '{projectName}' avec le modèle '{template}'...", @@ -267,7 +271,8 @@ "config_source_local_and_global": "Utilisation des configurations locale et globale.", "config_source_local": "Utilisation de la configuration locale.", "config_source_global": "Utilisation de la configuration globale.", - "templates_using_global_fallback": "Aucune configuration locale trouvée. Utilisation des modèles de la configuration globale." + "templates_using_global_fallback": "Aucune configuration locale trouvée. Utilisation des modèles de la configuration globale.", + "including_defaults_suffix": "(y compris les modèles par défaut)" }, "scaffolding": { "start": "Échafaudage du projet {language} : {project}", diff --git a/packages/devkit/src/commands/config/add.ts b/packages/devkit/src/commands/config/add.ts index da880b0..b2e94bd 100644 --- a/packages/devkit/src/commands/config/add.ts +++ b/packages/devkit/src/commands/config/add.ts @@ -1,12 +1,30 @@ import { logger, type TSpinner } from "#utils/logger.js"; import { handleErrorAndExit } from "#utils/errors/handler.js"; import { t } from "#utils/i18n/translator.js"; -import { readAndMergeConfigs } from "#core/config/loader.js"; +import { readConfigSources } from "#core/config/loader.js"; import { validateAndSaveTemplate } from "./validate-and-save.js"; import { DevkitError } from "#utils/errors/base.js"; 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"; + +async function getTargetConfigForModification( + isGlobal: boolean, +): Promise { + const sources = await readConfigSources({ + forceGlobal: isGlobal, + forceLocal: !isGlobal, + }); + + const targetConfig = isGlobal ? sources.global : sources.local; + + if (targetConfig) { + return targetConfig; + } + + return sources.default; +} export function setupAddCommand(configCommand: Command): void { configCommand @@ -69,9 +87,7 @@ export function setupAddCommand(configCommand: Command): void { validateProgrammingLanguage(language); - const { config } = await readAndMergeConfigs({ - forceGlobal: isGlobal, - }); + const config = await getTargetConfigForModification(isGlobal); const templateDetails: AddTemplateSchema = { language, diff --git a/packages/devkit/src/commands/config/index.ts b/packages/devkit/src/commands/config/index.ts index e4cfedc..64fd084 100644 --- a/packages/devkit/src/commands/config/index.ts +++ b/packages/devkit/src/commands/config/index.ts @@ -1,5 +1,4 @@ import { t } from "#utils/i18n/translator.js"; -import { readAndMergeConfigs } from "#core/config/loader.js"; import { handleErrorAndExit } from "#utils/errors/handler.js"; import { handleNonInteractiveSettingsUpdate } from "./logic.js"; import { type Command } from "commander"; @@ -9,12 +8,30 @@ import { setupAddCommand } from "./add.js"; import { setupRemoveCommand } from "./remove.js"; import { setupUpdateCommand } from "./update.js"; import { setupListCommand } from "./list.js"; +import type { CliConfig } from "#utils/schema/schema.js"; +import { readConfigSources } from "#core/config/loader.js"; interface ConfigOptions { global?: boolean; set?: string[]; } +async function getSettingsConfig(isGlobal: boolean): Promise { + const isLocal = !isGlobal; + const configSources = await readConfigSources({ + forceGlobal: isGlobal, + forceLocal: isLocal, + }); + + function getConfig(config: CliConfig | null): CliConfig { + return (config || {}) as CliConfig; + } + + return isLocal + ? getConfig(configSources?.local) + : getConfig(configSources?.global); +} + async function handleConfigAction( keys: string[], cmdOptions: ConfigOptions, @@ -22,7 +39,7 @@ async function handleConfigAction( ): Promise { const { global: isGlobal, set: bulkSetValues } = cmdOptions; - const { config } = await readAndMergeConfigs({ forceGlobal: isGlobal }); + const config = await getSettingsConfig(!!isGlobal); spinner.stop(); if (bulkSetValues && bulkSetValues.length > 0) { @@ -54,7 +71,7 @@ async function handleConfigAction( ); } }); - spinner.succeed(logger.colors.green(t("messages.success.config_updated"))); + spinner.succeed(logger.colors.green(t("messages.success.config_read"))); return; } diff --git a/packages/devkit/src/commands/config/list.ts b/packages/devkit/src/commands/config/list.ts index dbf3277..47eabdb 100644 --- a/packages/devkit/src/commands/config/list.ts +++ b/packages/devkit/src/commands/config/list.ts @@ -2,46 +2,51 @@ import { t } from "#utils/i18n/translator.js"; import { handleErrorAndExit } from "#utils/errors/handler.js"; import { DevkitError } from "#utils/errors/base.js"; import { logger, type TSpinner } from "#utils/logger.js"; -import { readAndMergeConfigs } from "#core/config/loader.js"; import { printSettings, printTemplates } from "#core/template/printer.js"; +import { getMergedConfig } from "#core/config/merger.js"; +import { + readConfigSources, + type ConfigurationSources, +} from "#core/config/loader.js"; +import { getAnnotatedTemplates } from "#core/template/annotator.js"; import { type Command } from "commander"; -import type { LanguageConfig } from "#/utils/schema/schema"; type ListCommandOptions = { all?: boolean; + includeDefaults?: boolean; }; const getStartMessageForConfig = ( isGlobal: boolean, showAll: boolean, - source: string, + includeDefaults: boolean, + sources: ConfigurationSources, ): Parameters[0] => { + const hasLocal = !!sources?.local; + const hasGlobal = !!sources?.global; + + if (isGlobal) { + if (!hasGlobal && !includeDefaults) return "errors.config.global_not_found"; + } + + if (!isGlobal) { + if (!hasLocal && !includeDefaults) return "errors.config.local_not_found"; + } + if (showAll) { - if (source === "merged") { - return "messages.status.config_source_local_and_global"; - } - if (source === "global") { - return "messages.status.config_source_global"; - } - if (source === "local") { - return "messages.status.config_source_local"; - } - return "warnings.no_config_found"; + return "messages.status.config_source_local_and_global"; } if (isGlobal) { - if (source !== "global") { - return "errors.config.global_not_found"; - } return "messages.status.config_source_global"; } - if (source === "local") { + if (!isGlobal) { return "messages.status.config_source_local"; } - if (source === "global") { - return "messages.status.templates_using_global_fallback"; + if (isGlobal) { + return "messages.status.config_source_global"; } return "warnings.no_config_found"; @@ -53,14 +58,20 @@ export function setupListCommand(configCommand: Command): void { .alias("ls") .description(t("commands.config.list.command.description")) .option("-a, --all", t("commands.config.list.options.all")) + .option( + "-d, --include-defaults", + t("commands.list.options.include_defaults"), + false, + ) .action(async (cmdOptions: ListCommandOptions, childCommand: Command) => { - const { all: showAll } = cmdOptions; + const { all: showAll, includeDefaults } = cmdOptions; const parentOpts = childCommand?.parent?.opts(); const isGlobal = !!parentOpts?.global; const spinner: TSpinner = logger .spinner(t("messages.status.config_loading")) .start(); + try { if (isGlobal && showAll) { throw new DevkitError( @@ -70,9 +81,19 @@ export function setupListCommand(configCommand: Command): void { ); } - const { config, source } = await readAndMergeConfigs({ - forceGlobal: isGlobal, - mergeAll: showAll, + const configSources = await readConfigSources({ + forceGlobal: !!isGlobal, + mergeAll: !!showAll, + }); + + const config = await getMergedConfig( + !!showAll || !!isGlobal || !!includeDefaults, + ); + + const annotatedTemplates = await getAnnotatedTemplates({ + forceGlobal: !!isGlobal, + mergeAll: !!showAll, + includeDefaults: !!includeDefaults, }); spinner.stop(); @@ -80,14 +101,23 @@ export function setupListCommand(configCommand: Command): void { const startMessageKey = getStartMessageForConfig( isGlobal, !!showAll, - source, + !!includeDefaults, + configSources, ); if (startMessageKey.startsWith("errors.config")) { throw new DevkitError(t(startMessageKey)); } - spinner.info(t(startMessageKey)).start(); + let startMessage = t(startMessageKey); + + if (includeDefaults) { + const defaultsSuffix = t("messages.status.including_defaults_suffix"); + + startMessage += defaultsSuffix; + } + + spinner.info(startMessage).start(); logger.log( logger.colors.bold("\n" + t("commands.config.list.settings_header")), @@ -97,15 +127,13 @@ export function setupListCommand(configCommand: Command): void { logger.log( logger.colors.bold("\n" + t("commands.config.list.templates_header")), ); - if (Object.keys(config?.templates || {}).length === 0) { - logger.log(logger.colors.yellow(t("warnings.template_not_found"))); + + if (annotatedTemplates.length === 0) { + logger.log(logger.colors.yellow(t("warnings.template.not_found"))); } else { - Object.entries(config?.templates || {}).forEach( - ([language, langTemplates]) => { - printTemplates([[language, langTemplates.templates]]); - }, - ); + printTemplates(annotatedTemplates, [], "tree"); } + spinner.stop(); } catch (error: unknown) { handleErrorAndExit(error as Error, spinner); diff --git a/packages/devkit/src/commands/config/logic.ts b/packages/devkit/src/commands/config/logic.ts index 6ded6c0..bbcaad7 100644 --- a/packages/devkit/src/commands/config/logic.ts +++ b/packages/devkit/src/commands/config/logic.ts @@ -7,7 +7,7 @@ import { import { t } from "#utils/i18n/translator.js"; import { DevkitError } from "#utils/errors/base.js"; import deepmerge from "deepmerge"; -import { readAndMergeConfigs } from "#core/config/loader.js"; +import { readConfigSources } from "#core/config/loader.js"; import { saveGlobalConfig, saveLocalConfig } from "#core/config/writer.js"; import { validateConfigValue } from "#utils/validations/validateConfigValue.js"; import { configAliases } from "#utils/validations/configAliases.js"; @@ -33,18 +33,30 @@ async function saveConfig( } } +async function getTargetConfig(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.local_not_found")); + } + throw new DevkitError(t("errors.config.not_found")); + } + + return targetConfig; +} + export async function handleNonInteractiveSettingsUpdate( key: string, value: string, isGlobal: boolean, ): Promise { - const { config, source } = await readAndMergeConfigs({ - forceGlobal: isGlobal, - }); - - if (source === "default" && !isGlobal) { - throw new DevkitError(t("errors.config.local_not_found")); - } + const config = await getTargetConfig(isGlobal); const canonicalKey = ( configAliases as Record @@ -70,23 +82,19 @@ export async function handleNonInteractiveTemplateUpdate( }, isGlobal: boolean, ): Promise { - const { config, source } = await readAndMergeConfigs({ - forceGlobal: isGlobal, - }); - - if (source === "default" && !isGlobal) { - throw new DevkitError(t("errors.config.local_not_found")); - } + const config = await getTargetConfig(isGlobal); validateProgrammingLanguage(language); - const languageTemplates = config.templates[language]; - if (!languageTemplates) { - throw new DevkitError( - t("errors.template.language_not_found", { language }), - ); + if (!config.templates) { + config.templates = {}; + } + if (!config.templates[language]) { + config.templates[language] = { templates: {} }; } + const languageTemplates = config.templates[language]; + const templateKey = Object.keys(languageTemplates.templates).find( (key) => key === templateName || diff --git a/packages/devkit/src/commands/config/remove.ts b/packages/devkit/src/commands/config/remove.ts index 1b09172..8270ed1 100644 --- a/packages/devkit/src/commands/config/remove.ts +++ b/packages/devkit/src/commands/config/remove.ts @@ -2,7 +2,7 @@ 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 { readAndMergeConfigs } from "#core/config/loader.js"; +import { readConfigSources } from "#core/config/loader.js"; import { saveGlobalConfig, saveLocalConfig } from "#core/config/writer.js"; import { type Command } from "commander"; import { type CliConfig } from "#utils/schema/schema.js"; @@ -20,6 +20,27 @@ async function saveConfig( } } +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; +} + export function setupRemoveCommand(configCommand: Command): void { configCommand .command("remove ") @@ -42,9 +63,7 @@ export function setupRemoveCommand(configCommand: Command): void { try { validateProgrammingLanguage(language); - const { config: targetConfig } = await readAndMergeConfigs({ - forceGlobal: isGlobal, - }); + const targetConfig = await getTargetConfigForModification(isGlobal); const languageTemplates = targetConfig?.templates?.[language]; if (!languageTemplates?.templates) { @@ -79,9 +98,13 @@ export function setupRemoveCommand(configCommand: Command): void { } if (templatesToRemove.length === 0) { - throw new DevkitError( - t("errors.template.not_found", { template: notFound.join(", ") }), - ); + if (notFound.length === templateNames.length) { + throw new DevkitError( + t("errors.template.not_found", { + template: notFound.join(", "), + }), + ); + } } const templatesToKeep = Object.fromEntries( @@ -105,7 +128,7 @@ export function setupRemoveCommand(configCommand: Command): void { if (notFound.length > 0) { logger.warning( logger.colors.yellow( - t("warnings.templates_not_found", { + t("warnings.template.list_not_found", { templates: notFound.join(", "), }), ), diff --git a/packages/devkit/src/commands/index.ts b/packages/devkit/src/commands/index.ts index af48d99..01e6130 100644 --- a/packages/devkit/src/commands/index.ts +++ b/packages/devkit/src/commands/index.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { readAndMergeConfigs } from "#core/config/loader.js"; +import { readConfigSources } from "#core/config/loader.js"; import { t } from "#utils/i18n/translator.js"; import { logger, TSpinner } from "#utils/logger.js"; import { getProjectVersion } from "#core/info/project.js"; @@ -8,47 +8,45 @@ 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 { defaultCliConfig, SUPPORTED_LANGUAGES } from "#utils/schema/schema.js"; + import { setupInfoCommand } from "#commands/info.js"; import { loadTranslations } from "#utils/i18n/translation-loader.js"; export async function setupAndParse() { - const program = new Command(); + const spinner: TSpinner = logger.spinner(); - program.option("-v, --verbose", "Enable verbose logging for detailed output"); + try { + const { configFound, global, local } = await readConfigSources({ + mergeAll: true, + }); - program.parseOptions(process.argv); - const isVerbose = !!program.opts().verbose; + let rawLocale = local?.settings?.language || global?.settings?.language; + await loadTranslations(rawLocale || null); - const spinner: TSpinner = logger - .spinner() - .start( - isVerbose - ? logger.colors.cyan(logger.colors.bold("Initializing CLI...")) - : "", - ); + const program = new Command(); - try { - const { config, source } = await readAndMergeConfigs({ - useFallback: true, - }); + program.option("-v, --verbose", t("program.program.verbose_option")); - const locale = - config?.settings?.language && - SUPPORTED_LANGUAGES.includes(config?.settings?.language) - ? config?.settings?.language || "en" - : defaultCliConfig.settings.language; + program.parseOptions(process.argv); + const isVerbose = !!program.opts().verbose; - await loadTranslations(locale); + spinner.start( + isVerbose + ? logger.colors.cyan( + logger.colors.bold(t("program.status.initializing")), + ) + : "", + ); - isVerbose && + if (isVerbose) { spinner.succeed( logger.colors.green( logger.colors.bold(t("messages.success.program_initialized")), ), ); + } - if (source === "default") { + if (!configFound) { logger.warning( `\n${logger.colors.yellowBold(logger.colors.italic(t("warnings.not_found")))}\n`, ); @@ -65,11 +63,11 @@ export async function setupAndParse() { ) .helpOption("-h, --help", t("program.help.description")); - setupInitCommand({ program, config }); - setupNewCommand({ program, config }); + setupInitCommand({ program }); + setupNewCommand({ program }); setupConfigCommand(program); - setupListCommand({ program, config }); - setupInfoCommand({ program, config }); + setupListCommand({ program }); + setupInfoCommand({ program }); program.parse(process.argv); spinner.stop(); diff --git a/packages/devkit/src/commands/list.ts b/packages/devkit/src/commands/list.ts index 6b87b21..987059e 100644 --- a/packages/devkit/src/commands/list.ts +++ b/packages/devkit/src/commands/list.ts @@ -2,62 +2,27 @@ import { t } from "#utils/i18n/translator.js"; import { handleErrorAndExit } from "#utils/errors/handler.js"; import { DevkitError } from "#utils/errors/base.js"; import { logger, type TSpinner } from "#utils/logger.js"; -import { readAndMergeConfigs } from "#core/config/loader.js"; import { printTemplates } from "#core/template/printer.js"; import type { DisplayModesValues, - LanguageConfig, SetupCommandOptions, } from "#utils/schema/schema.js"; import { validateDisplayMode, validateProgrammingLanguage, } from "#utils/validations/config.js"; +import { + getAnnotatedTemplates, + type AnnotatedTemplate, +} from "#core/template/annotator.js"; type ListCommandOptions = { global?: boolean; all?: boolean; where?: string[]; mode: DisplayModesValues; -}; - -const getStartMessage = ( - isGlobal: boolean, - showAll: boolean, - source: string, -): Parameters[0] => { - const TEMPLATE_NOT_FOUND_KEY = "errors.template.not_found"; - const GLOBAL_NOT_FOUND_KEY = "errors.config.global_not_found"; - - if (showAll) { - if (source === "merged") { - return "messages.config_source.using_local_and_global"; - } - if (source === "global") { - return "messages.config_source.global_only"; - } - if (source === "local") { - return "messages.config_source.local_only"; - } - return TEMPLATE_NOT_FOUND_KEY; - } - - if (isGlobal) { - if (source !== "global") { - return GLOBAL_NOT_FOUND_KEY; - } - return "messages.config_source.global"; - } - - if (source === "local") { - return "messages.config_source.local"; - } - - if (source === "global") { - return "messages.config_source.global_fallback"; - } - - return TEMPLATE_NOT_FOUND_KEY; + includeDefaults?: boolean; + settings?: boolean; }; export function setupListCommand(options: SetupCommandOptions): void { @@ -70,14 +35,27 @@ export function setupListCommand(options: SetupCommandOptions): void { .argument("[language]", t("commands.list.command.language.argument"), "") .option("-g, --global", t("commands.list.options.global")) .option("-a, --all", t("commands.list.options.all")) + .option("-s, --settings", t("commands.list.options.settings")) .option("-w, --where ", t("commands.list.command.where.option")) .option( "-m, --mode ", t("commands.list.command.mode.option"), "tree", ) + .option( + "-d, --include-defaults", + t("commands.list.options.include_defaults"), + false, + ) .action(async (language, cmdOptions: ListCommandOptions) => { - const { global: isGlobal, all: showAll, where, mode } = cmdOptions; + const { + global: isGlobal, + all: showAll, + where, + mode, + includeDefaults, + settings, + } = cmdOptions; const whereClauses: string[] = where || []; @@ -96,43 +74,68 @@ export function setupListCommand(options: SetupCommandOptions): void { if (language) validateProgrammingLanguage(language); - const { config, source } = await readAndMergeConfigs({ - forceGlobal: isGlobal, - mergeAll: showAll, - }); + const annotatedTemplates: AnnotatedTemplate[] = + await getAnnotatedTemplates({ + forceGlobal: !!isGlobal, + mergeAll: !!showAll, + includeDefaults: !!includeDefaults, + }); + + let finalConfig = null; + if (settings) { + const { getMergedConfig } = await import("#core/config/merger.js"); + finalConfig = await getMergedConfig(!!showAll || !!isGlobal); + } spinner.stop(); - const startMessageKey = getStartMessage(!!isGlobal, !!showAll, source); - - if (startMessageKey === "errors.config.global_not_found") { - spinner.succeed(logger.colors.yellow(t(startMessageKey))); - return; - } + spinner + .succeed(logger.colors.green(t("messages.success.config_loaded"))) + .start(); - spinner.info(t(startMessageKey)).start(); - const allTemplates = Object.entries(config?.templates || {}); + const defaultsSuffix = includeDefaults + ? t("messages.status.including_defaults_suffix") + : ""; - if (allTemplates.length === 0) { - spinner.succeed( - logger.colors.yellow( - t("warnings.template_not_found", { - template: "", - }), + if (settings && finalConfig) { + const { printSettings } = await import("#core/template/printer.js"); + logger.log( + logger.colors.bold( + "\n" + t("commands.list.output.settings_header") + defaultsSuffix, ), ); - return; + printSettings(finalConfig.settings || {}); } - logger.log(logger.colors.bold("\n" + t("commands.list.output.header"))); - - const templatesToPrint: [string, LanguageConfig["templates"]][] = []; + let templatesToPrint: AnnotatedTemplate[] = annotatedTemplates; + if (language) { + templatesToPrint = annotatedTemplates.filter( + (t) => t._language === language, + ); + } - allTemplates.forEach(([lang, langConfig]) => { - if (!language || lang === language) { - templatesToPrint.push([lang, langConfig.templates]); + if (templatesToPrint.length === 0) { + if (!settings) { + const translationKey = language + ? "warnings.template.not_found_for_language" + : "warnings.template.not_found_in_config"; + + spinner.warn( + logger.colors.yellow( + t(translationKey, { + language, + }), + ), + ); } - }); + return; + } + + logger.log( + logger.colors.bold( + "\n" + t("commands.list.output.header") + defaultsSuffix, + ), + ); validateDisplayMode(mode); diff --git a/packages/devkit/src/commands/new.ts b/packages/devkit/src/commands/new.ts index bd17c38..236b285 100644 --- a/packages/devkit/src/commands/new.ts +++ b/packages/devkit/src/commands/new.ts @@ -4,6 +4,7 @@ import { DevkitError } from "#utils/errors/base.js"; 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"; const getScaffolder = async (language: string) => { if (language === "javascript") { @@ -16,7 +17,7 @@ const getScaffolder = async (language: string) => { }; export function setupNewCommand(options: SetupCommandOptions) { - const { program, config } = options; + const { program } = options; program .command("new") .alias("nw") @@ -44,6 +45,7 @@ export function setupNewCommand(options: SetupCommandOptions) { try { validateProgrammingLanguage(language); + const config = await getMergedConfig(true); const languageTemplates = config.templates[language]; if (!languageTemplates) { throw new DevkitError( diff --git a/packages/devkit/src/core/config/finder.ts b/packages/devkit/src/core/config/finder.ts index 32a2c4b..c779552 100644 --- a/packages/devkit/src/core/config/finder.ts +++ b/packages/devkit/src/core/config/finder.ts @@ -1,6 +1,5 @@ import { CONFIG_FILE_NAMES, - type ConfigurationSource, type ReadConfigOptions, } from "#utils/schema/schema.js"; import fs from "#utils/fs/file.js"; @@ -28,71 +27,45 @@ export async function getConfigFilepath(isGlobal = false): Promise { } type ReadConfig = Omit; -export async function findConfigPaths(options: ReadConfig): Promise<{ - primary: string | null; - secondary: string | null; - source: ConfigurationSource; - configFound: boolean; +export async function getConfigPathSources(options: ReadConfig): Promise<{ + localPath: string | null; + globalPath: string | null; }> { - const shouldMergeAll = - !!options.mergeAll || (!!options.forceGlobal && !!options.forceLocal); - - let primaryPath: string | null = null; - let secondaryPath: string | null = null; - let source: ConfigurationSource = "default"; - let configFound = false; + const localConfigPath = await findLocalConfigFile(); + const globalConfigPath = await findGlobalConfigFile(); const isConfigPathExist = async (path: string | null): Promise => path ? await fs.pathExists(path) : false; - if (shouldMergeAll) { - const localPath = await findLocalConfigFile(); - const globalPath = await findGlobalConfigFile(); - const hasLocal = await isConfigPathExist(localPath); - const hasGlobal = await isConfigPathExist(globalPath); + const hasLocal = await isConfigPathExist(localConfigPath); + const hasGlobal = await isConfigPathExist(globalConfigPath); - if (hasLocal) { - primaryPath = localPath; - configFound = true; - source = hasGlobal ? "merged" : "local"; - if (hasGlobal) { - secondaryPath = globalPath; - } - } else if (hasGlobal) { - primaryPath = globalPath; - configFound = true; - source = "global"; - } + let finalLocalPath: string | null = null; + let finalGlobalPath: string | null = null; + + const shouldMergeAll = !!options.mergeAll; + + if (shouldMergeAll) { + finalLocalPath = hasLocal ? localConfigPath : null; + finalGlobalPath = hasGlobal ? globalConfigPath : null; } else if (options.forceLocal) { - primaryPath = await findLocalConfigFile(); - if (await isConfigPathExist(primaryPath)) { - source = "local"; - configFound = true; - } + finalLocalPath = hasLocal ? localConfigPath : null; + finalGlobalPath = null; } else if (options.forceGlobal) { - primaryPath = await findGlobalConfigFile(); - if (await isConfigPathExist(primaryPath)) { - source = "global"; - configFound = true; - } + finalLocalPath = null; + finalGlobalPath = hasGlobal ? globalConfigPath : null; } else { - primaryPath = await findLocalConfigFile(); - if (await isConfigPathExist(primaryPath)) { - source = "local"; - configFound = true; - } else { - primaryPath = await findGlobalConfigFile(); - if (await isConfigPathExist(primaryPath)) { - source = "global"; - configFound = true; - } + if (hasLocal) { + finalLocalPath = localConfigPath; + finalGlobalPath = null; + } else if (hasGlobal) { + finalLocalPath = null; + finalGlobalPath = globalConfigPath; } } return { - primary: primaryPath, - secondary: secondaryPath, - source, - configFound, + localPath: finalLocalPath, + globalPath: finalGlobalPath, }; } diff --git a/packages/devkit/src/core/config/loader.ts b/packages/devkit/src/core/config/loader.ts index c417d28..7b2961e 100644 --- a/packages/devkit/src/core/config/loader.ts +++ b/packages/devkit/src/core/config/loader.ts @@ -1,23 +1,24 @@ -import deepmerge from "deepmerge"; -import fs from "#utils/fs/file.js"; import { type CliConfig, defaultCliConfig, - type ConfigurationSource, type ReadConfigOptions, } from "#utils/schema/schema.js"; -import { findConfigPaths } from "./finder.js"; +import fs from "#utils/fs/file.js"; +import { getConfigPathSources } from "./finder.js"; -async function readAndMergeSingleConfig( - currentConfig: CliConfig, - path: string, -): Promise { +export type ConfigurationSources = { + default: CliConfig; + global: CliConfig | null; + local: CliConfig | null; + configFound: boolean; +}; + +async function readSingleConfig( + path: string | null, +): Promise { if (path && (await fs.pathExists(path))) { try { - const foundConfig = await fs.readJson(path); - return deepmerge(currentConfig, foundConfig, { - arrayMerge: (_, sourceArray) => sourceArray, - }); + return (await fs.readJson(path)) as CliConfig; } catch (e: unknown) { console.error( `Warning: Failed to parse configuration file at "${path}". The file may be invalid.`, @@ -25,33 +26,25 @@ async function readAndMergeSingleConfig( ); } } - return currentConfig; + return null; } -export async function readAndMergeConfigs( +export async function readConfigSources( options: ReadConfigOptions = {}, -): Promise<{ config: CliConfig; source: ConfigurationSource }> { - const { primary, secondary, source, configFound } = - await findConfigPaths(options); - - let finalConfig: CliConfig = {} as CliConfig; +): Promise { + const { localPath, globalPath } = await getConfigPathSources(options); - if (configFound) { - finalConfig = await readAndMergeSingleConfig( - structuredClone(finalConfig), - primary || "", - ); - if (secondary) { - finalConfig = await readAndMergeSingleConfig( - structuredClone(finalConfig), - secondary, - ); - } - } + const [localConfig, globalConfig] = await Promise.all([ + readSingleConfig(localPath), + readSingleConfig(globalPath), + ]); - if (!configFound && options.useFallback) { - finalConfig = deepmerge(structuredClone(defaultCliConfig), finalConfig); - } + const configFound = !!localConfig || !!globalConfig; - return { config: finalConfig, source }; + return { + default: structuredClone(defaultCliConfig), + global: structuredClone(globalConfig), + local: structuredClone(localConfig), + configFound, + }; } diff --git a/packages/devkit/src/core/config/merger.ts b/packages/devkit/src/core/config/merger.ts new file mode 100644 index 0000000..3b1fe23 --- /dev/null +++ b/packages/devkit/src/core/config/merger.ts @@ -0,0 +1,39 @@ +import deepmerge from "deepmerge"; +import { readConfigSources } from "./loader.js"; +import { type CliConfig } from "#utils/schema/schema.js"; + +export function mergeCliConfigs( + configs: (CliConfig | null | undefined)[], +): CliConfig { + const mergeOptions: deepmerge.Options = { + arrayMerge: (_, sourceArray) => sourceArray, + }; + + const validConfigs = configs.filter( + (config): config is CliConfig => !!config, + ); + + if (validConfigs.length === 0) { + return {} as CliConfig; + } + + const mergedConfig = validConfigs.reduce((accumulator, currentConfig) => { + return deepmerge(accumulator, currentConfig, mergeOptions) as CliConfig; + }); + + return mergedConfig; +} + +export async function getMergedConfig( + mergeAll: boolean = true, +): Promise { + const { + local, + global, + default: defaultConfig, + } = await readConfigSources({ + mergeAll: mergeAll, + }); + + return mergeCliConfigs([defaultConfig, global, local]); +} diff --git a/packages/devkit/src/core/info/info.ts b/packages/devkit/src/core/info/info.ts index dd242bd..20361c5 100644 --- a/packages/devkit/src/core/info/info.ts +++ b/packages/devkit/src/core/info/info.ts @@ -1,4 +1,4 @@ -import { readAndMergeConfigs } from "../config/loader.js"; +import { getMergedConfig } from "../config/merger.js"; import { defaultCliConfig, type CliConfig } from "#utils/schema/schema.js"; import { getPackageManager } from "#utils/package-manager/index.js"; import { findGlobalConfigFile, findLocalConfigFile } from "../config/search.js"; @@ -27,7 +27,7 @@ const getPackageManagerVersion = async ( const { stdout } = await execute(managerToQuery, ["--version"]); return `${managerToQuery} v${(stdout as string)?.trim?.()}`; // oxlint-disable-next-line no-unused-vars - } catch (error) { + } catch (_error: unknown) { return t("errors.system.info_package_manager_not_found", { manager: managerToQuery, }); @@ -50,10 +50,7 @@ export type SystemInfo = { export const collectSystemInfo = async ( cliVersion: string, ): Promise => { - const { config } = await readAndMergeConfigs({ - mergeAll: false, - forceGlobal: false, - }); + const config = await getMergedConfig(true); const packageManagerVersion = await getPackageManagerVersion(config); diff --git a/packages/devkit/src/core/template/annotator.ts b/packages/devkit/src/core/template/annotator.ts new file mode 100644 index 0000000..b97d163 --- /dev/null +++ b/packages/devkit/src/core/template/annotator.ts @@ -0,0 +1,68 @@ +import { readConfigSources } from "../config/loader.js"; +import { + type CliConfig, + type ReadConfigOptions, + type TemplateConfig, +} from "#utils/schema/schema.js"; + +type TemplateSource = "default" | "global" | "local"; + +export type AnnotatedTemplate = TemplateConfig & { + _source: TemplateSource; + _language: string; + _name: string; +}; + +type ReadConfigOptionsForAnnotation = Omit & { + includeDefaults: boolean; +}; + +export async function getAnnotatedTemplates( + options: ReadConfigOptionsForAnnotation, +): Promise { + const sources = await readConfigSources(options); + + const finalTemplatesMap = new Map(); + + const processTemplates = ( + config: CliConfig | null, + sourceTag: TemplateSource, + ) => { + if (!config || !config.templates) return; + + for (const [lang, langConfig] of Object.entries(config.templates)) { + if (!langConfig || !langConfig.templates) continue; + + for (const [name, template] of Object.entries(langConfig.templates)) { + const key = `${lang}/${name}`; + + const annotated: AnnotatedTemplate = { + ...template, + _source: sourceTag, + _language: lang, + _name: name, + }; + + finalTemplatesMap.set(key, annotated); + } + } + }; + + if (options.includeDefaults && sources.default) { + processTemplates(sources.default, "default"); + } + + const shouldProcessGlobal = + sources.global && (options.mergeAll || options.forceGlobal); + if (shouldProcessGlobal) { + processTemplates(sources.global, "global"); + } + + const shouldProcessLocal = + sources.local && (options.mergeAll || !options.forceGlobal); + if (shouldProcessLocal) { + processTemplates(sources.local, "local"); + } + + return Array.from(finalTemplatesMap.values()); +} diff --git a/packages/devkit/src/core/template/printer.ts b/packages/devkit/src/core/template/printer.ts index d6057fe..3513264 100644 --- a/packages/devkit/src/core/template/printer.ts +++ b/packages/devkit/src/core/template/printer.ts @@ -2,32 +2,40 @@ import { t } from "#utils/i18n/translator.js"; import { type CliConfig, type DisplayModesValues, - type LanguageConfig, } from "#utils/schema/schema.js"; import { logger } from "#utils/logger.js"; import { filterTemplatesByWhereClause } from "./filter.js"; +import { type AnnotatedTemplate } from "./annotator.js"; + +function getSourceTag(source: AnnotatedTemplate["_source"]): string { + const dim = logger.colors.dim; + switch (source) { + case "local": + return logger.colors.blue("(local)"); + case "global": + return logger.colors.magenta("(global)"); + case "default": + return dim("(default)"); + default: + return ""; + } +} -type TemplateMap = LanguageConfig["templates"]; -export type TemplateList = [string, TemplateMap]; - -export function printTemplatesTree( - language: string, - templates: TemplateMap, - whereClauses: string[], -): void { - const filteredTemplates = filterTemplatesByWhereClause( - templates, - whereClauses, - ); +export function printTemplatesTree(templates: AnnotatedTemplate[]): void { + if (templates.length === 0) return; - if (filteredTemplates.length === 0) return; + const language = templates[0]._language; + const languageFormatted = + language.charAt(0).toUpperCase() + language.slice(1).toLowerCase(); - logger.log(`\n${logger.colors.boldBlue(language.toUpperCase())}:`); + logger.log(`\n${logger.colors.boldBlue(languageFormatted)}:`); - filteredTemplates.forEach(([templateName, templateConfig]) => { + templates.forEach((templateConfig) => { const dim = logger.colors.dim; const cyanDim = logger.colors.cyanDim; + const sourceTag = getSourceTag(templateConfig._source); + const alias = templateConfig?.alias ? cyanDim( `(${t("commands.template.add.options.alias")}: ${templateConfig.alias})`, @@ -47,22 +55,23 @@ export function printTemplatesTree( ? `\n ${dim(t("commands.template.add.options.package_manager"))}: ${templateConfig.packageManager}` : ""; - const coloredName = logger.colors.green(templateName); + const coloredName = logger.colors.green(templateConfig._name); logger.log( - ` - ${coloredName} ${alias}${description}${location}${cacheStrategy}${packageManager}\n`, + ` - ${coloredName} ${sourceTag} ${alias}${description}${location}${cacheStrategy}${packageManager}\n`, ); }); } function printTemplatesTable( - templatesList: TemplateList[], + templates: AnnotatedTemplate[], whereClauses: string[], ): void { const tableData: string[][] = []; const languageHeader = logger.colors.bold("Language"); const nameHeader = logger.colors.bold("Name"); const aliasHeader = logger.colors.bold("Alias"); + const sourceHeader = logger.colors.bold("Source"); const descriptionHeader = logger.colors.bold("Description"); const locationHeader = logger.colors.bold("Location"); @@ -70,26 +79,24 @@ function printTemplatesTable( languageHeader, nameHeader, aliasHeader, + sourceHeader, descriptionHeader, locationHeader, ]); - templatesList.forEach(([lang, templates]) => { - const filteredTemplates = filterTemplatesByWhereClause( - templates, - whereClauses, - ); - - filteredTemplates.forEach(([templateName, templateConfig]) => { - const row = [ - logger.colors.boldBlue(lang.charAt(0).toUpperCase() + lang.slice(1)), - logger.colors.green(templateName), - templateConfig.alias || logger.colors.dim("N/A"), - templateConfig.description || logger.colors.dim("N/A"), - templateConfig.location || logger.colors.dim("N/A"), - ]; - tableData.push(row); - }); + templates.forEach((templateConfig) => { + const row = [ + logger.colors.boldBlue( + templateConfig._language.charAt(0).toUpperCase() + + templateConfig._language.slice(1), + ), + logger.colors.green(templateConfig._name), + templateConfig.alias || logger.colors.dim("N/A"), + getSourceTag(templateConfig._source), + templateConfig.description || logger.colors.dim("N/A"), + templateConfig.location || logger.colors.dim("N/A"), + ]; + tableData.push(row); }); if (tableData.length > 1) { @@ -99,24 +106,61 @@ function printTemplatesTable( const messageKey = whereClauses.length > 0 - ? "warnings.template_not_found_with_filter" - : "warnings.template_not_found"; + ? "warnings.template.not_found_with_filter" + : "warnings.template.not_found"; logger.warning(t(messageKey)); } export function printTemplates( - templatesList: TemplateList[], + templatesList: AnnotatedTemplate[], whereClauses: string[] = [], mode: DisplayModesValues = "tree", ): void { + if (templatesList.length === 0) { + const messageKey = + whereClauses.length > 0 + ? "warnings.template.not_found_with_filter" + : "warnings.template.not_found"; + logger.warning(t(messageKey)); + return; + } + + const finalFilteredList: AnnotatedTemplate[] = templatesList.filter( + (template) => { + const templateMap = { [template._name]: template }; + return filterTemplatesByWhereClause(templateMap, whereClauses).length > 0; + }, + ); + + if (finalFilteredList.length === 0) { + const messageKey = + whereClauses.length > 0 + ? "warnings.template.not_found_with_filter" + : "warnings.template.not_found"; + logger.warning(t(messageKey)); + return; + } + + const finalTemplatesByLanguage = finalFilteredList.reduce( + (acc, template) => { + const lang = template._language; + if (!acc[lang]) { + acc[lang] = []; + } + acc[lang].push(template); + return acc; + }, + {} as Record, + ); + if (mode === "table") { - printTemplatesTable(templatesList, whereClauses); + printTemplatesTable(finalFilteredList, whereClauses); return; } - templatesList.forEach(([lang, templates]) => { - printTemplatesTree(lang, templates, whereClauses); + Object.entries(finalTemplatesByLanguage).forEach(([_, templates]) => { + printTemplatesTree(templates); }); } diff --git a/packages/devkit/src/utils/i18n/translation-loader.ts b/packages/devkit/src/utils/i18n/translation-loader.ts index fc81c88..00c7b5b 100644 --- a/packages/devkit/src/utils/i18n/translation-loader.ts +++ b/packages/devkit/src/utils/i18n/translation-loader.ts @@ -9,6 +9,7 @@ import { DevkitError } from "#utils/errors/base.js"; import { findLocalesDir } from "./locales.js"; export let translations: Record = {}; +export let activeLanguage: TextLanguageValues = "en"; function getSupportedLanguage( lang?: string | null, @@ -29,7 +30,9 @@ export async function loadTranslations( const userLang = getSupportedLanguage(configLang); const rawSystemLocale = await osLocale(); const systemLang = getSupportedLanguage(rawSystemLocale); + const languageToLoad = userLang || systemLang || "en"; + activeLanguage = languageToLoad as TextLanguageValues; try { const localesDir = await findLocalesDir(); @@ -42,6 +45,7 @@ export async function loadTranslations( const fallbackPath = path.join(localesDir, "en.json"); try { translations = await fs.readJson(fallbackPath); + activeLanguage = "en"; } catch (e) { throw new DevkitError( `Failed to load translations from both ${languageToLoad}.json and the fallback en.json`, diff --git a/packages/devkit/src/utils/schema/schema.ts b/packages/devkit/src/utils/schema/schema.ts index 6ded806..48f11f5 100644 --- a/packages/devkit/src/utils/schema/schema.ts +++ b/packages/devkit/src/utils/schema/schema.ts @@ -94,7 +94,6 @@ export interface UpdateCommandOptions { export interface SetupCommandOptions { program: Command; - config: CliConfig; } export interface ReadConfigOptions { diff --git a/packages/devkit/vitest.setup.ts b/packages/devkit/vitest.setup.ts index 84ef39e..9ef6581 100644 --- a/packages/devkit/vitest.setup.ts +++ b/packages/devkit/vitest.setup.ts @@ -27,8 +27,12 @@ const { start: vi.fn(() => { return mockSpinner; }), - succeed: vi.fn(), - warn: vi.fn(), + succeed: vi.fn(() => { + return mockSpinner; + }), + warn: vi.fn(() => { + return mockSpinner; + }), info: vi.fn(() => { return mockSpinner; }),