diff --git a/.changeset/short-deer-sort.md b/.changeset/short-deer-sort.md new file mode 100644 index 0000000..9a2d5cf --- /dev/null +++ b/.changeset/short-deer-sort.md @@ -0,0 +1,5 @@ +--- +"scaffolder-toolkit": minor +--- + +feat: refactoring configuration command logic into dedicated, reusable files and adding comprehensive unit tests for init, remove, and update commands, including new wildcard (\*) template resolution. diff --git a/packages/devkit/README.md b/packages/devkit/README.md index 4281cc9..fe682ea 100644 --- a/packages/devkit/README.md +++ b/packages/devkit/README.md @@ -274,8 +274,9 @@ dk config add javascript react-ts-template --description "My custom React TS tem The `dk config update` command allows you to modify an existing template's properties. - You must provide the language and the name of the template(s) you wish to update. -- **Important:** If you list multiple template names, the command will apply the **exact same property updates** to all of them. For instance, you cannot update the `description` of two different templates to different values in a single command. -- You can also update the template's name using the `--new-name` flag. +- **Wildcard Support:** Use the wildcard character `*` in place of a template name (e.g., `dk config update javascript *`) to apply the update to **all templates** registered under that language. +- **Multiple Templates:** You can list multiple template names to apply the **exact same property updates** to all of them. +- You can also update the template's name using the `--new-name` flag (this flag only works when updating a single template). - Use the `--global` flag to update templates in your global (`~/.devkitrc`) file. @@ -284,18 +285,21 @@ The `dk config update` command allows you to modify an existing template's prope # Update the description and alias for a single template dk config update javascript my-template --description "A new and improved description" --alias "my-alias" -# Update a template's package manager and remove its alias -dk config update javascript my-template --package-manager bun --alias null +# Apply a new package manager to ALL javascript templates in the local config +dk config update javascript * --package-manager bun # Change a template's name and its description in a single command dk config update javascript my-template --new-name my-cool-template --description "A newly renamed template" ``` +--- + #### Remove an existing template from your configuration The `dk config remove` command allows you to delete one or more templates from your configuration file. - You must provide the language and the name(s) of the template(s) you wish to remove. +- **Wildcard Support:** Use the wildcard character `*` in place of a template name (e.g., `dk config remove javascript *`) to remove **all templates** registered under that language. - **Multiple Templates:** You can list multiple template names to remove them all in one operation (e.g., `dk config remove javascript template1 template2`). - **Global:** You can explicitly remove the template from your global (`~/.devkitrc`) file using the `--global` flag. - **Local:** It removes the template from the `.devkit.json` file in the root of your current project. @@ -306,6 +310,9 @@ The `dk config remove` command allows you to delete one or more templates from y # Remove the 'react-ts-template' for 'javascript' from the local config dk config remove javascript react-ts-template +# Remove ALL javascript templates from the local config +dk config remove javascript * + # Remove multiple templates at once from the local config dk config remove javascript template1 template2 @@ -313,6 +320,8 @@ dk config remove javascript template1 template2 dk config remove node node-api --global ``` +--- + #### Manage cache strategy for a template Use the `dk config cache` command to update the cache strategy for a specific template. diff --git a/packages/devkit/TODO.md b/packages/devkit/TODO.md index 1948b1d..baf7325 100644 --- a/packages/devkit/TODO.md +++ b/packages/devkit/TODO.md @@ -62,7 +62,7 @@ This document tracks all planned and completed tasks for the Dev Kit project. - [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. +- [x] 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. diff --git a/packages/devkit/__tests__/integrations/config/index.spec.ts b/packages/devkit/__tests__/integrations/config/index.spec.ts index 49a9b25..7a70a59 100644 --- a/packages/devkit/__tests__/integrations/config/index.spec.ts +++ b/packages/devkit/__tests__/integrations/config/index.spec.ts @@ -201,7 +201,7 @@ describe("dk config", () => { expect(exitCode).toBe(0); expect(all).toContain( - "Les valeurs pour l'option '--set' doivent être une série de paires clé-valeur (par ex. --set clé1 valeur1 clé2 valeur2).", + "Les valeurs pour l'option '--set' doivent être une série de paires clé-valeur (ex: --set clé1 valeur1 clé2 valeur2).", ); }); diff --git a/packages/devkit/__tests__/integrations/config/remove.spec.ts b/packages/devkit/__tests__/integrations/config/remove.spec.ts index 05966a3..3f9ddbd 100644 --- a/packages/devkit/__tests__/integrations/config/remove.spec.ts +++ b/packages/devkit/__tests__/integrations/config/remove.spec.ts @@ -41,6 +41,10 @@ const localConfig: CliConfig = { location: "https://github.com/vuejs/vue", alias: "vb", }, + "node-cli": { + description: "A Node.js CLI template", + location: "https://github.com/node-cli-template", + }, }, }, }, @@ -51,17 +55,21 @@ const globalConfig: CliConfig = { templates: { javascript: { templates: { - "react-ts": { - description: "A React project with TypeScript", - location: "https://github.com/react-ts-template", - alias: "rt", + "global-react-ts": { + description: "A global React project with TypeScript", + location: "https://github.com/react-ts-template-global", + alias: "grt", + }, + "global-lib": { + description: "A global library template", + location: "https://github.com/global-lib", }, }, }, }, }; -describe("dk config remove", () => { +describe("dk config remove - Basic and Alias", () => { beforeAll(() => { vi.unmock("#utils/shell.js"); }); @@ -84,7 +92,7 @@ describe("dk config remove", () => { await fs.remove(globalConfigDir); }); - it("should remove a template from the local config", async () => { + it("should remove a single template from the local config by name", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const { exitCode, all } = await execute( "bun", @@ -106,21 +114,48 @@ describe("dk config remove", () => { expect( updatedConfig.templates.javascript.templates["react-ts"], ).toBeDefined(); + expect( + Object.keys(updatedConfig.templates.javascript.templates).length, + ).toBe(2); }); - it("should remove a template from the global config when --global is used", async () => { - await fs.writeJson( - path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), - globalConfig, + it("should remove multiple templates at once by name", async () => { + await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); + const { exitCode, all } = await execute( + "bun", + [CLI_PATH, "config", "remove", "javascript", "vue-basic", "react-ts"], + { all: true }, + ); + + const updatedConfig = await fs.readJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + ); + + expect(exitCode).toBe(0); + expect(all).toContain( + "Successfully removed 2 template(s) (vue-basic, react-ts) from javascript.", ); + expect( + updatedConfig.templates.javascript.templates["vue-basic"], + ).toBeUndefined(); + expect( + updatedConfig.templates.javascript.templates["react-ts"], + ).toBeUndefined(); + expect( + Object.keys(updatedConfig.templates.javascript.templates).length, + ).toBe(1); + }); + + it("should remove templates by alias", async () => { + await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const { exitCode, all } = await execute( "bun", - [CLI_PATH, "config", "--global", "rm", "javascript", "react-ts"], - { all: true, env: { HOME: globalConfigDir } }, + [CLI_PATH, "config", "remove", "javascript", "rt"], + { all: true }, ); const updatedConfig = await fs.readJson( - path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), ); expect(exitCode).toBe(0); @@ -130,13 +165,36 @@ describe("dk config remove", () => { expect( updatedConfig.templates.javascript.templates["react-ts"], ).toBeUndefined(); + expect( + Object.keys(updatedConfig.templates.javascript.templates).length, + ).toBe(2); + }); +}); + +describe("dk config remove - Wildcard Support", () => { + beforeEach(async () => { + originalCwd = process.cwd(); + tempDir = path.join(os.tmpdir(), `devkit-test-config-remove-${Date.now()}`); + globalConfigDir = path.join( + os.tmpdir(), + `devkit-global-config-dir-${Date.now()}`, + ); + await fs.ensureDir(tempDir); + process.chdir(tempDir); + await fs.ensureDir(globalConfigDir); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await fs.remove(tempDir); + await fs.remove(globalConfigDir); }); - it("should remove multiple templates at once", async () => { + it("should remove ALL local templates using the wildcard '*'", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const { exitCode, all } = await execute( "bun", - [CLI_PATH, "config", "remove", "javascript", "vue-basic", "react-ts"], + [CLI_PATH, "config", "remove", "javascript", "*"], { all: true }, ); @@ -146,18 +204,50 @@ describe("dk config remove", () => { expect(exitCode).toBe(0); expect(all).toContain( - "Successfully removed 2 template(s) (vue-basic, react-ts) from javascript.", + "Successfully removed 3 template(s) (react-ts, vue-basic, node-cli) from javascript.", ); expect( Object.keys(updatedConfig.templates.javascript.templates).length, ).toBe(0); }); - it("should remove templates by alias", async () => { + it("should remove ALL global templates using the wildcard '*' with --global", async () => { + await fs.writeJson( + path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + globalConfig, + ); + const { exitCode, all } = await execute( + "bun", + [CLI_PATH, "config", "--global", "remove", "javascript", "*"], + { all: true, env: { HOME: globalConfigDir } }, + ); + + const updatedConfig = await fs.readJson( + path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + ); + + expect(exitCode).toBe(0); + expect(all).toContain( + "Successfully removed 2 template(s) (global-react-ts, global-lib) from javascript.", + ); + expect( + Object.keys(updatedConfig.templates.javascript.templates).length, + ).toBe(0); + }); + + it("should remove all templates and warn for explicitly listed not-found names when using wildcard", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const { exitCode, all } = await execute( "bun", - [CLI_PATH, "config", "remove", "javascript", "rt"], + [ + CLI_PATH, + "config", + "remove", + "javascript", + "*", + "non-existent-A", + "non-existent-B", + ], { all: true }, ); @@ -167,11 +257,61 @@ describe("dk config remove", () => { expect(exitCode).toBe(0); expect(all).toContain( - "Successfully removed 1 template(s) (react-ts) from javascript.", + "Successfully removed 3 template(s) (react-ts, vue-basic, node-cli) from javascript.", + ); + expect(all).toContain( + "⚠️ The following templates were not found: non-existent-A, non-existent-B", ); expect( - updatedConfig.templates.javascript.templates["react-ts"], + Object.keys(updatedConfig.templates.javascript.templates).length, + ).toBe(0); + }); +}); + +describe("dk config remove - Global and Edge Cases", () => { + beforeEach(async () => { + originalCwd = process.cwd(); + tempDir = path.join(os.tmpdir(), `devkit-test-config-remove-${Date.now()}`); + globalConfigDir = path.join( + os.tmpdir(), + `devkit-global-config-dir-${Date.now()}`, + ); + await fs.ensureDir(tempDir); + process.chdir(tempDir); + await fs.ensureDir(globalConfigDir); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await fs.remove(tempDir); + await fs.remove(globalConfigDir); + }); + + it("should remove a template from the global config when --global is used", async () => { + await fs.writeJson( + path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + globalConfig, + ); + const { exitCode, all } = await execute( + "bun", + [CLI_PATH, "config", "--global", "rm", "javascript", "global-lib"], + { all: true, env: { HOME: globalConfigDir } }, + ); + + const updatedConfig = await fs.readJson( + path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + ); + + expect(exitCode).toBe(0); + expect(all).toContain( + "Successfully removed 1 template(s) (global-lib) from javascript.", + ); + expect( + updatedConfig.templates.javascript.templates["global-lib"], ).toBeUndefined(); + expect( + Object.keys(updatedConfig.templates.javascript.templates).length, + ).toBe(1); }); it("should show a warning for templates not found while removing others", async () => { @@ -196,6 +336,9 @@ describe("dk config remove", () => { expect( updatedConfig.templates.javascript.templates["vue-basic"], ).toBeUndefined(); + expect( + Object.keys(updatedConfig.templates.javascript.templates).length, + ).toBe(2); }); it("should show an error if no templates are found to remove", async () => { @@ -215,7 +358,7 @@ describe("dk config remove", () => { expect(exitCode).toBe(1); expect(all).toContain( - "::[DEV]>> Devkit encountered an unexpected internal issue: Template 'not-found-1, not-found-2' not found in configuration.", + "Template 'not-found-1, not-found-2' not found in configuration.", ); }); @@ -229,11 +372,11 @@ describe("dk config remove", () => { expect(exitCode).toBe(1); expect(all).toContain( - "::[DEV]>> Devkit encountered an unexpected internal issue: Invalid value for Programming Language. Valid options are: javascript", + "Invalid value for Programming Language. Valid options are: javascript", ); }); - it("should throw an error if no config file exists to remove from", async () => { + it("should throw an error if no config file exists to remove from (local)", async () => { const { exitCode, all } = await execute( "bun", [CLI_PATH, "config", "remove", "javascript", "vue-basic"], @@ -243,4 +386,15 @@ describe("dk config remove", () => { expect(exitCode).toBe(1); expect(all).toContain("No configuration file found."); }); + + it("should throw an error if no config file exists to remove from (global)", async () => { + const { exitCode, all } = await execute( + "bun", + [CLI_PATH, "config", "--global", "remove", "javascript", "vue-basic"], + { all: true, reject: false, env: { HOME: globalConfigDir } }, + ); + + expect(exitCode).toBe(1); + expect(all).toContain("Global configuration file not found."); + }); }); diff --git a/packages/devkit/__tests__/integrations/config/update.spec.ts b/packages/devkit/__tests__/integrations/config/update.spec.ts index 6a55aa8..ed88cbd 100644 --- a/packages/devkit/__tests__/integrations/config/update.spec.ts +++ b/packages/devkit/__tests__/integrations/config/update.spec.ts @@ -51,84 +51,101 @@ const createGlobalTemplateFiles = async () => { ); }; -describe("dk config update", () => { - beforeAll(() => { - vi.unmock("#utils/shell.js"); - }); +beforeAll(() => { + vi.unmock("#utils/shell.js"); +}); + +beforeEach(async () => { + originalCwd = process.cwd(); + tempDir = path.join(os.tmpdir(), `devkit-test-config-update-${Date.now()}`); + globalConfigDir = path.join( + os.tmpdir(), + `devkit-global-config-dir-${Date.now()}`, + ); + localTemplatePath = path.join(tempDir, "templates"); + globalTemplatePath = path.join(globalConfigDir, "templates"); + + await fs.ensureDir(tempDir); + process.chdir(tempDir); + await fs.ensureDir(globalConfigDir); + + await createLocalTemplateFiles(); + await createGlobalTemplateFiles(); - beforeEach(async () => { - originalCwd = process.cwd(); - tempDir = path.join(os.tmpdir(), `devkit-test-config-update-${Date.now()}`); - globalConfigDir = path.join( - os.tmpdir(), - `devkit-global-config-dir-${Date.now()}`, - ); - localTemplatePath = path.join(tempDir, "templates"); - globalTemplatePath = path.join(globalConfigDir, "templates"); - - await fs.ensureDir(tempDir); - process.chdir(tempDir); - await fs.ensureDir(globalConfigDir); - - await createLocalTemplateFiles(); - await createGlobalTemplateFiles(); - - localConfig = { - ...defaultCliConfig, - templates: { - javascript: { - templates: { - "react-ts": { - description: "A React project with TypeScript", - location: path.join( - localTemplatePath, - "javascript", - "react-ts.txt", - ), - alias: "rt", - packageManager: "npm", - }, - "vue-basic": { - description: "A basic Vue template", - location: path.join( - localTemplatePath, - "javascript", - "vue-basic.txt", - ), - alias: "vb", - }, + localConfig = { + ...defaultCliConfig, + templates: { + javascript: { + templates: { + "react-ts": { + description: "A React project with TypeScript", + location: path.join( + localTemplatePath, + "javascript", + "react-ts.txt", + ), + alias: "rt", + packageManager: "npm", + }, + "vue-basic": { + description: "A basic Vue template", + location: path.join( + localTemplatePath, + "javascript", + "vue-basic.txt", + ), + alias: "vb", + }, + "other-js": { + description: "Another JS template", + location: path.join( + localTemplatePath, + "javascript", + "other-js.txt", + ), }, }, }, - }; - - globalConfig = { - ...defaultCliConfig, - templates: { - javascript: { - templates: { - "react-ts": { - description: "A global React template", - location: path.join( - globalTemplatePath, - "javascript", - "react-ts.txt", - ), - alias: "rt-global", - }, + }, + }; + + globalConfig = { + ...defaultCliConfig, + templates: { + javascript: { + templates: { + "react-ts": { + description: "A global React template", + location: path.join( + globalTemplatePath, + "javascript", + "react-ts.txt", + ), + alias: "rt-global", + }, + "angular-js": { + description: "A global Angular template", + location: path.join( + globalTemplatePath, + "javascript", + "angular-js.txt", + ), + alias: "ng-g", }, }, }, - }; - }); + }, + }; +}); - afterEach(async () => { - process.chdir(originalCwd); - await fs.remove(tempDir); - await fs.remove(globalConfigDir); - }); +afterEach(async () => { + process.chdir(originalCwd); + await fs.remove(tempDir); + await fs.remove(globalConfigDir); +}); - it("should update a single template in the local config", async () => { +describe("Config Update - Single and Multiple Templates", () => { + it("should update a single template in the local config by name", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const { exitCode, all } = await execute( "bun", @@ -162,30 +179,59 @@ describe("dk config update", () => { ); }); - it("should update a single template in the global config with --global flag", async () => { - await fs.writeJson( - path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), - globalConfig, - ); + it("should update multiple templates in the local config", async () => { + await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const { exitCode, all } = await execute( "bun", [ CLI_PATH, "config", - "--global", - "up", + "update", "javascript", "react-ts", + "vue-basic", "-d", - "An updated global React template", - "-a", - "rts-global", + "Updated description for all", ], - { all: true, env: { HOME: globalConfigDir } }, + { all: true }, ); const updatedConfig = await fs.readJson( - path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + ); + + expect(exitCode).toBe(0); + expect(all).toContain( + "Successfully updated 2 (react-ts, vue-basic) template(s) from javascript!", + ); + expect( + updatedConfig.templates.javascript.templates["react-ts"].description, + ).toBe("Updated description for all"); + expect( + updatedConfig.templates.javascript.templates["vue-basic"].description, + ).toBe("Updated description for all"); + }); +}); + +describe("Config Update - Wildcard and Alias Support", () => { + it("should update a single template in the local config using its alias", async () => { + await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); + const { exitCode, all } = await execute( + "bun", + [ + CLI_PATH, + "config", + "update", + "javascript", + "rt", + "-d", + "Updated via Alias", + ], + { all: true }, + ); + + const updatedConfig = await fs.readJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), ); expect(exitCode).toBe(0); @@ -194,13 +240,39 @@ describe("dk config update", () => { ); expect( updatedConfig.templates.javascript.templates["react-ts"].description, - ).toBe("An updated global React template"); - expect(updatedConfig.templates.javascript.templates["react-ts"].alias).toBe( - "rts-global", + ).toBe("Updated via Alias"); + }); + + it("should update ALL local templates using the wildcard '*'", async () => { + await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); + const { exitCode, all } = await execute( + "bun", + [ + CLI_PATH, + "config", + "update", + "javascript", + "*", + "-d", + "Updated by Wildcard", + ], + { all: true }, + ); + + const updatedConfig = await fs.readJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + ); + + expect(exitCode).toBe(0); + expect(all).toContain( + "Successfully updated 3 (react-ts, vue-basic, other-js) template(s) from javascript!", ); + expect( + updatedConfig.templates.javascript.templates["other-js"].description, + ).toBe("Updated by Wildcard"); }); - it("should update multiple templates in the local config", async () => { + it("should update templates found by '*' but warn for explicitly listed non-existent names", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const { exitCode, all } = await execute( "bun", @@ -209,10 +281,11 @@ describe("dk config update", () => { "config", "update", "javascript", - "react-ts", - "vue-basic", + "*", + "non-existent-A", + "non-existent-B", "-d", - "Updated description for all", + "Updated, ignoring unknowns", ], { all: true }, ); @@ -223,17 +296,17 @@ describe("dk config update", () => { expect(exitCode).toBe(0); expect(all).toContain( - "Successfully updated 2 (react-ts, vue-basic) template(s) from javascript!", + "Successfully updated 3 (react-ts, vue-basic, other-js) template(s) from javascript!", + ); + expect(all).toContain( + "The following templates were not found: non-existent-A, non-existent-B", ); expect( updatedConfig.templates.javascript.templates["react-ts"].description, - ).toBe("Updated description for all"); - expect( - updatedConfig.templates.javascript.templates["vue-basic"].description, - ).toBe("Updated description for all"); + ).toBe("Updated, ignoring unknowns"); }); - it("should handle partial updates with some failures", async () => { + it("should update found templates while warning for missing ones when not using wildcard", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const { exitCode, all } = await execute( "bun", @@ -254,19 +327,104 @@ describe("dk config update", () => { path.join(tempDir, LOCAL_CONFIG_FILE_NAME), ); - expect(exitCode).toBe(1); + expect(exitCode).toBe(0); expect(all).toContain( - "Failed to update 'non-existent-template': Template 'non-existent-template' not found in configuration.", + "Successfully updated 1 (react-ts) template(s) from javascript!", ); expect(all).toContain( - "Successfully updated 1 (react-ts, non-existent-template) template(s) from javascript!", + "The following templates were not found: non-existent-template", ); expect( updatedConfig.templates.javascript.templates["react-ts"].description, ).toBe("A description that should fail for one"); }); +}); + +describe("Config Update - Global Configuration", () => { + it("should update a single template in the global config with --global flag", async () => { + await fs.writeJson( + path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + globalConfig, + ); + const { exitCode, all } = await execute( + "bun", + [ + CLI_PATH, + "config", + "--global", + "up", + "javascript", + "react-ts", + "-d", + "An updated global React template", + "-a", + "rts-global", + ], + { all: true, env: { HOME: globalConfigDir } }, + ); + + const updatedConfig = await fs.readJson( + path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + ); + + expect(exitCode).toBe(0); + expect(all).toContain( + "Successfully updated 1 (react-ts) template(s) from javascript!", + ); + expect( + updatedConfig.templates.javascript.templates["react-ts"].description, + ).toBe("An updated global React template"); + expect(updatedConfig.templates.javascript.templates["react-ts"].alias).toBe( + "rts-global", + ); + }); - it("should fail gracefully if a template is not found", async () => { + it("should update ALL global templates using the wildcard '*' with --global flag", async () => { + await fs.writeJson( + path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + globalConfig, + ); + + const newLocationPath = path.join( + globalTemplatePath, + "javascript", + "react-ts.txt", + ); + + const { exitCode, all } = await execute( + "bun", + [ + CLI_PATH, + "config", + "--global", + "update", + "javascript", + "*", + "-l", + newLocationPath, + ], + { all: true, env: { HOME: globalConfigDir } }, + ); + + const updatedConfig = await fs.readJson( + path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + ); + + expect(exitCode).toBe(0); + expect(all).toContain( + "Successfully updated 2 (react-ts, angular-js) template(s) from javascript!", + ); + expect( + updatedConfig.templates.javascript.templates["react-ts"].location, + ).toBe(newLocationPath); + expect( + updatedConfig.templates.javascript.templates["angular-js"].location, + ).toBe(newLocationPath); + }); +}); + +describe("Config Update - Failure and Edge Cases", () => { + it("should fail gracefully if NO template names were found to act on", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const { exitCode, all } = await execute( "bun", @@ -275,7 +433,8 @@ describe("dk config update", () => { "config", "update", "javascript", - "non-existent-template", + "non-existent-template-A", + "non-existent-template-B", "-d", "some-description", ], @@ -284,11 +443,11 @@ describe("dk config update", () => { expect(exitCode).toBe(1); expect(all).toContain( - "Failed to update 'non-existent-template': Template 'non-existent-template' not found in configuration.", + "Template 'non-existent-template-A, non-existent-template-B' not found in configuration.", ); }); - it("should fail gracefully if a language is not found", async () => { + it("should fail gracefully if a language is not found (validation error)", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); const { exitCode, all } = await execute( "bun", @@ -306,7 +465,7 @@ describe("dk config update", () => { expect(exitCode).toBe(1); expect(all).toContain( - "Failed to update 'ts-template': Invalid value for Programming Language. Valid options are: javascript", + "Invalid value for Programming Language. Valid options are: javascript", ); }); diff --git a/packages/devkit/__tests__/integrations/list.spec.ts b/packages/devkit/__tests__/integrations/list.spec.ts index 6c217d9..ecf69b2 100644 --- a/packages/devkit/__tests__/integrations/list.spec.ts +++ b/packages/devkit/__tests__/integrations/list.spec.ts @@ -509,7 +509,7 @@ describe("dk list", () => { expect(exitCode).toBe(0); expect(all).toContain( - "Modèles disponibles :(y compris les modèles par défaut)", + "Modèles disponibles :(incluant les modèles par défaut)", ); expect(all).toContain("remix"); }); @@ -574,7 +574,7 @@ describe("dk list", () => { expect(exitCode).toBe(0); expect(all).toContain( - "Modèles disponibles :(y compris les modèles par défaut)", + "Modèles disponibles :(incluant les modèles par défaut)", ); expect(all).toContain("Javascript"); expect(all).toContain("remix"); diff --git a/packages/devkit/__tests__/integrations/verbose.spec.ts b/packages/devkit/__tests__/integrations/verbose.spec.ts index 9342f0d..b636a78 100644 --- a/packages/devkit/__tests__/integrations/verbose.spec.ts +++ b/packages/devkit/__tests__/integrations/verbose.spec.ts @@ -55,7 +55,7 @@ describe("dk --verbose", () => { it("should display the config warning even without --verbose when using a default config", async () => { const { all } = await runTestCommand([]); expect(all).toContain( - "⚠️ No configuration file found. Using default settings.", + "No configuration file found. Using default settings.", ); expect(all).not.toContain("CLI initialized successfully."); }); diff --git a/packages/devkit/__tests__/units/commands/config/remove.spec.ts b/packages/devkit/__tests__/units/commands/config/remove.spec.ts index 414de7a..679238c 100644 --- a/packages/devkit/__tests__/units/commands/config/remove.spec.ts +++ b/packages/devkit/__tests__/units/commands/config/remove.spec.ts @@ -2,7 +2,10 @@ 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"; +import type { + CliConfig, + ConfigurationSource, +} from "../../../../src/utils/schema/schema.js"; const { mockHandleErrorAndExit, @@ -38,7 +41,6 @@ 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.template.list_not_found"; const ERROR_TEMPLATE_NOT_FOUND_KEY = "errors.template.not_found"; @@ -58,6 +60,10 @@ const defaultTemplateConfig: CliConfig["templates"] = { description: "A basic React template", location: "https://github.com/facebook/react", }, + "node-cli": { + description: "A Node.js CLI template", + location: "https://github.com/node/node-cli", + }, }, }, typescript: { @@ -71,7 +77,7 @@ const sampleConfig: CliConfig = { }; const createMockSources = ( - targetType: "local" | "global" | "default", + targetType: ConfigurationSource, ): ReturnType => { const local = targetType === "local" ? structuredClone(sampleConfig) : null; const global = targetType === "global" ? structuredClone(sampleConfig) : null; @@ -85,6 +91,23 @@ const createMockSources = ( describe("setupRemoveCommand", () => { let mockConfigCommand: any; + const callAction = ( + language: string, + templateNames: string[], + isGlobal: boolean, + ) => { + const parentOpts = { global: isGlobal }; + return actionFn( + language, + templateNames, + {}, + { + parent: { + opts: vi.fn(() => parentOpts), + }, + }, + ); + }; beforeEach(() => { vi.clearAllMocks(); @@ -113,27 +136,9 @@ describe("setupRemoveCommand", () => { ); }); - describe("action handler", () => { - const callAction = ( - language: string, - templateNames: string[], - isGlobal: boolean, - ) => { - const parentOpts = { global: isGlobal }; - return actionFn( - language, - templateNames, - {}, - { - parent: { - opts: vi.fn(() => parentOpts), - }, - }, - ); - }; - + describe("action handler - Single and Multiple Named Removal", () => { it("should remove a template by its name from local config", async () => { - mockReadConfigSources.mockImplementation(() => + mockReadConfigSources.mockImplementationOnce(() => createMockSources("local"), ); @@ -145,27 +150,25 @@ describe("setupRemoveCommand", () => { forceLocal: true, }); - expect(mockSpinner.start).toHaveBeenCalledWith( - mockLogger.colors.cyan(mocktFn(STATUS_REMOVING_KEY)), - ); expect(mockSaveLocalConfig).toHaveBeenCalledOnce(); - const expectedConfig: CliConfig = { - settings: {} as CliConfig["settings"], - templates: { - javascript: { - templates: { - "react-basic": { - description: "A basic React template", - location: "https://github.com/facebook/react", + expect(mockSaveLocalConfig).toHaveBeenCalledWith( + expect.objectContaining({ + settings: { + ...defaultTemplateConfig.settings, + }, + templates: { + javascript: { + templates: { + ...defaultTemplateConfig.javascript?.templates, + "vue-basic": undefined, }, }, + typescript: { + ...defaultTemplateConfig.typescript, + }, }, - typescript: { - templates: {}, - }, - }, - }; - expect(mockSaveLocalConfig).toHaveBeenCalledWith(expectedConfig); + }), + ); expect(mockSpinner.succeed).toHaveBeenCalledWith( mocktFn(SUCCESS_REMOVED_KEY, { @@ -174,7 +177,6 @@ describe("setupRemoveCommand", () => { language: "javascript", }), ); - expect(mockHandleErrorAndExit).not.toHaveBeenCalled(); }); it("should remove a template by its alias", async () => { @@ -201,20 +203,28 @@ describe("setupRemoveCommand", () => { ); setupRemoveCommand(mockConfigCommand); - await callAction("javascript", ["vue-basic", "react-basic"], false); + await callAction("javascript", ["vue-basic", "react-basic", "vb"], false); expect(mockSaveLocalConfig).toHaveBeenCalledWith( expect.objectContaining({ + settings: { + ...defaultTemplateConfig.settings, + }, templates: { javascript: { - templates: {}, + templates: { + "node-cli": { + ...defaultTemplateConfig.javascript?.templates?.["node-cli"], + }, + }, }, typescript: { - templates: {}, + ...defaultTemplateConfig.typescript, }, }, }), ); + expect(mockSpinner.succeed).toHaveBeenCalledWith( mocktFn(SUCCESS_REMOVED_KEY, { count: "2", @@ -238,6 +248,144 @@ describe("setupRemoveCommand", () => { }); expect(mockSaveGlobalConfig).toHaveBeenCalledOnce(); }); + }); + + describe("action handler - Wildcard and Mixed Removal", () => { + it("should remove ALL templates using the wildcard '*'", async () => { + mockReadConfigSources.mockImplementation(() => + createMockSources("local"), + ); + + setupRemoveCommand(mockConfigCommand); + await callAction("javascript", ["*"], false); + + expect(mockSaveLocalConfig).toHaveBeenCalledOnce(); + expect(mockSaveLocalConfig).toHaveBeenCalledWith( + expect.objectContaining({ + templates: { + javascript: { + templates: {}, + }, + typescript: { + templates: {}, + }, + }, + }), + ); + expect(mockSpinner.succeed).toHaveBeenCalledWith( + mocktFn(SUCCESS_REMOVED_KEY, { + count: "3", + templateName: "vue-basic, react-basic, node-cli", + language: "javascript", + }), + ); + expect(mockLogger.warning).not.toHaveBeenCalled(); + }); + + it("should remove ALL templates and warn about explicitly listed non-existent names (wildcard + extra)", async () => { + mockReadConfigSources.mockImplementation(() => + createMockSources("local"), + ); + + setupRemoveCommand(mockConfigCommand); + await callAction("javascript", ["*", "non-existent-A", "vb"], false); + + expect(mockSaveLocalConfig).toHaveBeenCalledOnce(); + expect(mockSaveLocalConfig).toHaveBeenCalledWith( + expect.objectContaining({ + settings: {}, + templates: { + javascript: { + templates: {}, + }, + typescript: { + templates: {}, + }, + }, + }), + ); + expect(mockSpinner.succeed).toHaveBeenCalledWith( + mocktFn(SUCCESS_REMOVED_KEY, { + count: "3", + templateName: "vue-basic, react-basic, node-cli", + language: "javascript", + }), + ); + expect(mockLogger.warning).toHaveBeenCalledWith( + mockLogger.colors.yellow( + mocktFn(WARNING_NOT_FOUND_KEY, { + templates: ["non-existent-A", "vb"].join(", "), + }), + ), + ); + }); + + it("should remove existing templates and warn about non-existent ones (no wildcard)", async () => { + mockReadConfigSources.mockImplementation(() => + createMockSources("local"), + ); + + setupRemoveCommand(mockConfigCommand); + await callAction("javascript", ["vue-basic", "non-existent"], false); + + expect(mockSaveLocalConfig).toHaveBeenCalledOnce(); + expect(mockSaveLocalConfig).toHaveBeenCalledWith( + expect.objectContaining({ + settings: {}, + templates: { + javascript: { + templates: { + "react-basic": { + ...defaultTemplateConfig.javascript?.templates?.[ + "react-basic" + ], + }, + "node-cli": { + ...defaultTemplateConfig.javascript?.templates?.["node-cli"], + }, + }, + }, + typescript: { + templates: {}, + }, + }, + }), + ); + expect(mockSpinner.succeed).toHaveBeenCalledWith( + mocktFn(SUCCESS_REMOVED_KEY, { + count: "1", + templateName: "vue-basic", + language: "javascript", + }), + ); + expect(mockLogger.warning).toHaveBeenCalledWith( + mockLogger.colors.yellow( + mocktFn(WARNING_NOT_FOUND_KEY, { + templates: "non-existent", + }), + ), + ); + }); + }); + + describe("action handler - Error and Edge Cases", () => { + const callAction = ( + language: string, + templateNames: string[], + isGlobal: boolean, + ) => { + const parentOpts = { global: isGlobal }; + return actionFn( + language, + templateNames, + {}, + { + parent: { + opts: vi.fn(() => parentOpts), + }, + }, + ); + }; it("should throw DevkitError if local config is not found (isGlobal=false)", async () => { mockReadConfigSources.mockResolvedValue({ @@ -250,11 +398,6 @@ describe("setupRemoveCommand", () => { 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, @@ -272,11 +415,6 @@ describe("setupRemoveCommand", () => { 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, @@ -307,7 +445,7 @@ describe("setupRemoveCommand", () => { ); }); - it("should throw an error if none of the provided template names exist", async () => { + it("should throw an error if none of the provided template names exist (templatesToActOn.length === 0)", async () => { mockReadConfigSources.mockImplementation(() => createMockSources("local"), ); @@ -325,45 +463,6 @@ describe("setupRemoveCommand", () => { ); }); - it("should remove existing templates and warn about non-existent ones", async () => { - mockReadConfigSources.mockImplementation(() => - createMockSources("local"), - ); - - setupRemoveCommand(mockConfigCommand); - await callAction("javascript", ["vue-basic", "non-existent"], false); - - expect(mockSaveLocalConfig).toHaveBeenCalledOnce(); - expect(mockSaveLocalConfig).toHaveBeenCalledWith( - expect.objectContaining({ - templates: { - javascript: { - templates: { - "react-basic": expect.any(Object), - }, - }, - typescript: { - templates: {}, - }, - }, - }), - ); - expect(mockSpinner.succeed).toHaveBeenCalledWith( - mocktFn(SUCCESS_REMOVED_KEY, { - count: "1", - templateName: "vue-basic", - language: "javascript", - }), - ); - expect(mockLogger.warning).toHaveBeenCalledWith( - mockLogger.colors.yellow( - mocktFn(WARNING_NOT_FOUND_KEY, { - templates: "non-existent", - }), - ), - ); - }); - it("should handle unexpected errors gracefully", async () => { const mockError = new Error("Config read failed"); mockReadConfigSources.mockRejectedValue(mockError); diff --git a/packages/devkit/__tests__/units/commands/config/remove/index.spec.ts b/packages/devkit/__tests__/units/commands/config/remove/index.spec.ts new file mode 100644 index 0000000..477c2a2 --- /dev/null +++ b/packages/devkit/__tests__/units/commands/config/remove/index.spec.ts @@ -0,0 +1,273 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { setupRemoveCommand } from "../../../../../src/commands/config/remove/index.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, mockGetTemplateNamesToActOn, mockSaveConfig } = + vi.hoisted(() => ({ + mockHandleErrorAndExit: vi.fn(), + mockGetTemplateNamesToActOn: vi.fn(), + mockSaveConfig: vi.fn(), + })); + +let actionFn: (...options: unknown[]) => Promise; + +vi.mock("#utils/errors/handler.js", () => ({ + handleErrorAndExit: mockHandleErrorAndExit, +})); + +vi.mock("../../../../../src/commands/config/remove/logic.js", () => ({ + getTemplateNamesToActOn: mockGetTemplateNamesToActOn, + saveConfig: mockSaveConfig, +})); + +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.template.list_not_found"; +const ERROR_TEMPLATE_NOT_FOUND_KEY = "errors.template.not_found"; +const ERROR_VALIDATION_REQUIRED_KEY = + "errors.validation.template_name_required"; + +const MOCK_LANGUAGE_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", + }, +}; + +const MOCK_TARGET_CONFIG: CliConfig = { + settings: {} as CliConfig["settings"], + templates: { + javascript: { + templates: MOCK_LANGUAGE_TEMPLATES, + }, + typescript: { + templates: {}, + }, + }, +}; + +const callAction = ( + language: string, + templateNames: string[], + isGlobal: boolean, +) => { + const parentOpts = { global: isGlobal }; + return actionFn( + language, + templateNames, + {}, + { + parent: { + opts: vi.fn(() => parentOpts), + }, + }, + ); +}; + +describe("setupRemoveCommand (Command Handler)", () => { + let mockConfigCommand: any; + + beforeEach(() => { + vi.clearAllMocks(); + actionFn = vi.fn(); + mockConfigCommand = { + command: vi.fn(() => mockConfigCommand), + description: vi.fn(() => mockConfigCommand), + option: vi.fn(() => mockConfigCommand), + alias: vi.fn(() => mockConfigCommand), + action: vi.fn((fn) => { + actionFn = fn; + return mockConfigCommand; + }), + }; + }); + + it("should set up the remove command with correct options and arguments", () => { + setupRemoveCommand(mockConfigCommand); + expect(mockConfigCommand.command).toHaveBeenCalledWith( + "remove ", + ); + expect(mockConfigCommand.alias).toHaveBeenCalledWith("rm"); + expect(mockConfigCommand.description).toHaveBeenCalledWith( + mocktFn(CMD_DESCRIPTION_KEY), + ); + }); + + describe("action handler", () => { + it("should remove one template and save to local config (success case)", async () => { + const templateToRemove = "vue-basic"; + const language = "javascript"; + + mockGetTemplateNamesToActOn.mockResolvedValue({ + targetConfig: structuredClone(MOCK_TARGET_CONFIG), + languageTemplates: structuredClone(MOCK_LANGUAGE_TEMPLATES), + templatesToActOn: [templateToRemove], + notFound: [], + }); + mockSaveConfig.mockResolvedValue(undefined); + + setupRemoveCommand(mockConfigCommand); + await callAction(language, [templateToRemove], false); + + expect(mockGetTemplateNamesToActOn).toHaveBeenCalledWith( + language, + [templateToRemove], + false, + ); + + expect(mockSaveConfig).toHaveBeenCalledOnce(); + const savedConfig = mockSaveConfig.mock.calls[0]![0]; + + expect( + savedConfig.templates[language].templates[templateToRemove], + ).toBeUndefined(); + expect( + savedConfig.templates[language].templates["react-basic"], + ).toBeDefined(); + + expect(mockSpinner.start).toHaveBeenCalledWith( + mockLogger.colors.cyan(mocktFn(STATUS_REMOVING_KEY)), + ); + expect(mockSpinner.succeed).toHaveBeenCalledWith( + mocktFn(SUCCESS_REMOVED_KEY, { + count: "1", + templateName: templateToRemove, + language: language, + }), + ); + expect(mockHandleErrorAndExit).not.toHaveBeenCalled(); + }); + + it("should remove multiple templates and save to global config", async () => { + const templates = ["vue-basic", "react-basic"]; + const language = "javascript"; + + mockGetTemplateNamesToActOn.mockResolvedValue({ + targetConfig: structuredClone(MOCK_TARGET_CONFIG), + languageTemplates: structuredClone(MOCK_LANGUAGE_TEMPLATES), + templatesToActOn: templates, + notFound: [], + }); + mockSaveConfig.mockResolvedValue(undefined); + + setupRemoveCommand(mockConfigCommand); + await callAction(language, templates, true); + + expect(mockGetTemplateNamesToActOn).toHaveBeenCalledWith( + language, + templates, + true, + ); + expect(mockSaveConfig).toHaveBeenCalledWith(expect.any(Object), true); + + const savedConfig = mockSaveConfig.mock.calls[0]![0]; + expect(savedConfig.templates[language].templates).toEqual({}); + + expect(mockSpinner.succeed).toHaveBeenCalledWith( + mocktFn(SUCCESS_REMOVED_KEY, { + count: "2", + templateName: templates.join(", "), + language: language, + }), + ); + expect(mockLogger.warning).not.toHaveBeenCalled(); + }); + + it("should remove existing templates and log a warning for notFound templates", async () => { + const existingTemplate = "vue-basic"; + const missingTemplate = "non-existent"; + const language = "javascript"; + + mockGetTemplateNamesToActOn.mockResolvedValue({ + targetConfig: structuredClone(MOCK_TARGET_CONFIG), + languageTemplates: structuredClone(MOCK_LANGUAGE_TEMPLATES), + templatesToActOn: [existingTemplate], + notFound: [missingTemplate], + }); + mockSaveConfig.mockResolvedValue(undefined); + + setupRemoveCommand(mockConfigCommand); + await callAction(language, [existingTemplate, missingTemplate], false); + + expect(mockSaveConfig).toHaveBeenCalledOnce(); + + expect(mockSpinner.succeed).toHaveBeenCalledWith( + mocktFn(SUCCESS_REMOVED_KEY, { + count: "1", + templateName: existingTemplate, + language: language, + }), + ); + + expect(mockLogger.warning).toHaveBeenCalledWith( + mockLogger.colors.yellow( + mocktFn(WARNING_NOT_FOUND_KEY, { + templates: missingTemplate, + }), + ), + ); + expect(mockHandleErrorAndExit).not.toHaveBeenCalled(); + }); + + it("should throw DevkitError if no template names are provided", async () => { + setupRemoveCommand(mockConfigCommand); + await callAction("javascript", [], false); + + expect(mockHandleErrorAndExit).toHaveBeenCalledWith( + new DevkitError(mocktFn(ERROR_VALIDATION_REQUIRED_KEY)), + mockSpinner, + ); + expect(mockGetTemplateNamesToActOn).not.toHaveBeenCalled(); + }); + + it("should throw DevkitError if getTemplateNamesToActOn returns zero templatesToActOn", async () => { + const missingTemplates = ["non-existent-1", "non-existent-2"]; + + mockGetTemplateNamesToActOn.mockResolvedValue({ + targetConfig: structuredClone(MOCK_TARGET_CONFIG), + languageTemplates: structuredClone(MOCK_LANGUAGE_TEMPLATES), + templatesToActOn: [], + notFound: missingTemplates, + }); + + setupRemoveCommand(mockConfigCommand); + await callAction("javascript", missingTemplates, false); + + expect(mockHandleErrorAndExit).toHaveBeenCalledWith( + new DevkitError( + mocktFn(ERROR_TEMPLATE_NOT_FOUND_KEY, { + template: missingTemplates.join(", "), + }), + ), + mockSpinner, + ); + expect(mockSaveConfig).not.toHaveBeenCalled(); + }); + + it("should handle unexpected errors from getTemplateNamesToActOn gracefully", async () => { + const mockError = new Error("Config read failed during resolution"); + mockGetTemplateNamesToActOn.mockRejectedValue(mockError); + + setupRemoveCommand(mockConfigCommand); + await callAction("javascript", ["vue-basic"], false); + + expect(mockHandleErrorAndExit).toHaveBeenCalledWith( + mockError, + mockSpinner, + ); + expect(mockSaveConfig).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/devkit/__tests__/units/commands/config/remove/logic.spec.ts b/packages/devkit/__tests__/units/commands/config/remove/logic.spec.ts new file mode 100644 index 0000000..91cad7f --- /dev/null +++ b/packages/devkit/__tests__/units/commands/config/remove/logic.spec.ts @@ -0,0 +1,262 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { + saveConfig, + getTargetConfigForModification, + resolveTemplateNames, + getTemplateNamesToActOn, +} from "../../../../../src/commands/config/remove/logic.js"; +import { mocktFn } from "../../../../../vitest.setup.js"; +import type { CliConfig } from "../../../../integrations/common.js"; +import { DevkitError } from "../../../../../src/utils/errors/base.js"; + +const { + mockReadConfigSources, + mockSaveGlobalConfig, + mockSaveLocalConfig, + mockValidateProgrammingLanguage, +} = vi.hoisted(() => ({ + mockReadConfigSources: vi.fn(), + mockSaveGlobalConfig: vi.fn(), + mockSaveLocalConfig: vi.fn(), + mockValidateProgrammingLanguage: vi.fn(), +})); + +vi.mock("#core/config/loader.js", () => ({ + readConfigSources: mockReadConfigSources, +})); + +vi.mock("#core/config/writer.js", () => ({ + saveGlobalConfig: mockSaveGlobalConfig, + saveLocalConfig: mockSaveLocalConfig, +})); + +vi.mock("#utils/validations/config.js", () => ({ + validateProgrammingLanguage: mockValidateProgrammingLanguage, +})); + +const BASE_CONFIG: CliConfig = { + settings: {} as CliConfig["settings"], + templates: { + javascript: { + templates: { + "react-ts": { + description: "A React project with TypeScript", + location: "https://github.com/react-ts-template", + alias: "rt", + }, + "vue-basic": { + description: "A basic Vue template", + location: "https://github.com/vuejs/vue", + alias: "vb", + }, + }, + }, + python: { + templates: {}, + }, + }, +}; + +const ERROR_LOCAL_NOT_FOUND_KEY = "errors.config.local_not_found"; +const ERROR_GLOBAL_NOT_FOUND_KEY = "errors.config.global_not_found"; +const ERROR_LANG_NOT_FOUND_KEY = "errors.template.language_not_found"; + +const createMockSources = ( + local: CliConfig | null, + global: CliConfig | null, +) => { + return Promise.resolve({ + local: structuredClone(local), + global: structuredClone(global), + default: structuredClone(BASE_CONFIG), + configFound: local !== null || global !== null, + }); +}; + +describe("Remove Command Logic Utilities", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockValidateProgrammingLanguage.mockReturnValue(true); + }); + + describe("saveConfig", () => { + it("should call saveLocalConfig when isGlobal is false", async () => { + const config = structuredClone(BASE_CONFIG); + await saveConfig(config, false); + + expect(mockSaveLocalConfig).toHaveBeenCalledWith(config); + expect(mockSaveGlobalConfig).not.toHaveBeenCalled(); + }); + + it("should call saveGlobalConfig when isGlobal is true", async () => { + const config = structuredClone(BASE_CONFIG); + await saveConfig(config, true); + + expect(mockSaveGlobalConfig).toHaveBeenCalledWith(config); + expect(mockSaveLocalConfig).not.toHaveBeenCalled(); + }); + }); + + describe("getTargetConfigForModification", () => { + it("should return local config when isGlobal is false and local exists", async () => { + mockReadConfigSources.mockImplementation(() => + createMockSources(BASE_CONFIG, null), + ); + + const config = await getTargetConfigForModification(false); + expect(config.templates.javascript).toBeDefined(); + }); + + it("should return global config when isGlobal is true and global exists", async () => { + mockReadConfigSources.mockImplementation(() => + createMockSources(null, BASE_CONFIG), + ); + + const config = await getTargetConfigForModification(true); + expect(config.templates.javascript).toBeDefined(); + }); + + it("should throw DevkitError for missing local config", async () => { + mockReadConfigSources.mockImplementation(() => + createMockSources(null, BASE_CONFIG), + ); + + await expect(getTargetConfigForModification(false)).rejects.toThrow( + new DevkitError(mocktFn(ERROR_LOCAL_NOT_FOUND_KEY)), + ); + }); + + it("should throw DevkitError for missing global config", async () => { + mockReadConfigSources.mockImplementation(() => + createMockSources(BASE_CONFIG, null), + ); + + await expect(getTargetConfigForModification(true)).rejects.toThrow( + new DevkitError(mocktFn(ERROR_GLOBAL_NOT_FOUND_KEY)), + ); + }); + }); + + describe("resolveTemplateNames", () => { + const templatesMap = { + "react-ts": "react-ts", + rt: "react-ts", + "vue-basic": "vue-basic", + vb: "vue-basic", + }; + + it("should resolve single template name", () => { + const result = resolveTemplateNames(["vue-basic"], templatesMap); + expect(result.templatesToActOn).toEqual(["vue-basic"]); + expect(result.notFound).toEqual([]); + }); + + it("should resolve single template alias", () => { + const result = resolveTemplateNames(["rt"], templatesMap); + expect(result.templatesToActOn).toEqual(["react-ts"]); + expect(result.notFound).toEqual([]); + }); + + it("should resolve multiple unique names from mixed names and aliases", () => { + const result = resolveTemplateNames( + ["vue-basic", "rt", "react-ts"], + templatesMap, + ); + expect(result.templatesToActOn).toEqual(["vue-basic", "react-ts"]); + expect(result.notFound).toEqual([]); + }); + + it("should identify not found names correctly", () => { + const result = resolveTemplateNames( + ["vue-basic", "not-found", "rt", "another-one"], + templatesMap, + ); + expect(result.templatesToActOn).toEqual(["vue-basic", "react-ts"]); + expect(result.notFound).toEqual(["not-found", "another-one"]); + }); + + it("should handle wildcard '*' alone and resolve all templates", () => { + const result = resolveTemplateNames(["*"], templatesMap); + expect(result.templatesToActOn.sort()).toEqual( + ["react-ts", "vue-basic"].sort(), + ); + expect(result.notFound).toEqual([]); + }); + + it("should handle wildcard '*' with other names/aliases and only list not-found explicit names", () => { + const result = resolveTemplateNames( + ["*", "rt", "missing-A", "vb", "missing-B"], + templatesMap, + ); + expect(result.templatesToActOn.sort()).toEqual( + ["react-ts", "vue-basic"].sort(), + ); + expect(result.notFound.sort()).toEqual( + ["missing-A", "missing-B", "rt", "vb"].sort(), + ); + }); + + it("should return empty results if templatesMap is empty", () => { + const result = resolveTemplateNames(["template-1"], {}); + expect(result.templatesToActOn).toEqual([]); + expect(result.notFound).toEqual(["template-1"]); + }); + }); + + describe("getTemplateNamesToActOn", () => { + beforeEach(() => { + mockReadConfigSources.mockImplementation(() => + createMockSources(BASE_CONFIG, null), + ); + }); + + it("should throw error if validateProgrammingLanguage fails", async () => { + const mockError = new DevkitError("Invalid language"); + mockValidateProgrammingLanguage.mockImplementation(() => { + throw mockError; + }); + + await expect( + getTemplateNamesToActOn("invalid-lang", ["template"], false), + ).rejects.toThrow(mockError); + + expect(mockReadConfigSources).not.toHaveBeenCalled(); + }); + + it("should return correct structure for valid template name", async () => { + const result = await getTemplateNamesToActOn( + "javascript", + ["react-ts"], + false, + ); + + expect(result.targetConfig).toEqual(BASE_CONFIG); + expect(result.languageTemplates).toEqual( + BASE_CONFIG.templates?.javascript?.templates, + ); + expect(result.templatesToActOn).toEqual(["react-ts"]); + expect(result.notFound).toEqual([]); + }); + + it("should throw error if language is not found in config", async () => { + await expect( + getTemplateNamesToActOn("csharp", ["my-template"], false), + ).rejects.toThrow( + new DevkitError( + mocktFn(ERROR_LANG_NOT_FOUND_KEY, { language: "csharp" }), + ), + ); + }); + + it("should throw error if language is found but has empty templates map", async () => { + const result = await getTemplateNamesToActOn( + "python", + ["py-basic"], + false, + ); + + expect(result.templatesToActOn).toEqual([]); + expect(result.notFound).toEqual(["py-basic"]); + }); + }); +}); diff --git a/packages/devkit/__tests__/units/commands/config/update.spec.ts b/packages/devkit/__tests__/units/commands/config/update.spec.ts deleted file mode 100644 index 66fbfd8..0000000 --- a/packages/devkit/__tests__/units/commands/config/update.spec.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { vi, describe, it, expect, beforeEach } from "vitest"; -import { setupUpdateCommand } from "../../../../src/commands/config/update.js"; -import { mockSpinner, mocktFn, mockLogger } from "../../../../vitest.setup.js"; -import { DevkitError } from "../../../../src/utils/errors/base.js"; - -const { mockHandleErrorAndExit, mockHandleNonInteractiveTemplateUpdate } = - vi.hoisted(() => ({ - mockHandleErrorAndExit: vi.fn(), - mockHandleNonInteractiveTemplateUpdate: vi.fn(), - })); - -let actionFn: (...options: unknown[]) => Promise; - -vi.mock("#utils/errors/handler.js", () => ({ - handleErrorAndExit: mockHandleErrorAndExit, -})); - -vi.mock("../../../../src/commands/config/logic.js", () => ({ - handleNonInteractiveTemplateUpdate: mockHandleNonInteractiveTemplateUpdate, -})); - -const consoleLogSpy = mockLogger.log; -const mockProcessExit = vi - .spyOn(process, "exit") - .mockImplementation((() => {}) as unknown as never); - -const CMD_DESCRIPTION_KEY = - "commands.config.update_template.command.description"; -const OPT_NEW_NAME_KEY = "commands.config.update_template.options.new_name"; -const OPT_DESCRIPTION_KEY = - "commands.config.update_template.options.description"; -const OPT_ALIAS_KEY = "commands.config.update_template.options.alias"; -const OPT_LOCATION_KEY = "commands.config.update_template.options.location"; -const OPT_CACHE_STRATEGY_KEY = - "commands.config.update_template.options.cache_strategy"; -const OPT_PACKAGE_MANAGER_KEY = - "commands.config.update_template.options.package_manager"; -const OPT_GLOBAL_KEY = "commands.config.update_template.options.global"; -const STATUS_UPDATING_KEY = "messages.status.template_updating"; -const VALIDATION_REQUIRED_KEY = "errors.validation.template_name_required"; -const TEMPLATE_NOT_FOUND_KEY = "errors.template.not_found"; -const SINGLE_FAIL_KEY = "errors.template.single_fail"; -const SUCCESS_SUMMARY_KEY = "messages.success.template_summary_updated"; - -describe("setupUpdateCommand", () => { - let mockConfigCommand: any; - - beforeEach(() => { - vi.clearAllMocks(); - actionFn = vi.fn(); - mockConfigCommand = { - command: vi.fn(() => mockConfigCommand), - description: vi.fn(() => mockConfigCommand), - option: vi.fn(() => mockConfigCommand), - alias: vi.fn(() => mockConfigCommand), - action: vi.fn((fn) => { - actionFn = fn; - return mockConfigCommand; - }), - }; - }); - - it("should set up the update command with correct options and arguments", () => { - setupUpdateCommand(mockConfigCommand); - expect(mockConfigCommand.command).toHaveBeenCalledWith( - "update ", - ); - expect(mockConfigCommand.alias).toHaveBeenCalledWith("up"); - - expect(mockConfigCommand.description).toHaveBeenCalledWith( - mocktFn(CMD_DESCRIPTION_KEY), - ); - expect(mockConfigCommand.option).toHaveBeenCalledWith( - "-n, --new-name ", - mocktFn(OPT_NEW_NAME_KEY), - ); - expect(mockConfigCommand.option).toHaveBeenCalledWith( - "-d, --description ", - mocktFn(OPT_DESCRIPTION_KEY), - ); - expect(mockConfigCommand.option).toHaveBeenCalledWith( - "-a, --alias ", - mocktFn(OPT_ALIAS_KEY), - ); - expect(mockConfigCommand.option).toHaveBeenCalledWith( - "-l, --location ", - mocktFn(OPT_LOCATION_KEY), - ); - expect(mockConfigCommand.option).toHaveBeenCalledWith( - "--cache-strategy ", - mocktFn(OPT_CACHE_STRATEGY_KEY), - ); - expect(mockConfigCommand.option).toHaveBeenCalledWith( - "--package-manager ", - mocktFn(OPT_PACKAGE_MANAGER_KEY), - ); - expect(mockConfigCommand.option).toHaveBeenCalledWith( - "-g, --global", - mocktFn(OPT_GLOBAL_KEY), - false, - ); - }); - - describe("action handler", () => { - const defaultCmdOptions = { - description: "Updated description", - location: "http://updated.com", - newName: "new-name", - global: false, - }; - - it("should update a single template and print a success message", async () => { - mockHandleNonInteractiveTemplateUpdate.mockResolvedValueOnce(undefined); - - setupUpdateCommand(mockConfigCommand); - await actionFn("javascript", ["my-template"], defaultCmdOptions, { - parent: { opts: () => ({ global: false }) }, - }); - - expect(mockSpinner.start).toHaveBeenCalledWith( - mockLogger.colors.cyan( - mocktFn(STATUS_UPDATING_KEY, { templateName: "my-template" }), - ), - ); - expect(mockHandleNonInteractiveTemplateUpdate).toHaveBeenCalledWith( - "javascript", - "my-template", - { - ...defaultCmdOptions, - language: "javascript", - isGlobal: false, - }, - false, - ); - expect(mockSpinner.stop).toHaveBeenCalled(); - expect(consoleLogSpy).toHaveBeenCalledWith( - mockLogger.colors.green( - `\n✔ ${mocktFn(SUCCESS_SUMMARY_KEY, { - count: "1", - templateName: "my-template", - language: "javascript", - })}`, - ), - ); - expect(mockProcessExit).not.toHaveBeenCalled(); - }); - - it("should update multiple templates and print a summary", async () => { - mockHandleNonInteractiveTemplateUpdate.mockResolvedValue(undefined); - - setupUpdateCommand(mockConfigCommand); - await actionFn("javascript", ["temp1", "temp2"], defaultCmdOptions, { - parent: { opts: () => ({ global: false }) }, - }); - - expect(mockHandleNonInteractiveTemplateUpdate).toHaveBeenCalledTimes(2); - expect(mockHandleNonInteractiveTemplateUpdate).toHaveBeenCalledWith( - "javascript", - "temp1", - expect.objectContaining({ language: "javascript", isGlobal: false }), - false, - ); - expect(mockHandleNonInteractiveTemplateUpdate).toHaveBeenCalledWith( - "javascript", - "temp2", - expect.objectContaining({ language: "javascript", isGlobal: false }), - false, - ); - expect(consoleLogSpy).toHaveBeenCalledWith( - mockLogger.colors.green( - `\n✔ ${mocktFn(SUCCESS_SUMMARY_KEY, { - count: "2", - templateName: "temp1, temp2", - language: "javascript", - })}`, - ), - ); - }); - - it("should handle mixed success and failure and exit with code 1", async () => { - mockHandleNonInteractiveTemplateUpdate - .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce( - new DevkitError( - mocktFn(TEMPLATE_NOT_FOUND_KEY, { template: "temp2" }), - ), - ) - .mockResolvedValueOnce(undefined); - - setupUpdateCommand(mockConfigCommand); - await actionFn( - "javascript", - ["temp1", "temp2", "temp3"], - defaultCmdOptions, - { parent: { opts: () => ({ global: false }) } }, - ); - - expect(mockHandleNonInteractiveTemplateUpdate).toHaveBeenCalledTimes(3); - expect(consoleLogSpy).toHaveBeenCalledWith( - mockLogger.colors.yellow( - `\n${mocktFn(SINGLE_FAIL_KEY, { - templateName: "temp2", - error: mocktFn(TEMPLATE_NOT_FOUND_KEY, { template: "temp2" }), - })}`, - ), - ); - expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining(SUCCESS_SUMMARY_KEY), - ); - expect(mockProcessExit).toHaveBeenCalledOnce(); - expect(mockProcessExit).toHaveBeenCalledWith(1); - }); - - it("should handle an invalid template name (empty array)", async () => { - setupUpdateCommand(mockConfigCommand); - - await actionFn("javascript", [], defaultCmdOptions, { - parent: { opts: () => ({ global: false }) }, - }); - - expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - new DevkitError(mocktFn(VALIDATION_REQUIRED_KEY)), - mockSpinner, - ); - expect(mockHandleNonInteractiveTemplateUpdate).not.toHaveBeenCalled(); - }); - - it("should handle unexpected errors gracefully", async () => { - const mockError = new Error("Unexpected error"); - mockHandleNonInteractiveTemplateUpdate.mockRejectedValueOnce(mockError); - - setupUpdateCommand(mockConfigCommand); - await actionFn("javascript", ["my-template"], defaultCmdOptions, { - parent: { opts: () => ({ global: false }) }, - }); - - expect(mockSpinner.stop).toHaveBeenCalled(); - expect(mockHandleErrorAndExit).not.toHaveBeenCalled(); - expect(consoleLogSpy).toHaveBeenCalledWith( - mockLogger.colors.yellow( - `\n${mocktFn(SINGLE_FAIL_KEY, { - templateName: "my-template", - error: "unknown error", - })}`, - ), - ); - expect(mockProcessExit).toHaveBeenCalledWith(1); - }); - }); -}); diff --git a/packages/devkit/__tests__/units/commands/config/update/index.spec.ts b/packages/devkit/__tests__/units/commands/config/update/index.spec.ts new file mode 100644 index 0000000..067f91a --- /dev/null +++ b/packages/devkit/__tests__/units/commands/config/update/index.spec.ts @@ -0,0 +1,415 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { setupUpdateCommand } from "../../../../../src/commands/config/update/index.js"; +import { + mockSpinner, + mocktFn, + mockLogger, +} from "../../../../../vitest.setup.js"; +import { DevkitError } from "../../../../../src/utils/errors/base.js"; + +const { + mockHandleErrorAndExit, + mockHandleNonInteractiveTemplateUpdate, + mockResolveTemplateNamesForUpdate, + mockValidateProgrammingLanguage, +} = vi.hoisted(() => ({ + mockHandleErrorAndExit: vi.fn(), + mockHandleNonInteractiveTemplateUpdate: vi.fn(), + mockResolveTemplateNamesForUpdate: vi.fn(), + mockValidateProgrammingLanguage: vi.fn(), +})); + +let actionFn: (...options: unknown[]) => Promise; + +vi.mock("#utils/errors/handler.js", () => ({ + handleErrorAndExit: mockHandleErrorAndExit, +})); + +vi.mock("#utils/validations/config.js", () => ({ + validateProgrammingLanguage: mockValidateProgrammingLanguage, +})); + +vi.mock("../../../../../src/commands/config/logic.js", () => ({ + handleNonInteractiveTemplateUpdate: mockHandleNonInteractiveTemplateUpdate, +})); + +vi.mock("../../../../../src/commands/config/update/logic.js", () => ({ + resolveTemplateNamesForUpdate: mockResolveTemplateNamesForUpdate, +})); + +const consoleLogSpy = mockLogger.log; +const mockProcessExit = vi + .spyOn(process, "exit") + .mockImplementation((() => {}) as unknown as never); + +const OPT_NEW_NAME_KEY = "commands.config.update_template.options.new_name"; +const OPT_DESCRIPTION_KEY = + "commands.config.update_template.options.description"; +const OPT_ALIAS_KEY = "commands.config.update_template.options.alias"; +const OPT_LOCATION_KEY = "commands.config.update_template.options.location"; +const OPT_CACHE_STRATEGY_KEY = + "commands.config.update_template.options.cache_strategy"; +const OPT_PACKAGE_MANAGER_KEY = + "commands.config.update_template.options.package_manager"; +const OPT_GLOBAL_KEY = "commands.config.update_template.options.global"; +const VALIDATION_REQUIRED_KEY = "errors.validation.template_name_required"; +const TEMPLATE_NOT_FOUND_KEY = "errors.template.not_found"; +const SINGLE_FAIL_KEY = "errors.template.single_fail"; +const SUCCESS_SUMMARY_KEY = "messages.success.template_summary_updated"; +const WARNING_NOT_FOUND_KEY = "warnings.template.list_not_found"; + +describe("setupUpdateCommand", () => { + let mockConfigCommand: any; + + beforeEach(() => { + vi.clearAllMocks(); + actionFn = vi.fn(); + mockConfigCommand = { + command: vi.fn(() => mockConfigCommand), + description: vi.fn(() => mockConfigCommand), + option: vi.fn(() => mockConfigCommand), + alias: vi.fn(() => mockConfigCommand), + action: vi.fn((fn) => { + actionFn = fn; + return mockConfigCommand; + }), + }; + mockProcessExit.mockClear(); + mockValidateProgrammingLanguage.mockReturnValue(true); + }); + + it("should set up the update command with correct options and arguments", () => { + setupUpdateCommand(mockConfigCommand); + expect(mockConfigCommand.command).toHaveBeenCalledWith( + "update ", + ); + expect(mockConfigCommand.alias).toHaveBeenCalledWith("up"); + + expect(mockConfigCommand.option).toHaveBeenCalledWith( + "-n, --new-name ", + mocktFn(OPT_NEW_NAME_KEY), + ); + expect(mockConfigCommand.option).toHaveBeenCalledWith( + "-d, --description ", + mocktFn(OPT_DESCRIPTION_KEY), + ); + expect(mockConfigCommand.option).toHaveBeenCalledWith( + "-a, --alias ", + mocktFn(OPT_ALIAS_KEY), + ); + expect(mockConfigCommand.option).toHaveBeenCalledWith( + "-l, --location ", + mocktFn(OPT_LOCATION_KEY), + ); + expect(mockConfigCommand.option).toHaveBeenCalledWith( + "--cache-strategy ", + mocktFn(OPT_CACHE_STRATEGY_KEY), + ); + expect(mockConfigCommand.option).toHaveBeenCalledWith( + "--package-manager ", + mocktFn(OPT_PACKAGE_MANAGER_KEY), + ); + expect(mockConfigCommand.option).toHaveBeenCalledWith( + "-g, --global", + mocktFn(OPT_GLOBAL_KEY), + false, + ); + }); + + describe("action handler - Success and Wildcard", () => { + const defaultCmdOptions = { + description: "Updated description", + location: "http://updated.com", + newName: "new-name", + global: false, + }; + const parentOpts = { parent: { opts: () => ({ global: false }) } }; + + it("should update a single template (resolved name) and print a success message", async () => { + mockResolveTemplateNamesForUpdate.mockResolvedValueOnce({ + resolvedNames: ["my-template-actual"], + notFoundNames: [], + }); + mockHandleNonInteractiveTemplateUpdate.mockResolvedValueOnce(undefined); + + setupUpdateCommand(mockConfigCommand); + await actionFn("javascript", ["my-alias"], defaultCmdOptions, parentOpts); + + expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith( + "javascript", + ); + expect(mockResolveTemplateNamesForUpdate).toHaveBeenCalledWith( + "javascript", + ["my-alias"], + false, + ); + + expect(mockHandleNonInteractiveTemplateUpdate).toHaveBeenCalledWith( + "javascript", + "my-template-actual", + { + ...defaultCmdOptions, + language: "javascript", + isGlobal: false, + }, + false, + ); + expect(mockSpinner.stop).toHaveBeenCalled(); + + expect(consoleLogSpy).toHaveBeenCalledWith( + mockLogger.colors.green( + `\n✔ ${mocktFn(SUCCESS_SUMMARY_KEY, { + count: "1", + templateName: "my-template-actual", + language: "javascript", + })}`, + ), + ); + expect(mockLogger.warning).not.toHaveBeenCalled(); + expect(mockProcessExit).not.toHaveBeenCalled(); + }); + + it("should update multiple templates and print a summary", async () => { + const templateList = ["temp1", "temp2"]; + mockResolveTemplateNamesForUpdate.mockResolvedValueOnce({ + resolvedNames: templateList, + notFoundNames: [], + }); + mockHandleNonInteractiveTemplateUpdate.mockResolvedValue(undefined); + + setupUpdateCommand(mockConfigCommand); + await actionFn("javascript", templateList, defaultCmdOptions, parentOpts); + + expect(mockHandleNonInteractiveTemplateUpdate).toHaveBeenCalledTimes(2); + expect(mockHandleNonInteractiveTemplateUpdate).toHaveBeenCalledWith( + "javascript", + "temp1", + expect.objectContaining({ language: "javascript", isGlobal: false }), + false, + ); + expect(mockHandleNonInteractiveTemplateUpdate).toHaveBeenCalledWith( + "javascript", + "temp2", + expect.objectContaining({ language: "javascript", isGlobal: false }), + false, + ); + + expect(consoleLogSpy).toHaveBeenCalledWith( + mockLogger.colors.green( + `\n✔ ${mocktFn(SUCCESS_SUMMARY_KEY, { + count: "2", + templateName: "temp1, temp2", + language: "javascript", + })}`, + ), + ); + expect(mockLogger.warning).not.toHaveBeenCalled(); + expect(mockProcessExit).not.toHaveBeenCalled(); + }); + + it("should update ALL templates using the wildcard '*' and print success", async () => { + const allTemplates = ["tempA", "tempB", "tempC"]; + mockResolveTemplateNamesForUpdate.mockResolvedValueOnce({ + resolvedNames: allTemplates, + notFoundNames: [], + }); + mockHandleNonInteractiveTemplateUpdate.mockResolvedValue(undefined); + + setupUpdateCommand(mockConfigCommand); + await actionFn("javascript", ["*"], defaultCmdOptions, parentOpts); + + expect(mockHandleNonInteractiveTemplateUpdate).toHaveBeenCalledTimes(3); + expect(consoleLogSpy).toHaveBeenCalledWith( + mockLogger.colors.green( + `\n✔ ${mocktFn(SUCCESS_SUMMARY_KEY, { + count: "3", + templateName: "tempA, tempB, tempC", + language: "javascript", + })}`, + ), + ); + expect(mockLogger.warning).not.toHaveBeenCalled(); + expect(mockProcessExit).not.toHaveBeenCalled(); + }); + + it("should update templates (wildcard) and display warning for explicitly listed missing names", async () => { + const allTemplates = ["tempA", "tempB"]; + mockResolveTemplateNamesForUpdate.mockResolvedValueOnce({ + resolvedNames: allTemplates, + notFoundNames: ["missing-1", "missing-2"], + }); + mockHandleNonInteractiveTemplateUpdate.mockResolvedValue(undefined); + + setupUpdateCommand(mockConfigCommand); + await actionFn( + "javascript", + ["*", "missing-1", "missing-2"], + defaultCmdOptions, + parentOpts, + ); + + expect(mockHandleNonInteractiveTemplateUpdate).toHaveBeenCalledTimes(2); + expect(consoleLogSpy).toHaveBeenCalledWith( + mockLogger.colors.green( + expect.stringContaining( + mocktFn(SUCCESS_SUMMARY_KEY, { + count: "2", + templateName: "tempA, tempB", + }), + ), + ), + ); + expect(mockLogger.warning).toHaveBeenCalledWith( + mockLogger.colors.yellow( + mocktFn(WARNING_NOT_FOUND_KEY, { + templates: "missing-1, missing-2", + }), + ), + ); + expect(mockProcessExit).not.toHaveBeenCalled(); + }); + }); + + describe("action handler - Failure and Edge Cases", () => { + const defaultCmdOptions = { + description: "Updated description", + location: "http://updated.com", + newName: "new-name", + global: false, + }; + const parentOpts = { parent: { opts: () => ({ global: false }) } }; + + it("should handle mixed success and failure and exit with code 1", async () => { + const templatesToActOn = ["temp1", "temp2", "temp3"]; + mockResolveTemplateNamesForUpdate.mockResolvedValueOnce({ + resolvedNames: templatesToActOn, + notFoundNames: [], + }); + + mockHandleNonInteractiveTemplateUpdate + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce( + new DevkitError( + mocktFn(TEMPLATE_NOT_FOUND_KEY, { template: "temp2" }), + ), + ) + .mockResolvedValueOnce(undefined); + + setupUpdateCommand(mockConfigCommand); + await actionFn( + "javascript", + templatesToActOn, + defaultCmdOptions, + parentOpts, + ); + + expect(mockHandleNonInteractiveTemplateUpdate).toHaveBeenCalledTimes(3); + + expect(consoleLogSpy).toHaveBeenCalledWith( + mockLogger.colors.yellow( + `\n${mocktFn(SINGLE_FAIL_KEY, { + templateName: "temp2", + error: mocktFn(TEMPLATE_NOT_FOUND_KEY, { template: "temp2" }), + })}`, + ), + ); + + expect(consoleLogSpy).toHaveBeenCalledWith( + mockLogger.colors.green( + `\n✔ ${mocktFn(SUCCESS_SUMMARY_KEY, { + count: "2", + templateName: "temp1, temp2, temp3", + language: "javascript", + })}`, + ), + ); + + expect(mockProcessExit).toHaveBeenCalledWith(1); + }); + + it("should handle an invalid language (validation failure)", async () => { + const mockError = new DevkitError("Invalid language provided"); + mockValidateProgrammingLanguage.mockImplementation(() => { + throw mockError; + }); + + setupUpdateCommand(mockConfigCommand); + await actionFn("invalid-lang", ["my-template"], defaultCmdOptions, { + parent: { opts: () => ({ global: false }) }, + }); + + expect(mockResolveTemplateNamesForUpdate).not.toHaveBeenCalled(); + expect(mockHandleErrorAndExit).toHaveBeenCalledWith( + mockError, + mockSpinner, + ); + }); + + it("should throw an error if no templates are found to act on after resolution", async () => { + mockResolveTemplateNamesForUpdate.mockResolvedValueOnce({ + resolvedNames: [], + notFoundNames: ["missing-1", "missing-2"], + }); + + setupUpdateCommand(mockConfigCommand); + await actionFn( + "javascript", + ["missing-1", "missing-2"], + defaultCmdOptions, + parentOpts, + ); + + expect(mockHandleErrorAndExit).toHaveBeenCalledWith( + new DevkitError( + mocktFn(TEMPLATE_NOT_FOUND_KEY, { + template: "missing-1, missing-2", + }), + ), + mockSpinner, + ); + expect(mockHandleNonInteractiveTemplateUpdate).not.toHaveBeenCalled(); + }); + + it("should handle an invalid template name (empty array to action)", async () => { + setupUpdateCommand(mockConfigCommand); + + await actionFn("javascript", [], defaultCmdOptions, parentOpts); + + expect(mockHandleErrorAndExit).toHaveBeenCalledWith( + new DevkitError(mocktFn(VALIDATION_REQUIRED_KEY)), + mockSpinner, + ); + expect(mockResolveTemplateNamesForUpdate).not.toHaveBeenCalled(); + expect(mockHandleNonInteractiveTemplateUpdate).not.toHaveBeenCalled(); + }); + + it("should handle unexpected errors during template update gracefully", async () => { + const mockError = new Error("Unexpected error"); + mockResolveTemplateNamesForUpdate.mockResolvedValueOnce({ + resolvedNames: ["my-template"], + notFoundNames: [], + }); + mockHandleNonInteractiveTemplateUpdate.mockRejectedValueOnce(mockError); + + setupUpdateCommand(mockConfigCommand); + await actionFn( + "javascript", + ["my-template"], + defaultCmdOptions, + parentOpts, + ); + + expect(mockSpinner.stop).toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith( + mockLogger.colors.yellow( + `\n${mocktFn(SINGLE_FAIL_KEY, { + templateName: "my-template", + error: "unknown error", + })}`, + ), + ); + expect(mockProcessExit).toHaveBeenCalledWith(1); + expect(mockHandleErrorAndExit).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/devkit/__tests__/units/commands/config/update/logic.spec.ts b/packages/devkit/__tests__/units/commands/config/update/logic.spec.ts new file mode 100644 index 0000000..496c8a6 --- /dev/null +++ b/packages/devkit/__tests__/units/commands/config/update/logic.spec.ts @@ -0,0 +1,236 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { resolveTemplateNamesForUpdate } from "../../../../../src/commands/config/update/logic.js"; +import type { CliConfig } from "../../../../../src/utils/schema/schema.js"; + +const { mockReadConfigSources } = vi.hoisted(() => ({ + mockReadConfigSources: vi.fn(), +})); + +vi.mock("#core/config/loader.js", () => ({ + readConfigSources: mockReadConfigSources, +})); + +const mockCliConfig: CliConfig = { + settings: {} as CliConfig["settings"], + templates: { + javascript: { + templates: { + "react-ts": { + description: "A React project with TypeScript", + location: "https://github.com/react-ts-template", + alias: "rt", + }, + "vue-basic": { + description: "A basic Vue template", + location: "https://github.com/vuejs/vue", + alias: "vb", + }, + }, + }, + python: { + templates: { + "py-data": { + description: "Python data script", + location: "https://github.com/python/data", + }, + }, + }, + empty: { + templates: {}, + }, + }, +}; + +const createMockSources = ( + targetType: "local" | "global" | "none", + config: CliConfig | null = mockCliConfig, +) => { + return Promise.resolve({ + local: targetType === "local" ? structuredClone(config) : null, + global: targetType === "global" ? structuredClone(config) : null, + default: structuredClone(mockCliConfig), + configFound: targetType !== "none", + }); +}; + +describe("resolveTemplateNamesForUpdate", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Template Resolution (Local Config)", () => { + beforeEach(() => { + mockReadConfigSources.mockImplementation(() => + createMockSources("local"), + ); + }); + + it("should resolve a single template name correctly", async () => { + const result = await resolveTemplateNamesForUpdate( + "javascript", + ["react-ts"], + false, + ); + expect(result.resolvedNames).toEqual(["react-ts"]); + expect(result.notFoundNames).toEqual([]); + }); + + it("should resolve a template by its alias", async () => { + const result = await resolveTemplateNamesForUpdate( + "javascript", + ["rt"], + false, + ); + expect(result.resolvedNames).toEqual(["react-ts"]); + expect(result.notFoundNames).toEqual([]); + }); + + it("should resolve multiple unique templates from mixed names/aliases", async () => { + const result = await resolveTemplateNamesForUpdate( + "javascript", + ["vue-basic", "rt", "react-ts"], + false, + ); + expect(result.resolvedNames).toEqual(["vue-basic", "react-ts"]); + expect(result.notFoundNames).toEqual([]); + }); + + it("should correctly identify not found templates", async () => { + const result = await resolveTemplateNamesForUpdate( + "javascript", + ["react-ts", "missing-one", "vb", "missing-two"], + false, + ); + expect(result.resolvedNames).toEqual(["react-ts", "vue-basic"]); + expect(result.notFoundNames).toEqual(["missing-one", "missing-two"]); + }); + + it("should return empty arrays for resolution if all names are not found", async () => { + const result = await resolveTemplateNamesForUpdate( + "javascript", + ["missing-one", "missing-two"], + false, + ); + expect(result.resolvedNames).toEqual([]); + expect(result.notFoundNames).toEqual(["missing-one", "missing-two"]); + }); + }); + + describe("Wildcard ('*') Resolution", () => { + beforeEach(() => { + mockReadConfigSources.mockImplementation(() => + createMockSources("global"), + ); + }); + + it("should resolve ALL templates for the language when '*' is used alone", async () => { + const result = await resolveTemplateNamesForUpdate( + "javascript", + ["*"], + true, + ); + expect(result.resolvedNames.sort()).toEqual( + ["react-ts", "vue-basic"].sort(), + ); + expect(result.notFoundNames).toEqual([]); + }); + + it("should resolve ALL templates and list non-wildcard names as notFound if they don't resolve", async () => { + const result = await resolveTemplateNamesForUpdate( + "javascript", + ["*", "missing-A", "rt", "missing-B"], + true, + ); + expect(result.resolvedNames.sort()).toEqual( + ["react-ts", "vue-basic"].sort(), + ); + expect(result.notFoundNames.sort()).toEqual( + ["missing-A", "missing-B", "rt"].sort(), + ); + }); + + it("should resolve ALL templates for a language with one template", async () => { + const result = await resolveTemplateNamesForUpdate("python", ["*"], true); + expect(result.resolvedNames).toEqual(["py-data"]); + expect(result.notFoundNames).toEqual([]); + }); + }); + + describe("Config State Handling", () => { + it("should use global config when isGlobal is true", async () => { + mockReadConfigSources.mockImplementation(() => + createMockSources("global"), + ); + const result = await resolveTemplateNamesForUpdate( + "javascript", + ["react-ts"], + true, + ); + expect(mockReadConfigSources).toHaveBeenCalledWith({ + forceGlobal: true, + forceLocal: false, + }); + expect(result.resolvedNames).toEqual(["react-ts"]); + }); + + it("should use local config when isGlobal is false", async () => { + mockReadConfigSources.mockImplementation(() => + createMockSources("local"), + ); + const result = await resolveTemplateNamesForUpdate( + "javascript", + ["vue-basic"], + false, + ); + expect(mockReadConfigSources).toHaveBeenCalledWith({ + forceGlobal: false, + forceLocal: true, + }); + expect(result.resolvedNames).toEqual(["vue-basic"]); + }); + + it("should treat missing local config file as 'language templates undefined'", async () => { + mockReadConfigSources.mockResolvedValue({ + local: null, + global: mockCliConfig, + default: mockCliConfig, + configFound: true, + }); + + const result = await resolveTemplateNamesForUpdate( + "javascript", + ["react-ts"], + false, + ); + + expect(result.resolvedNames).toEqual([]); + expect(result.notFoundNames).toEqual(["react-ts"]); + }); + + it("should return names as notFound if language exists but has empty templates map", async () => { + mockReadConfigSources.mockImplementation(() => + createMockSources("local"), + ); + const result = await resolveTemplateNamesForUpdate( + "empty", + ["new-temp"], + false, + ); + expect(result.resolvedNames).toEqual([]); + expect(result.notFoundNames).toEqual(["new-temp"]); + }); + + it("should return names as notFound if language does not exist in the config", async () => { + mockReadConfigSources.mockImplementation(() => + createMockSources("local"), + ); + const result = await resolveTemplateNamesForUpdate( + "rust", + ["hello-world"], + false, + ); + expect(result.resolvedNames).toEqual([]); + expect(result.notFoundNames).toEqual(["hello-world"]); + }); + }); +}); diff --git a/packages/devkit/__tests__/units/commands/init/index.spec.ts b/packages/devkit/__tests__/units/commands/init/index.spec.ts new file mode 100644 index 0000000..770bde7 --- /dev/null +++ b/packages/devkit/__tests__/units/commands/init/index.spec.ts @@ -0,0 +1,147 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { setupInitCommand } from "../../../../src/commands/init/index.js"; +import { mockSpinner, mocktFn } from "../../../../vitest.setup.js"; +import type { SetupCommandOptions } from "../../../../src/utils/schema/schema.js"; +import { ConfigError } from "../../../../src/utils/errors/base.js"; + +const { mockHandleErrorAndExit, mockHandleGlobalInit, mockHandleLocalInit } = + vi.hoisted(() => ({ + mockHandleErrorAndExit: vi.fn(), + mockHandleGlobalInit: vi.fn(), + mockHandleLocalInit: vi.fn(), + })); + +let actionFn: (...options: unknown[]) => Promise; + +vi.mock("#utils/errors/handler.js", () => ({ + handleErrorAndExit: mockHandleErrorAndExit, +})); + +vi.mock("../../../../src/commands/init/logic.js", () => ({ + handleGlobalInit: mockHandleGlobalInit, + handleLocalInit: mockHandleLocalInit, +})); + +const CMD_DESCRIPTION_KEY = "commands.config.init.command.description"; +const OPT_LOCAL_KEY = "commands.config.init.option.local"; +const OPT_GLOBAL_KEY = "commands.config.init.option.global"; +const ERROR_INIT_MUTUAL_EXCLUSION_KEY = "errors.config.init_local_and_global"; + +const callAction = (local: boolean, global: boolean) => { + return actionFn({ local, global }); +}; + +describe("setupInitCommand", () => { + let mockProgram: any; + let setupOptions: SetupCommandOptions; + + beforeEach(() => { + vi.clearAllMocks(); + actionFn = vi.fn(); + mockProgram = { + command: vi.fn(() => mockProgram), + alias: vi.fn(() => mockProgram), + description: vi.fn(() => mockProgram), + option: vi.fn(() => mockProgram), + action: vi.fn((fn) => { + actionFn = fn; + return mockProgram; + }), + }; + setupOptions = { program: mockProgram }; + }); + + it("should set up the init command with correct arguments and options", () => { + setupInitCommand(setupOptions); + + expect(mockProgram.command).toHaveBeenCalledWith("init"); + expect(mockProgram.alias).toHaveBeenCalledWith("i"); + expect(mockProgram.description).toHaveBeenCalledWith( + mocktFn(CMD_DESCRIPTION_KEY), + ); + + expect(mockProgram.option).toHaveBeenCalledWith( + "-l, --local", + mocktFn(OPT_LOCAL_KEY), + false, + ); + expect(mockProgram.option).toHaveBeenCalledWith( + "-g, --global", + mocktFn(OPT_GLOBAL_KEY), + false, + ); + }); + + describe("action handler", () => { + beforeEach(() => { + setupInitCommand(setupOptions); + }); + + it("should default to calling handleLocalInit when no options are provided", async () => { + await callAction(false, false); + + expect(mockHandleLocalInit).toHaveBeenCalledWith(mockSpinner); + expect(mockHandleGlobalInit).not.toHaveBeenCalled(); + expect(mockHandleErrorAndExit).not.toHaveBeenCalled(); + }); + + it("should call handleLocalInit when the --local option is set", async () => { + await callAction(true, false); + + expect(mockHandleLocalInit).toHaveBeenCalledWith(mockSpinner); + expect(mockHandleGlobalInit).not.toHaveBeenCalled(); + expect(mockHandleErrorAndExit).not.toHaveBeenCalled(); + }); + + it("should call handleGlobalInit when the --global option is set", async () => { + await callAction(false, true); + + expect(mockHandleGlobalInit).toHaveBeenCalledWith(mockSpinner); + expect(mockHandleLocalInit).not.toHaveBeenCalled(); + expect(mockHandleErrorAndExit).not.toHaveBeenCalled(); + }); + + it("should throw ConfigError and call handleErrorAndExit when both --local and --global are set", async () => { + await callAction(true, true); + + expect(mockHandleGlobalInit).not.toHaveBeenCalled(); + expect(mockHandleLocalInit).not.toHaveBeenCalled(); + + expect(mockHandleErrorAndExit).toHaveBeenCalledTimes(1); + const errorCalled = mockHandleErrorAndExit.mock.calls[0]![0]; + + expect(errorCalled).toBeInstanceOf(ConfigError); + expect(errorCalled.message).toBe( + mocktFn(ERROR_INIT_MUTUAL_EXCLUSION_KEY), + ); + expect(mockHandleErrorAndExit).toHaveBeenCalledWith( + errorCalled, + mockSpinner, + ); + }); + + it("should catch and handle errors from handleGlobalInit", async () => { + const mockError = new Error("Global initialization failed"); + mockHandleGlobalInit.mockRejectedValue(mockError); + + await callAction(false, true); + + expect(mockHandleErrorAndExit).toHaveBeenCalledWith( + mockError, + mockSpinner, + ); + }); + + it("should catch and handle errors from handleLocalInit", async () => { + const mockError = new Error("Local initialization failed"); + mockHandleLocalInit.mockRejectedValue(mockError); + + await callAction(true, false); + + expect(mockHandleErrorAndExit).toHaveBeenCalledWith( + mockError, + mockSpinner, + ); + }); + }); +}); diff --git a/packages/devkit/__tests__/units/commands/init/logic.spec.ts b/packages/devkit/__tests__/units/commands/init/logic.spec.ts new file mode 100644 index 0000000..fbe99d9 --- /dev/null +++ b/packages/devkit/__tests__/units/commands/init/logic.spec.ts @@ -0,0 +1,288 @@ +import { vi, describe, it, expect, beforeEach, afterAll } from "vitest"; +import { + promptForStandardOverwrite, + handleGlobalInit, + handleLocalInit, +} from "../../../../src/commands/init/logic.js"; +import { CONFIG_FILE_NAMES } from "../../../../src/utils/schema/schema.js"; +import { mockSpinner, mocktFn, mockLogger } from "../../../../vitest.setup.js"; + +const { + mockFs, + mockPath, + mockOs, + mockSelect, + mockFindGlobalConfigFile, + mockFindMonorepoRoot, + mockFindProjectRoot, + mockFindUp, + mockSaveConfig, + mockGetPackageManager, +} = vi.hoisted(() => ({ + mockFs: { + pathExists: vi.fn(), + }, + mockPath: { + join: vi.fn(), + }, + mockOs: { + homedir: vi.fn(), + }, + mockSelect: vi.fn(), + mockFindGlobalConfigFile: vi.fn(), + mockFindMonorepoRoot: vi.fn(), + mockFindProjectRoot: vi.fn(), + mockFindUp: vi.fn(), + mockSaveConfig: vi.fn(), + mockGetPackageManager: vi.fn(), +})); + +vi.mock("#utils/fs/file.js", () => ({ default: mockFs })); +vi.mock("path", () => ({ default: mockPath })); +vi.mock("os", () => ({ default: mockOs })); +vi.mock("@inquirer/prompts", () => ({ select: mockSelect })); +vi.mock("#core/config/search.js", () => ({ + findGlobalConfigFile: mockFindGlobalConfigFile, +})); +vi.mock("#utils/fs/finder.js", () => ({ + findMonorepoRoot: mockFindMonorepoRoot, + findProjectRoot: mockFindProjectRoot, +})); +vi.mock("#utils/fs/find-up.js", () => ({ findUp: mockFindUp })); +vi.mock("#core/config/writer.js", () => ({ saveConfig: mockSaveConfig })); +vi.mock("#utils/package-manager/index.js", () => ({ + getPackageManager: mockGetPackageManager, +})); + +const CONFIG_INIT_START_KEY = "messages.status.config_init_start"; +const CONFIG_INITIALIZED_KEY = "messages.success.config_initialized"; +const CONFIRM_OVERWRITE_KEY = "commands.config.init.confirm_overwrite"; +const INIT_ABORTED_KEY = "commands.config.init.aborted"; +const COMMON_YES_KEY = "common.yes"; +const COMMON_NO_KEY = "common.no"; + +describe("Init Command Logic", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPath.join.mockImplementation((...args) => args.join("/")); + mockOs.homedir.mockReturnValue("/home/user"); + mockGetPackageManager.mockResolvedValue("pnpm"); + }); + + describe("promptForStandardOverwrite", () => { + const mockPath = "/path/to/config.json"; + + it("should return true if the user selects 'yes'", async () => { + mockSelect.mockResolvedValue(true); + const result = await promptForStandardOverwrite(mockPath); + + expect(mockSelect).toHaveBeenCalledWith({ + message: mockLogger.colors.yellow( + mocktFn(CONFIRM_OVERWRITE_KEY, { path: mockPath }), + ), + choices: [ + { name: mocktFn(COMMON_YES_KEY), value: true }, + { name: mocktFn(COMMON_NO_KEY), value: false }, + ], + default: true, + }); + expect(result).toBe(true); + }); + + it("should return false if the user selects 'no'", async () => { + mockSelect.mockResolvedValue(false); + const result = await promptForStandardOverwrite(mockPath); + expect(result).toBe(false); + }); + }); + + describe("handleGlobalInit", () => { + const globalFileName = CONFIG_FILE_NAMES[0]; + const defaultGlobalPath = `/home/user/${globalFileName}`; + + it("should create config at default global path if no existing file is found", async () => { + mockFindGlobalConfigFile.mockResolvedValue(null); + mockFs.pathExists.mockResolvedValue(false); + + await handleGlobalInit(mockSpinner); + + expect(mockPath.join).toHaveBeenCalledWith("/home/user", globalFileName); + + expect(mockSaveConfig).toHaveBeenCalledTimes(1); + expect(mockSaveConfig).toHaveBeenCalledWith( + expect.objectContaining({ + settings: expect.objectContaining({ defaultPackageManager: "pnpm" }), + }), + defaultGlobalPath, + ); + + expect(mockSpinner.start).toHaveBeenCalledWith( + mockLogger.colors.cyan( + mocktFn(CONFIG_INIT_START_KEY, { path: defaultGlobalPath }), + ), + ); + expect(mockSpinner.succeed).toHaveBeenCalledWith( + mockLogger.colors.green(mocktFn(CONFIG_INITIALIZED_KEY)), + ); + expect(mockSpinner.info).not.toHaveBeenCalled(); + }); + + it("should use existing global config path if found and overwrite is confirmed", async () => { + const existingPath = "/etc/custom/config.json"; + mockFindGlobalConfigFile.mockResolvedValue(existingPath); + mockFs.pathExists.mockResolvedValue(true); + mockSelect.mockResolvedValue(true); + + await handleGlobalInit(mockSpinner); + + expect(mockSelect).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining(existingPath), + }), + ); + + expect(mockSaveConfig).toHaveBeenCalledWith( + expect.any(Object), + existingPath, + ); + expect(mockSpinner.succeed).toHaveBeenCalled(); + expect(mockSpinner.info).not.toHaveBeenCalled(); + }); + + it("should abort if existing global config file is found and overwrite is denied", async () => { + const existingPath = "/home/user/.cli-config.json"; + mockFindGlobalConfigFile.mockResolvedValue(null); + mockFs.pathExists.mockResolvedValue(true); + mockSelect.mockResolvedValue(false); + + await handleGlobalInit(mockSpinner); + + expect(mockSelect).toHaveBeenCalled(); + + expect(mockSaveConfig).not.toHaveBeenCalled(); + expect(mockSpinner.info).toHaveBeenCalledWith( + mockLogger.colors.yellow(mocktFn(INIT_ABORTED_KEY)), + ); + expect(mockSpinner.succeed).not.toHaveBeenCalled(); + }); + + it("should detect and set 'yarn' as the default package manager", async () => { + mockFindGlobalConfigFile.mockResolvedValue(null); + mockFs.pathExists.mockResolvedValue(false); + mockGetPackageManager.mockResolvedValue("yarn"); + + await handleGlobalInit(mockSpinner); + + expect(mockSaveConfig).toHaveBeenCalledWith( + expect.objectContaining({ + settings: expect.objectContaining({ defaultPackageManager: "yarn" }), + }), + defaultGlobalPath, + ); + }); + }); + + describe("handleLocalInit", () => { + const localFileName = CONFIG_FILE_NAMES[1]; + const currentPath = "/project/current"; + const monorepoRoot = "/project"; + const projectRoot = "/project/sub"; + + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(currentPath); + + it("should create config at the project root if no existing file is found", async () => { + mockFindUp.mockResolvedValue(null); + mockFindMonorepoRoot.mockResolvedValue(null); + mockFindProjectRoot.mockResolvedValue(projectRoot); + + const expectedPath = `${projectRoot}/${localFileName}`; + + await handleLocalInit(mockSpinner); + + expect(mockFindUp).toHaveBeenCalledWith( + expect.objectContaining({ cwd: projectRoot, limit: projectRoot }), + ); + expect(mockPath.join).toHaveBeenCalledWith(projectRoot, localFileName); + + expect(mockSaveConfig).toHaveBeenCalledWith( + expect.any(Object), + expectedPath, + ); + expect(mockSpinner.succeed).toHaveBeenCalled(); + }); + + it("should prioritize monorepo root for path construction", async () => { + mockFindUp.mockResolvedValue(null); + mockFindMonorepoRoot.mockResolvedValue(monorepoRoot); + mockFindProjectRoot.mockResolvedValue(projectRoot); + + const expectedPath = `${monorepoRoot}/${localFileName}`; + + await handleLocalInit(mockSpinner); + + expect(mockFindUp).toHaveBeenCalledWith( + expect.objectContaining({ cwd: monorepoRoot, limit: monorepoRoot }), + ); + expect(mockPath.join).toHaveBeenCalledWith(monorepoRoot, localFileName); + expect(mockSaveConfig).toHaveBeenCalledWith( + expect.any(Object), + expectedPath, + ); + }); + + it("should use process.cwd() if no project or monorepo root is found", async () => { + mockFindUp.mockResolvedValue(null); + mockFindMonorepoRoot.mockResolvedValue(null); + mockFindProjectRoot.mockResolvedValue(null); + + const expectedPath = `${currentPath}/${localFileName}`; + + await handleLocalInit(mockSpinner); + + expect(mockFindUp).toHaveBeenCalledWith( + expect.objectContaining({ cwd: currentPath, limit: currentPath }), + ); + expect(mockPath.join).toHaveBeenCalledWith(currentPath, localFileName); + expect(mockSaveConfig).toHaveBeenCalledWith( + expect.any(Object), + expectedPath, + ); + }); + + it("should use existing config path and save if overwrite is confirmed", async () => { + const existingPath = "/project/.cli-config.json"; + mockFindUp.mockResolvedValue(existingPath); + mockSelect.mockResolvedValue(true); + mockFindMonorepoRoot.mockResolvedValue(monorepoRoot); + + await handleLocalInit(mockSpinner); + + expect(mockSelect).toHaveBeenCalled(); + expect(mockSaveConfig).toHaveBeenCalledWith( + expect.any(Object), + existingPath, + ); + expect(mockSpinner.succeed).toHaveBeenCalled(); + }); + + it("should use existing config path and abort if overwrite is denied", async () => { + const existingPath = "/project/current/.cli-config.json"; + mockFindUp.mockResolvedValue(existingPath); + mockSelect.mockResolvedValue(false); + + await handleLocalInit(mockSpinner); + + expect(mockSelect).toHaveBeenCalled(); + + expect(mockSaveConfig).not.toHaveBeenCalled(); + expect(mockSpinner.info).toHaveBeenCalledWith( + mockLogger.colors.yellow(mocktFn(INIT_ABORTED_KEY)), + ); + expect(mockSpinner.succeed).not.toHaveBeenCalled(); + }); + + afterAll(() => { + cwdSpy.mockRestore(); + }); + }); +}); diff --git a/packages/devkit/locales/en.json b/packages/devkit/locales/en.json index 252d432..9189dbe 100644 --- a/packages/devkit/locales/en.json +++ b/packages/devkit/locales/en.json @@ -375,7 +375,7 @@ } }, "warnings": { - "not_found": "⚠️ No configuration file found. Using default settings.", + "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": { @@ -386,7 +386,7 @@ "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.", + "no_config_found": "No configuration file found. Using default configuration.", "filter_delimiter_missing": "Ignoring filter clause missing a delimiter ('{delimiter1}' or '{delimiter2}'): {clause}", "filter_property_unrecognized": "Ignoring unrecognized filter property: {property}" } diff --git a/packages/devkit/locales/fr.json b/packages/devkit/locales/fr.json index d17d08a..bfa9f8f 100644 --- a/packages/devkit/locales/fr.json +++ b/packages/devkit/locales/fr.json @@ -6,17 +6,17 @@ }, "program": { "program": { - "description": "Une puissante boîte à outils (devkit) pour l'échafaudage de nouveaux projets.", - "verbose_option": "Activer la journalisation verbeuse pour une sortie détaillée" + "description": "Un puissant devkit pour l'échafaudage de nouveaux projets.", + "verbose_option": "Activer les logs verbeux pour une sortie détaillée" }, "version": { - "description": "Afficher la version actuelle de l'interface de ligne de commande." + "description": "Afficher la version actuelle de la CLI." }, "help": { "description": "Afficher l'aide pour une commande." }, "status": { - "initializing": "Initialisation de l'interface de ligne de commande..." + "initializing": "Initialisation de la CLI..." } }, "commands": { @@ -26,14 +26,14 @@ }, "project": { "language": { - "argument": "Le langage de programmation du modèle (par ex. 'react', 'node')" + "argument": "Le langage de programmation du modèle (ex: 'react', 'node')" }, "name": { "argument": "Le nom de votre nouveau projet" }, "template": { "option": { - "description": "Le nom du modèle à utiliser (par ex. 'ts', 'simple-api')" + "description": "Le nom du modèle à utiliser (ex: 'ts', 'simple-api')" } } } @@ -48,15 +48,15 @@ "option": "Définir le mode d'affichage : 'tree' ou 'table'. (Par défaut : tree)" }, "where": { - "option": "Filtrer les modèles par propriété en utilisant le format : ou =.\n\nLes filtres utilisent la correspondance de sous-chaîne (insensible à la casse) (par ex. 'desc:vue').\nUtilisez '*' pour les valeurs présentes (non vides) et '~' pour les valeurs manquantes (vides).\n\nExemple : 'pm:bun alias:* cache:~' (logique ET)" + "option": "Filtrer les modèles par propriété en utilisant le format :.\n\nLes filtres utilisent une correspondance de sous-chaîne insensible à la casse (ex: 'desc:vue').\nUtilisez '*' pour présent (non-vide) et '~' pour manquant (vide).\n\nExemple : 'pm:bun alias:* cache:~' (logique ET)" } }, "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.", - "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" + "settings": "Inclure les paramètres de configuration dans la sortie", + "include_defaults": "Inclure les modèles et paramètres par défaut non configurés dans la sortie" }, "output": { "header": "Modèles disponibles :", @@ -73,19 +73,19 @@ "settings": "Paramètres", "templates": "Modèles" }, - "prompt_setting_key": "Choisissez un paramètre à modifier :", - "prompt_template_name": "Choisissez un modèle à mettre à jour :", - "prompt_language": "Choisissez un langage :", - "prompt_template_property": "Choisissez une propriété à mettre à jour :", - "prompt_new_value": "Entrez la nouvelle valeur pour {key} :", + "prompt_setting_key": "Choisir un paramètre à modifier :", + "prompt_template_name": "Choisir un modèle à mettre à jour :", + "prompt_language": "Choisir une langue :", + "prompt_template_property": "Choisir une propriété à mettre à jour :", + "prompt_new_value": "Entrer la nouvelle valeur pour {key} :", "success": "Session de configuration terminée." }, "set": { "command": { - "description": "Définir une ou plusieurs valeurs de configuration. \n\nClés disponibles :\n pm, packageManager - Définit le gestionnaire de paquets par défaut à utiliser pour les nouveaux projets.\n Valeurs possibles : {pmValues}\n cache, cacheStrategy - Définit le comportement de mise en cache global pour les modèles distants.\n Valeurs possibles : 'always-refresh', 'never-refresh', 'daily'\n language, lg - Définit la langue de l'interface de ligne de commande. Valeurs possibles : 'en', 'fr'\n" + "description": "Définir une ou plusieurs valeurs de configuration. \n\nClés disponibles :\n pm, packageManager - Définit le gestionnaire de paquets par défaut à utiliser pour les nouveaux projets.\n Valeurs possibles : {pmValues}\n cache, cacheStrategy - Définit le comportement de mise en cache global pour les modèles distants.\n Valeurs possibles : 'always-refresh', 'never-refresh', 'daily'\n language, lg - Définit la langue de la CLI. Valeurs possibles : 'en', 'fr'\n" }, "argument": { - "description": "Une liste de paires clé-valeur à définir (par ex. 'pm npm language fr')" + "description": "Une liste de paires clé-valeur à définir (ex: 'pm npm language fr')" }, "option": { "global": "Mettre à jour la configuration globale au lieu de la locale.", @@ -97,7 +97,7 @@ "description": "Obtenir une ou plusieurs valeurs de configuration." }, "argument": { - "description": "La clé de la valeur de configuration à obtenir (par ex. 'pm', 'language')." + "description": "La clé de la valeur de configuration à obtenir (ex: 'pm', 'language')." }, "option": { "global": "Obtenir la configuration globale au lieu de la locale." @@ -123,7 +123,7 @@ "argument": "Le langage de programmation du modèle à mettre à jour." }, "template": { - "argument": "Le nom ou l'alias du modèle à mettre à jour." + "argument": "Le nom ou l'alias du ou des modèles à mettre à jour. Utiliser '*' pour tous les modèles." }, "options": { "new_name": "Un nouveau nom pour le modèle.", @@ -140,19 +140,19 @@ "description": "Initialise un fichier de configuration avec les paramètres par défaut." }, "option": { - "local": "Initialiser un fichier de configuration local au lieu d'un global.", - "global": "Initialiser un fichier de configuration global au lieu d'un local." + "local": "Initialiser un fichier de configuration locale au lieu de globale.", + "global": "Initialiser un fichier de configuration globale au lieu de locale." }, "confirm_overwrite": "Le fichier de configuration existe déjà à {path}. Voulez-vous l'écraser ?", "confirm_monorepo_overwrite": "Un fichier de configuration existe à la racine du monorepo à {path}. Voulez-vous en créer un nouveau dans le paquet actuel ?", "monorepo_location": "Une racine de monorepo a été trouvée, mais aucun fichier de configuration n'y existe. Où voulez-vous créer le nouveau fichier de configuration ?", "location_current": "Le créer dans le paquet actuel.", "location_root": "Le créer à la racine du monorepo.", - "aborted": "Opération annulée. Aucune modification n'a été apportée." + "aborted": "Opération annulée. Aucune modification n'a été effectuée." }, "list": { "command": { - "description": "Lister les paramètres et modèles disponibles." + "description": "Lister les paramètres et les modèles disponibles." }, "options": { "all": "Lister les modèles des configurations locale et globale (fusionnées)." @@ -172,23 +172,23 @@ }, "prompts": { "language": "Langage du modèle", - "template_name": "Entrez le nom du modèle", - "location": "Entrez l'emplacement du modèle", - "description": "Entrez la description du modèle", - "alias": "Entrez un alias court pour le modèle", + "template_name": "Entrer le nom du modèle", + "location": "Entrer l'emplacement du modèle", + "description": "Entrer la description du modèle", + "alias": "Entrer un alias court pour le modèle", "cache_strategy": "Stratégie de cache", "package_manager": "Gestionnaire de paquets" } }, "remove": { "command": { - "description": "Supprimer un modèle de la configuration." + "description": "Supprimer un ou plusieurs modèles de la configuration." }, "language": { "argument": "Le langage de programmation du modèle à supprimer." }, "name": { - "argument": "Le nom ou l'alias du modèle à supprimer." + "argument": "Le nom ou l'alias du ou des modèles à supprimer. Utiliser '*' pour tous les modèles." }, "option": { "global": "Supprimer le modèle de la configuration globale au lieu de la locale." @@ -200,7 +200,7 @@ "description": "Afficher les informations système et d'environnement pour le débogage." }, "header": { - "cli": "Scaffolder CLI", + "cli": "CLI Scaffolder", "runtime": "Environnement d'exécution", "os_details": "Détails du système d'exploitation", "config_files": "Fichiers de configuration" @@ -209,43 +209,39 @@ "version": "Version" }, "runtime": { - "runtime_name": "Environnement d'exécution", - "runtime_version": "Version de l'environnement d'exécution", + "runtime_name": "Environnement", + "runtime_version": "Version de l'environnement", "package_manager": "Gestionnaire de paquets" }, "os": { - "type_version": "Type/Version du SE", + "type_version": "Type/Version de l'OS", "architecture": "Architecture", "shell": "Shell actif", - "home_dir": "Répertoire d'accueil" + "home_dir": "Répertoire personnel" }, "shell": { "unknown": "Inconnu" }, "config": { - "global_path": "Chemin de la configuration globale", - "local_path": "Chemin de la configuration locale", + "global_path": "Chemin de la config globale", + "local_path": "Chemin de la config locale", "found": "[TROUVÉ]", "not_found": "[NON TROUVÉ]", - "global_expected_location": "dans votre répertoire d'accueil", + "global_expected_location": "dans votre répertoire personnel", "local_expected_location": "dans le répertoire de travail actuel" } - }, - "template_update": { - "success": "Modèle '{templateName}' mis à jour avec succès !", - "name_updated": "Nom du modèle mis à jour de '{oldName}' à '{newName}' avec succès !" } }, "messages": { "success": { - "program_initialized": "Interface de ligne de commande initialisée avec succès.", + "program_initialized": "CLI initialisée avec succès.", "new_project": "✔ Projet '{projectName}' créé avec succès !", "template_cloned_or_cache": "Modèle cloné et mis en cache avec succès.", "template_added": "Modèle '{templateName}' ajouté avec succès !", "template_removed": "Suppression réussie de {count} modèle(s) ({templateName}) de {language}.", "template_updated": "✔ Modèle '{templateName}' mis à jour avec succès !", - "template_name_updated": "✔ Modèle '{oldName}' mis à jour à '{newName}' avec succès !", - "template_summary_updated": "Mise à jour réussie de {count} modèle(s) ({templateName}) de {language}!", + "template_name_updated": "✔ Modèle '{oldName}' mis à jour en '{newName}' avec succès !", + "template_summary_updated": "Mise à jour réussie de {count} modèle(s) ({templateName}) de {language} !", "config_updated": "Configuration mise à jour avec succès !", "config_initialized": "Fichier de configuration créé avec succès !", "info_collected": "Informations système collectées.", @@ -260,19 +256,19 @@ "config_updating": "Mise à jour de la configuration...", "config_init_start": "Initialisation de la configuration...", "template_adding": "Ajout du modèle '{templateName}' à la configuration...", - "template_removing": "Suppression du(des) modèle(s) de la configuration...", - "template_updating": "Mise à jour du(des) modèle(s) '{templateName}' dans la configuration...", + "template_removing": "Suppression du/des modèle(s) de la configuration...", + "template_updating": "Mise à jour du/des modèle(s) '{templateName}' dans la configuration...", "cache_updating": "Mise à jour du cache...", "cache_clone_start": "✨ Clonage du nouveau modèle depuis {url}...", "cache_refresh_start": "🔄 Rafraîchissement du modèle mis en cache...", "cache_use_info": "🚀 Utilisation du modèle mis en cache pour {repoName}.", "cache_copy_start": "📂 Copie du modèle mis en cache dans le répertoire du projet...", - "info_loading": "Collecte des détails système et d'environnement...", + "info_loading": "Collecte des détails du système et de l'environnement...", "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.", - "including_defaults_suffix": "(y compris les modèles par défaut)" + "including_defaults_suffix": "(incluant les modèles par défaut)" }, "scaffolding": { "start": "Échafaudage du projet {language} : {project}", @@ -305,7 +301,7 @@ "unexpected": "Une erreur inattendue s'est produite", "unknown": "Une erreur inconnue s'est produite", "devkit_specific": "Devkit a rencontré un problème interne inattendu", - "aborted": "Opération annulée. Aucune modification n'a été apportée." + "aborted": "Opération annulée. Aucune modification n'a été effectuée." }, "validation": { "invalid_key": "Clé invalide : '{key}'. Les clés valides sont : {keys}", @@ -314,14 +310,14 @@ "invalid_cache_strategy": "Stratégie de cache invalide : '{value}'. Les options valides sont : {options}", "invalid_package_manager": "Gestionnaire de paquets invalide : '{value}'. Les options valides sont : {options}", "remove_required": "Impossible de supprimer une propriété requise : '{key}'.", - "alias_exists": "L'alias '{alias}' existe déjà pour un autre modèle dans cette langue. Veuillez choisir un alias différent.", + "alias_exists": "L'alias '{alias}' existe déjà pour un autre modèle dans ce langage. Veuillez choisir un alias différent.", "alias_empty": "L'alias du modèle ne peut pas être vide.", - "alias_too_short": "L'alias du modèle doit comporter au moins 10 lettres.", + "alias_too_short": "L'alias du modèle doit contenir au moins 10 lettres.", "description_empty": "La description du modèle ne peut pas être vide.", - "description_too_short": "La description du modèle doit comporter au moins 6 mots.", + "description_too_short": "La description du modèle doit contenir au moins 6 mots.", "github_repo": "URL de dépôt GitHub invalide ou inaccessible : {url}", "local_path": "Chemin local non trouvé : {path}", - "location": "Emplacement de modèle invalide. Veuillez fournir une URL GitHub ou un chemin local valide.", + "location": "Emplacement du modèle invalide. Veuillez fournir une URL GitHub ou un chemin local valide.", "language_required": "Le langage est requis.", "template_name_required": "Le nom du modèle est requis.", "location_required": "L'emplacement est requis.", @@ -336,8 +332,8 @@ "no_file_found": "Aucun fichier de configuration trouvé. Exécutez 'devkit config init' pour en créer un global, ou 'devkit config init --local' pour en créer un local.", "no_file_found_local": "Aucun fichier de configuration local trouvé. Exécutez 'devkit config init --local' pour en créer un.", "init_fail": "Échec de l'initialisation de la configuration.", - "init_local_and_global": "Impossible d'utiliser les drapeaux --local et --global en même temps.", - "global_not_found": "Fichier de configuration global non trouvé. Exécutez 'devkit config init --global' pour en créer un.", + "init_local_and_global": "Impossible d'utiliser les flags --local et --global en même temps.", + "global_not_found": "Fichier de configuration globale non trouvé. Exécutez 'devkit config init --global' pour en créer un.", "local_not_found": "Aucun fichier de configuration local trouvé. Exécutez 'devkit config init --local' pour en créer un.", "get_key_not_found": "Clé de configuration '{key}' non trouvée." }, @@ -346,7 +342,7 @@ "language_not_found": "Modèle non trouvé pour le langage '{language}'.", "exists": "Le modèle '{template}' existe déjà dans la configuration. Utilisez 'devkit config set' pour le mettre à jour.", "single_fail": "❌ Échec de la mise à jour de '{templateName}' : {error}", - "filter_regex_invalid": "Motif regex invalide dans le filtre : '{clause}' à cause de {error}." + "filter_regex_invalid": "Pattern regex invalide dans le filtre : '{clause}' à cause de {error}." }, "scaffolding": { "fail": "❌ Échec de l'échafaudage du projet : {error}", @@ -358,17 +354,17 @@ }, "command": { "set_invalid_arguments_count": "Nombre d'arguments invalide. Veuillez fournir des paires clé-valeur.", - "set_invalid_format": "Les valeurs pour l'option '--set' doivent être une série de paires clé-valeur (par ex. --set clé1 valeur1 clé2 valeur2).", - "update_no_options": "Aucune option de mise à jour n'a été fournie. Veuillez spécifier au moins une option à mettre à jour (par ex. --new-name, --description, etc.).", + "set_invalid_format": "Les valeurs pour l'option '--set' doivent être une série de paires clé-valeur (ex: --set clé1 valeur1 clé2 valeur2).", + "update_no_options": "Aucune option de mise à jour n'a été fournie. Veuillez spécifier au moins une option à mettre à jour (ex: --new-name, --description, etc.).", "mutually_exclusive_options": "Vous ne pouvez pas utiliser les options `{options}` ensemble. Elles sont mutuellement exclusives.", - "missing_required_options": "Options requises manquantes. Veuillez fournir tous les champs suivants : {fields}.\nAlternativement, exécutez avec le drapeau '-i' ou '--interactive' pour une configuration guidée." + "missing_required_options": "Options requises manquantes. Veuillez fournir toutes les suivantes : {fields}.\nAlternativement, exécutez avec le flag '-i' ou '--interactive' pour une configuration guidée." }, "system": { "file_not_found": "Impossible de trouver le fichier '{fileName}'.", "package_root_not_found": "Impossible de trouver la racine du projet contenant package.json.", "package_file_not_found": "{file} non trouvé à {path}", "package_name_update_fail": "Échec de la mise à jour du nom du projet :", - "version_read_fail": "Échec de la lecture de la version dans package.json.", + "version_read_fail": "Échec de la lecture de la version de package.json.", "git_generic": "Erreur Git", "info_package_manager_not_found": "Version de {manager} non trouvée. Est-il installé ?" }, @@ -379,15 +375,19 @@ } }, "warnings": { - "not_found": "⚠️ Aucun fichier de configuration trouvé. Utilisation des paramètres par défaut.", + "not_found": "Aucun fichier de configuration trouvé. Utilisation des paramètres par défaut.", "no_local_config": "Aucune configuration de projet locale trouvée. Utilisation des paramètres globaux ou par défaut.", "global_not_initialized": "Configuration globale non initialisée. Exécutez 'devkit config init' pour en créer une.", - "template_not_found": "Aucun modèle trouvé dans le fichier de configuration.", - "template_not_found_with_filter": "Aucun modèle ne correspondait au filtre spécifié.", - "templates_not_found": "Les modèles suivants n'ont pas été trouvés : {templates}.", + "template": { + "not_found": "Aucun modèle trouvé dans le fichier de configuration.", + "list_not_found": "Les modèles suivants n'ont pas été trouvés : {templates}.", + "not_found_with_filter": "Aucun modèle ne correspond au filtre spécifié.", + "not_found_for_language": "Aucun modèle trouvé pour le langage '{language}' dans la configuration.", + "not_found_in_config": "Aucun modèle trouvé dans la configuration." + }, "no_command_provided": "Avertissement : Aucune commande ou option fournie. Utilisez `dk config --help` pour voir les commandes disponibles.", - "no_config_found": "⚠️ Aucun fichier de configuration trouvé. Utilisation de la configuration par défaut.", - "filter_delimiter_missing": "Ignorons la clause de filtre sans délimiteur ('{delimiter1}' ou '{delimiter2}') : {clause}", - "filter_property_unrecognized": "Ignorons la propriété de filtre non reconnue : {property}" + "no_config_found": "Aucun fichier de configuration trouvé. Utilisation de la configuration par défaut.", + "filter_delimiter_missing": "Ignorance de la clause de filtre manquant un délimiteur ('{delimiter1}' ou '{delimiter2}') : {clause}", + "filter_property_unrecognized": "Ignorance de la propriété de filtre non reconnue : {property}" } } diff --git a/packages/devkit/src/commands/config/remove.ts b/packages/devkit/src/commands/config/remove.ts index 8270ed1..c7cd11e 100644 --- a/packages/devkit/src/commands/config/remove.ts +++ b/packages/devkit/src/commands/config/remove.ts @@ -5,7 +5,7 @@ import { logger, type TSpinner } from "#utils/logger.js"; import { readConfigSources } from "#core/config/loader.js"; import { saveGlobalConfig, saveLocalConfig } from "#core/config/writer.js"; import { type Command } from "commander"; -import { type CliConfig } from "#utils/schema/schema.js"; +import { type CliConfig, type TemplateConfig } from "#utils/schema/schema.js"; import { type RemoveCommandOptions } from "./types.js"; import { validateProgrammingLanguage } from "#utils/validations/config.js"; @@ -41,6 +41,81 @@ async function getTargetConfigForModification( return targetConfig; } +function resolveTemplateNames( + templateNames: string[], + templatesMap: Record, +): { templatesToActOn: string[]; notFound: string[] } { + const actualTemplateNames = Object.values(templatesMap); + const uniqueActualTemplateNames = [...new Set(actualTemplateNames)]; + + if (templateNames.includes("*")) { + return { + templatesToActOn: uniqueActualTemplateNames, + notFound: + templateNames.length > 1 + ? templateNames.filter((name) => name !== "*") + : [], + }; + } + + const templatesToActOn: string[] = []; + const notFound: string[] = []; + + for (const name of templateNames) { + const actualName = templatesMap[name]; + if (actualName) { + if (!templatesToActOn.includes(actualName)) { + templatesToActOn.push(actualName); + } + } else { + notFound.push(name); + } + } + + return { templatesToActOn, notFound }; +} + +async function getTemplateNamesToActOn( + language: string, + templateNames: string[], + isGlobal: boolean, +): Promise<{ + targetConfig: CliConfig; + languageTemplates: Record; + templatesToActOn: string[]; + notFound: string[]; +}> { + validateProgrammingLanguage(language); + + const targetConfig = await getTargetConfigForModification(isGlobal); + + const languageTemplates = targetConfig?.templates?.[language]?.templates; + + if (!languageTemplates) { + throw new DevkitError( + t("errors.template.language_not_found", { language: language }), + ); + } + + const templatesMap = Object.entries(languageTemplates).reduce( + (acc, [name, template]) => { + acc[name] = name; + if (template?.alias) { + acc[template.alias] = name; + } + return acc; + }, + {} as Record, + ); + + const { templatesToActOn, notFound } = resolveTemplateNames( + templateNames, + templatesMap, + ); + + return { targetConfig, languageTemplates, templatesToActOn, notFound }; +} + export function setupRemoveCommand(configCommand: Command): void { configCommand .command("remove ") @@ -61,66 +136,41 @@ export function setupRemoveCommand(configCommand: Command): void { .start(logger.colors.cyan(t("messages.status.template_removing"))); try { - validateProgrammingLanguage(language); - - const targetConfig = await getTargetConfigForModification(isGlobal); - - const languageTemplates = targetConfig?.templates?.[language]; - if (!languageTemplates?.templates) { + if (templateNames.length === 0) { throw new DevkitError( - t("errors.template.language_not_found", { language: language }), + t("errors.validation.template_name_required"), ); } - const templatesToRemove: string[] = []; - const notFound: string[] = []; - - const templatesMap = Object.entries( - languageTemplates.templates, - ).reduce( - (acc, [name, template]) => { - acc[name] = name; - if (template?.alias) { - acc[template.alias] = name; - } - return acc; - }, - {} as Record, - ); - - for (const templateName of templateNames) { - const actualName = templatesMap[templateName]; - if (actualName) { - templatesToRemove.push(actualName); - } else { - notFound.push(templateName); - } - } + const { + targetConfig, + languageTemplates, + templatesToActOn, + notFound, + } = await getTemplateNamesToActOn(language, templateNames, isGlobal); - if (templatesToRemove.length === 0) { - if (notFound.length === templateNames.length) { - throw new DevkitError( - t("errors.template.not_found", { - template: notFound.join(", "), - }), - ); - } + if (templatesToActOn.length === 0) { + throw new DevkitError( + t("errors.template.not_found", { + template: notFound.join(", "), + }), + ); } const templatesToKeep = Object.fromEntries( - Object.entries(languageTemplates?.templates || {}).filter( - ([key]) => !templatesToRemove.includes(key), + Object.entries(languageTemplates).filter( + ([key]) => !templatesToActOn.includes(key), ), ); - languageTemplates.templates = templatesToKeep; + targetConfig.templates[language].templates = templatesToKeep; await saveConfig(targetConfig, !!isGlobal); spinner.succeed( t("messages.success.template_removed", { - count: templatesToRemove.length.toString(), - templateName: templatesToRemove.join(", "), + count: templatesToActOn.length.toString(), + templateName: templatesToActOn.join(", "), language, }), ); diff --git a/packages/devkit/src/commands/config/remove/index.ts b/packages/devkit/src/commands/config/remove/index.ts new file mode 100644 index 0000000..1b097d8 --- /dev/null +++ b/packages/devkit/src/commands/config/remove/index.ts @@ -0,0 +1,82 @@ +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 { type Command } from "commander"; +import { type RemoveCommandOptions } from "../types.js"; +import { getTemplateNamesToActOn, saveConfig } from "./logic.js"; + +export function setupRemoveCommand(configCommand: Command): void { + configCommand + .command("remove ") + .alias("rm") + .description(t("commands.template.remove.command.description")) + .action( + async ( + language: string, + templateNames: string[], + _: RemoveCommandOptions, + childCommand: Command, + ) => { + const parentOpts = childCommand?.parent?.opts(); + const isGlobal = !!parentOpts?.global; + + const spinner: TSpinner = logger + .spinner() + .start(logger.colors.cyan(t("messages.status.template_removing"))); + + try { + if (templateNames.length === 0) { + throw new DevkitError( + t("errors.validation.template_name_required"), + ); + } + + const { + targetConfig, + languageTemplates, + templatesToActOn, + notFound, + } = await getTemplateNamesToActOn(language, templateNames, isGlobal); + + if (templatesToActOn.length === 0) { + throw new DevkitError( + t("errors.template.not_found", { + template: notFound.join(", "), + }), + ); + } + + const templatesToKeep = Object.fromEntries( + Object.entries(languageTemplates).filter( + ([key]) => !templatesToActOn.includes(key), + ), + ); + + targetConfig.templates[language].templates = templatesToKeep; + + await saveConfig(targetConfig, !!isGlobal); + + spinner.succeed( + t("messages.success.template_removed", { + count: templatesToActOn.length.toString(), + templateName: templatesToActOn.join(", "), + language, + }), + ); + + if (notFound.length > 0) { + logger.warning( + logger.colors.yellow( + t("warnings.template.list_not_found", { + templates: notFound.join(", "), + }), + ), + ); + } + } catch (error: unknown) { + handleErrorAndExit(error as Error, spinner); + } + }, + ); +} diff --git a/packages/devkit/src/commands/config/remove/logic.ts b/packages/devkit/src/commands/config/remove/logic.ts new file mode 100644 index 0000000..a326c73 --- /dev/null +++ b/packages/devkit/src/commands/config/remove/logic.ts @@ -0,0 +1,113 @@ +import { t } from "#utils/i18n/translator.js"; +import { DevkitError } from "#utils/errors/base.js"; +import { readConfigSources } from "#core/config/loader.js"; +import { saveGlobalConfig, saveLocalConfig } from "#core/config/writer.js"; +import { type CliConfig, type TemplateConfig } from "#utils/schema/schema.js"; +import { validateProgrammingLanguage } from "#utils/validations/config.js"; + +export async function saveConfig( + targetConfig: CliConfig, + isGlobal: boolean, +): Promise { + if (isGlobal) { + await saveGlobalConfig(targetConfig); + } else { + await saveLocalConfig(targetConfig); + } +} + +export 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 resolveTemplateNames( + templateNames: string[], + templatesMap: Record, +): { templatesToActOn: string[]; notFound: string[] } { + const actualTemplateNames = Object.values(templatesMap); + const uniqueActualTemplateNames = [...new Set(actualTemplateNames)]; + + if (templateNames.includes("*")) { + return { + templatesToActOn: uniqueActualTemplateNames, + notFound: + templateNames.length > 1 + ? templateNames.filter((name) => name !== "*") + : [], + }; + } + + const templatesToActOn: string[] = []; + const notFound: string[] = []; + + for (const name of templateNames) { + const actualName = templatesMap[name]; + if (actualName) { + if (!templatesToActOn.includes(actualName)) { + templatesToActOn.push(actualName); + } + } else { + notFound.push(name); + } + } + + return { templatesToActOn, notFound }; +} + +export async function getTemplateNamesToActOn( + language: string, + templateNames: string[], + isGlobal: boolean, +): Promise<{ + targetConfig: CliConfig; + languageTemplates: Record; + templatesToActOn: string[]; + notFound: string[]; +}> { + validateProgrammingLanguage(language); + + const targetConfig = await getTargetConfigForModification(isGlobal); + + const languageTemplates = targetConfig?.templates?.[language]?.templates; + + if (!languageTemplates) { + throw new DevkitError( + t("errors.template.language_not_found", { language: language }), + ); + } + + const templatesMap = Object.entries(languageTemplates).reduce( + (acc, [name, template]) => { + acc[name] = name; + if (template?.alias) { + acc[template.alias] = name; + } + return acc; + }, + {} as Record, + ); + + const { templatesToActOn, notFound } = resolveTemplateNames( + templateNames, + templatesMap, + ); + + return { targetConfig, languageTemplates, templatesToActOn, notFound }; +} diff --git a/packages/devkit/src/commands/config/update.ts b/packages/devkit/src/commands/config/update.ts index 88579b1..f2771c5 100644 --- a/packages/devkit/src/commands/config/update.ts +++ b/packages/devkit/src/commands/config/update.ts @@ -5,6 +5,63 @@ import { handleNonInteractiveTemplateUpdate } from "./logic.js"; import { type Command } from "commander"; import { type UpdateCommandOptions } from "./types.js"; import { DevkitError } from "#utils/errors/base.js"; +import { readConfigSources } from "#core/config/loader.js"; +import type { TemplateConfig } from "#utils/schema/schema.js"; +import { validateProgrammingLanguage } from "#utils/validations/config.js"; + +async function resolveTemplateNamesForUpdate( + language: string, + templateNames: string[], + isGlobal: boolean, +): Promise<{ resolvedNames: string[]; notFoundNames: string[] }> { + const sources = await readConfigSources({ + forceGlobal: isGlobal, + forceLocal: !isGlobal, + }); + + const targetConfig = isGlobal ? sources.global : sources.local; + const languageTemplates = targetConfig?.templates?.[language]?.templates; + + if (!languageTemplates) { + if (languageTemplates === undefined) { + return { resolvedNames: [], notFoundNames: templateNames }; + } + } + + const templatesMap = Object.entries(languageTemplates || {}).reduce( + (acc, [name, template]: [string, TemplateConfig]) => { + acc[name] = name; + if (template?.alias) { + acc[template.alias] = name; + } + return acc; + }, + {} as Record, + ); + + if (templateNames.includes("*")) { + return { + resolvedNames: [...new Set(Object.values(templatesMap))], + notFoundNames: templateNames.filter((name) => name !== "*"), + }; + } + + const resolvedNames: string[] = []; + const notFoundNames: string[] = []; + + for (const name of templateNames) { + const actualName = templatesMap[name]; + if (actualName) { + if (!resolvedNames.includes(actualName)) { + resolvedNames.push(actualName); + } + } else { + notFoundNames.push(name); + } + } + + return { resolvedNames, notFoundNames }; +} export function setupUpdateCommand(configCommand: Command): void { configCommand @@ -47,6 +104,9 @@ export function setupUpdateCommand(configCommand: Command): void { cmdOptions: UpdateCommandOptions, childCommand: Command, ) => { + const parentOpts = childCommand?.parent?.opts(); + const isGlobal = !!parentOpts?.global; + const spinner: TSpinner = logger.spinner().start( logger.colors.cyan( t("messages.status.template_updating", { @@ -55,12 +115,11 @@ export function setupUpdateCommand(configCommand: Command): void { ), ); - const parentOpts = childCommand?.parent?.opts(); - const isGlobal = !!parentOpts?.global; - const updates = { ...cmdOptions, language, isGlobal }; let hasErrors = false; let successfullyUpdatedCount = 0; + let templatesToActOn: string[] = []; + let notFoundNames: string[] = []; try { if (templateNames.length === 0) { @@ -69,7 +128,25 @@ export function setupUpdateCommand(configCommand: Command): void { ); } - for (const templateName of templateNames) { + if (language) validateProgrammingLanguage(language); + + const resolution = await resolveTemplateNamesForUpdate( + language, + templateNames, + isGlobal, + ); + templatesToActOn = resolution.resolvedNames; + notFoundNames = resolution.notFoundNames; + + if (templatesToActOn.length === 0) { + throw new DevkitError( + t("errors.template.not_found", { + template: notFoundNames.join(", "), + }), + ); + } + + for (const templateName of templatesToActOn) { try { await handleNonInteractiveTemplateUpdate( language, @@ -104,13 +181,23 @@ export function setupUpdateCommand(configCommand: Command): void { logger.colors.green( `\n✔ ${t("messages.success.template_summary_updated", { count: successfullyUpdatedCount.toString(), - templateName: templateNames.join(", "), + templateName: templatesToActOn.join(", "), language, })}`, ), ); } + if (notFoundNames.length > 0) { + logger.warning( + logger.colors.yellow( + t("warnings.template.list_not_found", { + templates: notFoundNames.join(", "), + }), + ), + ); + } + if (hasErrors) { process.exit(1); } diff --git a/packages/devkit/src/commands/config/update/index.ts b/packages/devkit/src/commands/config/update/index.ts new file mode 100644 index 0000000..d002b98 --- /dev/null +++ b/packages/devkit/src/commands/config/update/index.ts @@ -0,0 +1,154 @@ +import { t } from "#utils/i18n/translator.js"; +import { logger, type TSpinner } from "#utils/logger.js"; +import { handleErrorAndExit } from "#utils/errors/handler.js"; +import { handleNonInteractiveTemplateUpdate } from "../logic.js"; +import { type Command } from "commander"; +import { type UpdateCommandOptions } from "../types.js"; +import { resolveTemplateNamesForUpdate } from "./logic.js"; +import { DevkitError } from "#utils/errors/base.js"; +import { validateProgrammingLanguage } from "#utils/validations/config.js"; + +export function setupUpdateCommand(configCommand: Command): void { + configCommand + .command("update ") + .alias("up") + .description(t("commands.config.update_template.command.description")) + .option( + "-n, --new-name ", + t("commands.config.update_template.options.new_name"), + ) + .option( + "-d, --description ", + t("commands.config.update_template.options.description"), + ) + .option( + "-a, --alias ", + t("commands.config.update_template.options.alias"), + ) + .option( + "-l, --location ", + t("commands.config.update_template.options.location"), + ) + .option( + "--cache-strategy ", + t("commands.config.update_template.options.cache_strategy"), + ) + .option( + "--package-manager ", + t("commands.config.update_template.options.package_manager"), + ) + .option( + "-g, --global", + t("commands.config.update_template.options.global"), + false, + ) + .action( + async ( + language: string, + templateNames: string[], + cmdOptions: UpdateCommandOptions, + childCommand: Command, + ) => { + const parentOpts = childCommand?.parent?.opts(); + const isGlobal = !!parentOpts?.global; + + const spinner: TSpinner = logger.spinner().start( + logger.colors.cyan( + t("messages.status.template_updating", { + templateName: templateNames.join(", "), + }), + ), + ); + + const updates = { ...cmdOptions, language, isGlobal }; + let hasErrors = false; + let successfullyUpdatedCount = 0; + let templatesToActOn: string[] = []; + let notFoundNames: string[] = []; + + try { + if (templateNames.length === 0) { + throw new DevkitError( + t("errors.validation.template_name_required"), + ); + } + + if (language) validateProgrammingLanguage(language); + + const resolution = await resolveTemplateNamesForUpdate( + language, + templateNames, + isGlobal, + ); + templatesToActOn = resolution.resolvedNames; + notFoundNames = resolution.notFoundNames; + + if (templatesToActOn.length === 0) { + throw new DevkitError( + t("errors.template.not_found", { + template: notFoundNames.join(", "), + }), + ); + } + + for (const templateName of templatesToActOn) { + try { + await handleNonInteractiveTemplateUpdate( + language, + templateName, + updates, + !!isGlobal, + ); + + successfullyUpdatedCount++; + } catch (error: unknown) { + hasErrors = true; + if (error instanceof DevkitError) { + logger.log( + logger.colors.yellow( + `\n${t("errors.template.single_fail", { templateName, error: error.message })}`, + ), + ); + } else { + logger.log( + logger.colors.yellow( + `\n${t("errors.template.single_fail", { templateName, error: "unknown error" })}`, + ), + ); + } + } + } + + spinner.stop(); + + if (successfullyUpdatedCount > 0) { + logger.log( + logger.colors.green( + `\n✔ ${t("messages.success.template_summary_updated", { + count: successfullyUpdatedCount.toString(), + templateName: templatesToActOn.join(", "), + language, + })}`, + ), + ); + } + + if (notFoundNames.length > 0) { + logger.warning( + logger.colors.yellow( + t("warnings.template.list_not_found", { + templates: notFoundNames.join(", "), + }), + ), + ); + } + + if (hasErrors) { + process.exit(1); + } + } catch (error: unknown) { + handleErrorAndExit(error as Error, spinner); + } + }, + ); +} diff --git a/packages/devkit/src/commands/config/update/logic.ts b/packages/devkit/src/commands/config/update/logic.ts new file mode 100644 index 0000000..23825e3 --- /dev/null +++ b/packages/devkit/src/commands/config/update/logic.ts @@ -0,0 +1,56 @@ +import { readConfigSources } from "#core/config/loader.js"; +import type { TemplateConfig } from "#utils/schema/schema.js"; + +export async function resolveTemplateNamesForUpdate( + language: string, + templateNames: string[], + isGlobal: boolean, +): Promise<{ resolvedNames: string[]; notFoundNames: string[] }> { + const sources = await readConfigSources({ + forceGlobal: isGlobal, + forceLocal: !isGlobal, + }); + + const targetConfig = isGlobal ? sources.global : sources.local; + const languageTemplates = targetConfig?.templates?.[language]?.templates; + + if (!languageTemplates) { + if (languageTemplates === undefined) { + return { resolvedNames: [], notFoundNames: templateNames }; + } + } + + const templatesMap = Object.entries(languageTemplates || {}).reduce( + (acc, [name, template]: [string, TemplateConfig]) => { + acc[name] = name; + if (template?.alias) { + acc[template.alias] = name; + } + return acc; + }, + {} as Record, + ); + + if (templateNames.includes("*")) { + return { + resolvedNames: [...new Set(Object.values(templatesMap))], + notFoundNames: templateNames.filter((name) => name !== "*"), + }; + } + + const resolvedNames: string[] = []; + const notFoundNames: string[] = []; + + for (const name of templateNames) { + const actualName = templatesMap[name]; + if (actualName) { + if (!resolvedNames.includes(actualName)) { + resolvedNames.push(actualName); + } + } else { + notFoundNames.push(name); + } + } + + return { resolvedNames, notFoundNames }; +} diff --git a/packages/devkit/src/commands/init/index.ts b/packages/devkit/src/commands/init/index.ts new file mode 100644 index 0000000..ce68ea3 --- /dev/null +++ b/packages/devkit/src/commands/init/index.ts @@ -0,0 +1,35 @@ +import { type SetupCommandOptions } from "#utils/schema/schema.js"; +import { t } from "#utils/i18n/translator.js"; +import { ConfigError } from "#utils/errors/base.js"; +import { logger, TSpinner } from "#utils/logger.js"; +import { handleErrorAndExit } from "#utils/errors/handler.js"; +import { handleGlobalInit, handleLocalInit } from "./logic.js"; + +export function setupInitCommand(options: SetupCommandOptions): void { + const { program } = options; + program + .command("init") + .alias("i") + .description(t("commands.config.init.command.description")) + .option("-l, --local", t("commands.config.init.option.local"), false) + .option("-g, --global", t("commands.config.init.option.global"), false) + .action(async (cmdOptions: { local: boolean; global: boolean }) => { + const isLocal: boolean = cmdOptions.local; + const isGlobal: boolean = cmdOptions.global; + const spinner: TSpinner = logger.spinner(); + + try { + if (isLocal && isGlobal) { + throw new ConfigError(t("errors.config.init_local_and_global")); + } + + if (isGlobal) { + await handleGlobalInit(spinner); + } else { + await handleLocalInit(spinner); + } + } catch (error) { + handleErrorAndExit(error, spinner); + } + }); +} diff --git a/packages/devkit/src/commands/init/logic.ts b/packages/devkit/src/commands/init/logic.ts new file mode 100644 index 0000000..09c55b1 --- /dev/null +++ b/packages/devkit/src/commands/init/logic.ts @@ -0,0 +1,110 @@ +import { + CONFIG_FILE_NAMES, + defaultCliConfig, + type CliConfig, +} from "#utils/schema/schema.js"; +import { t } from "#utils/i18n/translator.js"; +import fs from "#utils/fs/file.js"; +import path from "path"; +import os from "os"; +import { logger, TSpinner } from "#utils/logger.js"; +import { select } from "@inquirer/prompts"; +import { findGlobalConfigFile } from "#core/config/search.js"; +import { findMonorepoRoot, findProjectRoot } from "#utils/fs/finder.js"; +import { findUp } from "#utils/fs/find-up.js"; +import { saveConfig } from "#core/config/writer.js"; +import { getPackageManager } from "#utils/package-manager/index.js"; + +export async function promptForStandardOverwrite( + filePath: string, +): Promise { + const response = await select({ + message: logger.colors.yellow( + t("commands.config.init.confirm_overwrite", { path: filePath }), + ), + choices: [ + { name: t("common.yes"), value: true }, + { name: t("common.no"), value: false }, + ], + default: true, + }); + return response; +} + +async function getUpdatedConfig(): Promise { + const detectedPackageManager = await getPackageManager(true); + return { + ...defaultCliConfig, + settings: { + ...defaultCliConfig.settings, + defaultPackageManager: + detectedPackageManager || + defaultCliConfig.settings.defaultPackageManager, + }, + }; +} + +export async function handleGlobalInit(spinner: TSpinner): Promise { + let finalPath = await findGlobalConfigFile(); + if (!finalPath) { + finalPath = path.join(os.homedir(), CONFIG_FILE_NAMES[0]); + } + + const shouldOverwrite = (await fs.pathExists(finalPath)) + ? await promptForStandardOverwrite(finalPath) + : true; + + if (shouldOverwrite) { + const configToSave = await getUpdatedConfig(); + spinner.start( + logger.colors.cyan( + t("messages.status.config_init_start", { path: finalPath }), + ), + ); + await saveConfig(configToSave, finalPath); + spinner.succeed( + logger.colors.green(t("messages.success.config_initialized")), + ); + } else { + spinner.info(logger.colors.yellow(t("commands.config.init.aborted"))); + } +} + +export async function handleLocalInit(spinner: TSpinner): Promise { + const allConfigFiles = [...CONFIG_FILE_NAMES]; + const currentPath = process.cwd(); + const monorepoRoot = await findMonorepoRoot(); + const projectRoot = await findProjectRoot(); + + let finalPath: string | null = null; + let shouldOverwrite = true; + const rootDir = monorepoRoot || projectRoot || currentPath; + + const existingConfigPath = await findUp({ + files: allConfigFiles, + cwd: rootDir, + limit: rootDir, + }); + + if (existingConfigPath) { + finalPath = existingConfigPath; + shouldOverwrite = await promptForStandardOverwrite(finalPath); + } else { + finalPath = path.join(rootDir, allConfigFiles[1]); + } + + if (shouldOverwrite && finalPath) { + const configToSave = await getUpdatedConfig(); + spinner.start( + logger.colors.cyan( + t("messages.status.config_init_start", { path: finalPath }), + ), + ); + await saveConfig(configToSave, finalPath); + spinner.succeed( + logger.colors.green(t("messages.success.config_initialized")), + ); + } else { + spinner.info(logger.colors.yellow(t("commands.config.init.aborted"))); + } +}