From 60186428ca020fcfc2c584e284f9bb5a74a917ec Mon Sep 17 00:00:00 2001 From: IT-WIBRC Date: Tue, 30 Sep 2025 14:54:15 +0100 Subject: [PATCH] refactor: move and to a single, centralized file for better code organization --- .changeset/rude-carpets-hammer.md | 5 + packages/devkit/TODO.md | 4 +- .../__tests__/integrations/config/add.spec.ts | 6 +- .../integrations/config/index.spec.ts | 2 +- .../integrations/config/list.spec.ts | 2 +- .../integrations/config/remove.spec.ts | 4 +- .../units/commands/config/index.spec.ts | 35 ++-- .../units/commands/config/list.spec.ts | 9 +- .../units/commands/config/prompts.spec.ts | 7 +- .../units/commands/config/remove.spec.ts | 10 +- .../units/commands/config/update.spec.ts | 14 +- .../__tests__/units/commands/index.spec.ts | 11 +- .../__tests__/units/commands/info.spec.ts | 31 +-- .../__tests__/units/commands/list.spec.ts | 17 +- .../__tests__/units/commands/new.spec.ts | 22 ++- .../__tests__/units/core/info/project.spec.ts | 61 ++++-- .../units/core/template/printer.spec.ts | 106 ++++++---- .../core/template/update-project-name.spec.ts | 24 +-- .../units/scaffolding/javascript.spec.ts | 15 +- .../units/utils/errors/handler.spec.ts | 96 +++++++-- .../__tests__/units/utils/logger.spec.ts | 169 +++++++++++++--- packages/devkit/locales/en.json | 3 +- packages/devkit/locales/fr.json | 3 +- packages/devkit/src/commands/config/add.ts | 11 +- packages/devkit/src/commands/config/index.ts | 21 +- packages/devkit/src/commands/config/list.ts | 11 +- .../devkit/src/commands/config/prompts.ts | 13 +- packages/devkit/src/commands/config/remove.ts | 12 +- packages/devkit/src/commands/config/update.ts | 19 +- .../src/commands/config/validate-and-save.ts | 7 +- packages/devkit/src/commands/index.ts | 24 ++- packages/devkit/src/commands/info.ts | 19 +- packages/devkit/src/commands/init.ts | 23 ++- packages/devkit/src/commands/list.ts | 13 +- packages/devkit/src/commands/new.ts | 28 +-- packages/devkit/src/core/cache/index.ts | 38 ++-- packages/devkit/src/core/info/project.ts | 15 +- packages/devkit/src/core/prompts/prompts.ts | 8 +- packages/devkit/src/core/template/printer.ts | 52 +++-- .../src/core/template/update-project-name.ts | 19 +- packages/devkit/src/scaffolding/javascript.ts | 58 ++++-- packages/devkit/src/utils/errors/handler.ts | 27 ++- packages/devkit/src/utils/logger.ts | 55 +++++- packages/devkit/vitest.setup.ts | 185 ++++++++++-------- 44 files changed, 837 insertions(+), 477 deletions(-) create mode 100644 .changeset/rude-carpets-hammer.md diff --git a/.changeset/rude-carpets-hammer.md b/.changeset/rude-carpets-hammer.md new file mode 100644 index 0000000..a1361f6 --- /dev/null +++ b/.changeset/rude-carpets-hammer.md @@ -0,0 +1,5 @@ +--- +"scaffolder-toolkit": patch +--- + +refactor: Move `chalk` and `ora` to a single, centralized file for better code organization. diff --git a/packages/devkit/TODO.md b/packages/devkit/TODO.md index 2017ff0..9c88a48 100644 --- a/packages/devkit/TODO.md +++ b/packages/devkit/TODO.md @@ -58,7 +58,7 @@ This document tracks all planned and completed tasks for the Dev Kit project. - [ ] Add a configuration validation step when initializing or updating the config file to ensure all required fields are present and correctly formatted. - [ ]: Enhance interactivity with the `dk config add` command - [ ] **Enhance `list` Command**: Add flag to also see default config `--with-defaults`. -- [ ] ** Enhance for organization Purpose **: Add new language `Typescript` with same code as javascript +- [ ] ** Enhance for organization Purpose **: Add new language `Typescript` with same code as javascript, also support for nodejs template name for those who prefer it than the language - [ ] Add wildcard support for template name in the `dk config update` and `dk config remove` commands. - [ ] Use the interactive approach for the `dk config add` command (code already there) - [ ] **Dynamic Error Messages**: Update error handling to dynamically generate lists of valid options (e.g., package managers, cache strategies) in error messages. @@ -66,7 +66,7 @@ This document tracks all planned and completed tasks for the Dev Kit project. - [ ] **Color Configuration**: Add a feature to allow users to configure the colors for templates. - [ ] **Dynamic Help Text**: Programmatically generate help text for options with constrained values (e.g., `--cache-strategy`) to ensure it's always up to date. - [ ] **Testing**: Stabilize the integration test of the `new` command -- [ ] **Centralize Utilities**: Move `chalk` and `ora` to a single, centralized file for better code organization. +- [x] **Centralize Utilities**: Move `chalk` and `ora` to a single, centralized file for better code organization. - [x] Enable GitHub discussions - [x] Refactor and restructure the utilities - [ ] Better json structure for languages diff --git a/packages/devkit/__tests__/integrations/config/add.spec.ts b/packages/devkit/__tests__/integrations/config/add.spec.ts index 60a0e25..1edfd6e 100644 --- a/packages/devkit/__tests__/integrations/config/add.spec.ts +++ b/packages/devkit/__tests__/integrations/config/add.spec.ts @@ -226,7 +226,7 @@ describe("dk config add", () => { expect(exitCode).toBe(1); expect(all).toContain( - "An unexpected error occurred: Invalid value for language. Valid options are: javascript", + "❌ Devkit encountered an unexpected internal issue: Invalid value for language. Valid options are: javascript", ); }); @@ -250,7 +250,7 @@ describe("dk config add", () => { expect(exitCode).toBe(1); expect(all).toContain( - "An unexpected error occurred: Template 'react-ts' already exists in the configuration. Use 'devkit config set' to update it.", + "❌ Devkit encountered an unexpected internal issue: Template 'react-ts' already exists in the configuration. Use 'devkit config set' to update it.", ); }); @@ -283,7 +283,7 @@ describe("dk config add", () => { expect(exitCode).toBe(1); expect(all).toContain( - "An unexpected error occurred: Alias 'rt' already exists for another template in this language. Please choose a different alias.", + "❌ Devkit encountered an unexpected internal issue: Alias 'rt' already exists for another template in this language. Please choose a different alias.", ); }); }); diff --git a/packages/devkit/__tests__/integrations/config/index.spec.ts b/packages/devkit/__tests__/integrations/config/index.spec.ts index 707b89b..5b4f147 100644 --- a/packages/devkit/__tests__/integrations/config/index.spec.ts +++ b/packages/devkit/__tests__/integrations/config/index.spec.ts @@ -228,7 +228,7 @@ describe("dk config", () => { expect(exitCode).toBe(1); expect(all).toContain( - "n unexpected error occurred: No local configuration file found. Run 'devkit config init --local' to create one.", + "❌ Devkit encountered an unexpected internal issue: No local configuration file found. Run 'devkit config init --local' to create one.", ); }); }); diff --git a/packages/devkit/__tests__/integrations/config/list.spec.ts b/packages/devkit/__tests__/integrations/config/list.spec.ts index 5474d88..50f2cfb 100644 --- a/packages/devkit/__tests__/integrations/config/list.spec.ts +++ b/packages/devkit/__tests__/integrations/config/list.spec.ts @@ -199,7 +199,7 @@ describe("dk config list", () => { expect(exitCode).toBe(1); expect(all).toContain( - "An unexpected error occurred: Global configuration file not found.", + "❌ Devkit encountered an unexpected internal issue: Global configuration file not found.", ); }); diff --git a/packages/devkit/__tests__/integrations/config/remove.spec.ts b/packages/devkit/__tests__/integrations/config/remove.spec.ts index 5bf38e7..e86cb65 100644 --- a/packages/devkit/__tests__/integrations/config/remove.spec.ts +++ b/packages/devkit/__tests__/integrations/config/remove.spec.ts @@ -215,7 +215,7 @@ describe("dk config remove", () => { expect(exitCode).toBe(1); expect(all).toContain( - "An unexpected error occurred: Template 'not-found-1, not-found-2' not found in configuration.", + "❌ Devkit encountered an unexpected internal issue: Template 'not-found-1, not-found-2' not found in configuration.", ); }); @@ -229,7 +229,7 @@ describe("dk config remove", () => { expect(exitCode).toBe(1); expect(all).toContain( - "An unexpected error occurred: Invalid value for language. Valid options are: javascript", + "❌ Devkit encountered an unexpected internal issue: Invalid value for language. Valid options are: javascript", ); }); diff --git a/packages/devkit/__tests__/units/commands/config/index.spec.ts b/packages/devkit/__tests__/units/commands/config/index.spec.ts index cc34e17..db98db9 100644 --- a/packages/devkit/__tests__/units/commands/config/index.spec.ts +++ b/packages/devkit/__tests__/units/commands/config/index.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { setupConfigCommand } from "../../../../src/commands/config/index.js"; -import { mockSpinner, mockChalk, mocktFn } from "../../../../vitest.setup.js"; +import { mockSpinner, mocktFn, mockLogger } from "../../../../vitest.setup.js"; const { mockReadAndMergeConfigs, @@ -48,7 +48,7 @@ vi.mock("../../../../src/commands/config/list.js", () => ({ setupListCommand: mockSetupListCommand, })); -vi.spyOn(console, "log").mockImplementation(() => {}); +console.log = mockLogger.log; describe("setupConfigCommand", () => { let mockProgram: any; @@ -110,7 +110,7 @@ describe("setupConfigCommand", () => { expect(mockSpinner.warn).toHaveBeenCalledOnce(); expect(mockSpinner.warn).toHaveBeenCalledWith( - mockChalk.green("warning.no_command_or_option_provided"), + mockLogger.colors.green("warning.no_command_or_option_provided"), ); }); @@ -131,7 +131,7 @@ describe("setupConfigCommand", () => { false, ); expect(mockSpinner.succeed).toHaveBeenCalledWith( - mockChalk.green("config.set.success"), + mockLogger.colors.green("config.set.success"), ); }); @@ -148,7 +148,7 @@ describe("setupConfigCommand", () => { expect(mockHandleNonInteractiveSettingsUpdate).not.toHaveBeenCalled(); expect(mockSpinner.fail).toHaveBeenCalledWith( - mockChalk.redBright("error.command.set.invalid_format"), + mockLogger.colors.redBright("error.command.set.invalid_format"), ); }); @@ -161,16 +161,15 @@ describe("setupConfigCommand", () => { config: mockConfig, source: "local", }); - const consoleLogSpy = vi.spyOn(console, "log"); setupConfigCommand(mockProgram); await mockAction(["language"], {}); - expect(consoleLogSpy).toHaveBeenCalledWith( - mockChalk.yellow.bold("language") + ": " + "typescript", + expect(mockLogger.log).toHaveBeenCalledWith( + mockLogger.colors.yellowBold("language") + ": " + "typescript", ); expect(mockSpinner.succeed).toHaveBeenCalledWith( - mockChalk.green("config.get.success"), + mockLogger.colors.green("config.get.success"), ); }); @@ -186,19 +185,18 @@ describe("setupConfigCommand", () => { config: mockConfig, source: "local", }); - const consoleLogSpy = vi.spyOn(console, "log"); setupConfigCommand(mockProgram); await mockAction(["language", "packageManager"], {}); - expect(consoleLogSpy).toHaveBeenCalledWith( - mockChalk.yellow.bold("language") + ": " + "typescript", + expect(mockLogger.log).toHaveBeenCalledWith( + mockLogger.colors.yellowBold("language") + ": " + "typescript", ); - expect(consoleLogSpy).toHaveBeenCalledWith( - mockChalk.yellow.bold("packageManager") + ": " + "bun", + expect(mockLogger.log).toHaveBeenCalledWith( + mockLogger.colors.yellowBold("packageManager") + ": " + "bun", ); expect(mockSpinner.succeed).toHaveBeenCalledWith( - mockChalk.green("config.get.success"), + mockLogger.colors.green("config.get.success"), ); }); @@ -208,20 +206,19 @@ describe("setupConfigCommand", () => { config: mockConfig, source: "local", }); - const consoleLogSpy = vi.spyOn(console, "log"); setupConfigCommand(mockProgram); await mockAction(["nonexistent_key"], {}); - expect(consoleLogSpy).toHaveBeenCalledWith( - mockChalk.redBright( + expect(mockLogger.log).toHaveBeenCalledWith( + mockLogger.colors.redBright( mocktFn("config.get.not_found", { key: "nonexistent_key", }), ), ); expect(mockSpinner.succeed).toHaveBeenCalledWith( - mockChalk.green("config.get.success"), + mockLogger.colors.green("config.get.success"), ); }); diff --git a/packages/devkit/__tests__/units/commands/config/list.spec.ts b/packages/devkit/__tests__/units/commands/config/list.spec.ts index 838cba6..725fa3b 100644 --- a/packages/devkit/__tests__/units/commands/config/list.spec.ts +++ b/packages/devkit/__tests__/units/commands/config/list.spec.ts @@ -1,6 +1,6 @@ import { vi, describe, it, expect, beforeEach } from "vitest"; import { setupListCommand } from "../../../../src/commands/config/list.js"; -import { mockSpinner, mockChalk, mocktFn } from "../../../../vitest.setup.js"; +import { mockSpinner, mockLogger, mocktFn } from "../../../../vitest.setup.js"; import { DevkitError } from "../../../../src/utils/errors/base.js"; const { @@ -33,7 +33,7 @@ vi.mock("#core/template/printer.js", () => ({ printTemplates: mockPrintTemplates, })); -const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); +const consoleLogSpy = mockLogger.log; describe("setupListCommand", () => { let mockConfigCommand: any; @@ -106,6 +106,9 @@ describe("setupListCommand", () => { expect(mockSpinner.info).toHaveBeenCalledWith( mocktFn("config.get.source.local"), ); + expect(mockLogger.log).toHaveBeenCalled(); + + expect(mockPrintSettings).toHaveBeenCalledOnce(); expect(mockPrintSettings).toHaveBeenCalledWith(sampleConfig.settings); expect(mockPrintTemplates).toHaveBeenCalledWith( "javascript", @@ -177,7 +180,7 @@ describe("setupListCommand", () => { ); expect(mockPrintSettings).toHaveBeenCalledWith({}); expect(consoleLogSpy).toHaveBeenCalledWith( - mockChalk.yellow(mocktFn("list.templates.not_found")), + mockLogger.colors.yellow(mocktFn("list.templates.not_found")), ); expect(mockPrintTemplates).not.toHaveBeenCalled(); }); diff --git a/packages/devkit/__tests__/units/commands/config/prompts.spec.ts b/packages/devkit/__tests__/units/commands/config/prompts.spec.ts index 7ee8626..dfcd1b9 100644 --- a/packages/devkit/__tests__/units/commands/config/prompts.spec.ts +++ b/packages/devkit/__tests__/units/commands/config/prompts.spec.ts @@ -4,7 +4,7 @@ import { handleNonInteractiveSettingsUpdate, handleNonInteractiveTemplateUpdate, } from "../../../../src/commands/config/logic.ts"; -import { mocktFn } from "../../../../vitest.setup.ts"; +import { mockLogger, mocktFn } from "../../../../vitest.setup.ts"; const { mockSelect, @@ -63,7 +63,6 @@ describe("Interactive Config Prompts", () => { beforeEach(() => { vi.clearAllMocks(); - vi.spyOn(console, "log").mockImplementation(() => {}); }); afterEach(() => { @@ -84,7 +83,7 @@ describe("Interactive Config Prompts", () => { "fr", false, ); - expect(console.log).toHaveBeenCalledWith(mocktFn("config.set.success")); + expect(mockLogger.log).toHaveBeenCalledWith(mocktFn("config.set.success")); }); it("should handle a full template update flow correctly (description)", async () => { @@ -105,7 +104,7 @@ describe("Interactive Config Prompts", () => { { description: "A cool new description" }, false, ); - expect(console.log).toHaveBeenCalledWith( + expect(mockLogger.log).toHaveBeenCalledWith( mocktFn("config.update.success", { templateName: "web" }), ); }); diff --git a/packages/devkit/__tests__/units/commands/config/remove.spec.ts b/packages/devkit/__tests__/units/commands/config/remove.spec.ts index 50a0ae2..a5ee650 100644 --- a/packages/devkit/__tests__/units/commands/config/remove.spec.ts +++ b/packages/devkit/__tests__/units/commands/config/remove.spec.ts @@ -1,6 +1,6 @@ import { vi, describe, it, expect, beforeEach } from "vitest"; import { setupRemoveCommand } from "../../../../src/commands/config/remove.js"; -import { mockSpinner, mockChalk, mocktFn } from "../../../../vitest.setup.js"; +import { mockSpinner, mockLogger, mocktFn } from "../../../../vitest.setup.js"; import { DevkitError } from "../../../../src/utils/errors/base.js"; const { @@ -36,8 +36,6 @@ vi.mock("#utils/validations/config.js", () => ({ validateProgrammingLanguage: mockValidateProgrammingLanguage, })); -vi.spyOn(console, "log").mockImplementation(() => {}); - describe("setupRemoveCommand", () => { let mockConfigCommand: any; @@ -100,7 +98,7 @@ describe("setupRemoveCommand", () => { await actionFn("javascript", ["vue-basic"], { global: false }); expect(mockSpinner.start).toHaveBeenCalledWith( - mockChalk.cyan(mocktFn("remove_template.start")), + mockLogger.colors.cyan(mocktFn("remove_template.start")), ); expect(mockSaveLocalConfig).toHaveBeenCalledWith({ settings: {}, @@ -275,7 +273,7 @@ describe("setupRemoveCommand", () => { }); it("should remove existing templates and warn about non-existent ones", async () => { - const consoleLogSpy = vi.spyOn(console, "log"); + const consoleLogSpy = mockLogger.log; const initialConfig = structuredClone(sampleConfig); mockReadAndMergeConfigs.mockResolvedValueOnce({ config: initialConfig, @@ -310,7 +308,7 @@ describe("setupRemoveCommand", () => { }), ); expect(consoleLogSpy).toHaveBeenCalledWith( - mockChalk.yellow( + mockLogger.colors.yellow( mocktFn("remove_template.not_found_warning", { template: "non-existent", }), diff --git a/packages/devkit/__tests__/units/commands/config/update.spec.ts b/packages/devkit/__tests__/units/commands/config/update.spec.ts index 190837c..1f23336 100644 --- a/packages/devkit/__tests__/units/commands/config/update.spec.ts +++ b/packages/devkit/__tests__/units/commands/config/update.spec.ts @@ -1,6 +1,6 @@ import { vi, describe, it, expect, beforeEach } from "vitest"; import { setupUpdateCommand } from "../../../../src/commands/config/update.js"; -import { mockSpinner, mockChalk, mocktFn } from "../../../../vitest.setup.js"; +import { mockSpinner, mocktFn, mockLogger } from "../../../../vitest.setup.js"; import { DevkitError } from "../../../../src/utils/errors/base.js"; const { mockHandleErrorAndExit, mockHandleNonInteractiveTemplateUpdate } = @@ -19,7 +19,7 @@ vi.mock("../../../../src/commands/config/logic.js", () => ({ handleNonInteractiveTemplateUpdate: mockHandleNonInteractiveTemplateUpdate, })); -const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); +const consoleLogSpy = mockLogger.log; const mockProcessExit = vi .spyOn(process, "exit") .mockImplementation((() => {}) as unknown as never); @@ -98,7 +98,7 @@ describe("setupUpdateCommand", () => { await actionFn("javascript", ["my-template"], defaultCmdOptions); expect(mockSpinner.start).toHaveBeenCalledWith( - mockChalk.cyan( + mockLogger.colors.cyan( mocktFn("config.update.updating", { templateName: "my-template" }), ), ); @@ -114,7 +114,7 @@ describe("setupUpdateCommand", () => { ); expect(mockSpinner.stop).toHaveBeenCalled(); expect(consoleLogSpy).toHaveBeenCalledWith( - mockChalk.green( + mockLogger.colors.green( `\n✔ ${mocktFn("config.update.success_summary", { count: "1", templateName: "my-template", @@ -145,7 +145,7 @@ describe("setupUpdateCommand", () => { false, ); expect(consoleLogSpy).toHaveBeenCalledWith( - mockChalk.green( + mockLogger.colors.green( `\n✔ ${mocktFn("config.update.success_summary", { count: "2", templateName: "temp1, temp2", @@ -174,7 +174,7 @@ describe("setupUpdateCommand", () => { expect(mockHandleNonInteractiveTemplateUpdate).toHaveBeenCalledTimes(3); expect(consoleLogSpy).toHaveBeenCalledWith( - mockChalk.yellow( + mockLogger.colors.yellow( `\n${mocktFn("config.update.single_fail", { templateName: "temp2", error: mocktFn("error.template.not_found", { template: "temp2" }), @@ -210,7 +210,7 @@ describe("setupUpdateCommand", () => { expect(mockSpinner.stop).toHaveBeenCalled(); expect(mockHandleErrorAndExit).not.toHaveBeenCalled(); expect(consoleLogSpy).toHaveBeenCalledWith( - mockChalk.yellow( + mockLogger.colors.yellow( `\n${mocktFn("config.update.single_fail", { templateName: "my-template", error: "unknown error", diff --git a/packages/devkit/__tests__/units/commands/index.spec.ts b/packages/devkit/__tests__/units/commands/index.spec.ts index 0a3cff9..9eede7f 100644 --- a/packages/devkit/__tests__/units/commands/index.spec.ts +++ b/packages/devkit/__tests__/units/commands/index.spec.ts @@ -1,5 +1,5 @@ import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mockProgram, mockSpinner } from "../../../vitest.setup.js"; +import { mockProgram, mockSpinner, mockLogger } from "../../../vitest.setup.js"; import { setupAndParse } from "../../../src/commands/index.js"; import type { CliConfig } from "../../../src/utils/schema/schema.js"; @@ -53,7 +53,7 @@ vi.mock("#core/config/loader.js", () => ({ readAndMergeConfigs: mockReadAndMergeConfigs, })); -const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); +const warnSpy = mockLogger.warning; const optsSpy = vi.spyOn(mockProgram, "opts"); const parseOptionsSpy = vi.spyOn(mockProgram, "parseOptions"); @@ -61,7 +61,6 @@ describe("index.ts (Entry point)", () => { beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); - warnSpy.mockClear(); }); afterEach(() => { @@ -124,11 +123,7 @@ describe("index.ts (Entry point)", () => { await vi.runAllTimersAsync(); expect(warnSpy).toHaveBeenCalledOnce(); - expect(warnSpy).toHaveBeenCalledWith( - "\n", - expect.stringContaining("warning.no_config_found"), - "\n", - ); + expect(warnSpy).toHaveBeenCalledWith("\nwarning.no_config_found\n"); }); }); diff --git a/packages/devkit/__tests__/units/commands/info.spec.ts b/packages/devkit/__tests__/units/commands/info.spec.ts index 2acbf61..cda8c42 100644 --- a/packages/devkit/__tests__/units/commands/info.spec.ts +++ b/packages/devkit/__tests__/units/commands/info.spec.ts @@ -1,6 +1,6 @@ import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; import { setupInfoCommand } from "../../../src/commands/info.js"; -import { mockSpinner, mocktFn } from "../../../vitest.setup.js"; +import { mockSpinner, mocktFn, mockLogger } from "../../../vitest.setup.js"; import { type SystemInfo } from "../../../src/core/info/info.js"; const { mockCollectSystemInfo, mockHandleErrorAndExit } = vi.hoisted(() => ({ @@ -16,19 +16,8 @@ vi.mock("#utils/errors/handler.js", () => ({ handleErrorAndExit: mockHandleErrorAndExit, })); -vi.mock("chalk", () => ({ - default: { - bold: { - cyan: (str: string) => str, - }, - yellow: (str: string) => str, - green: (str: string) => `[GREEN: ${str}]`, - red: (str: string) => `[RED: ${str}]`, - }, -})); - let consoleOutput: string[] = []; -const originalConsoleLog = console.log; +const originalConsoleLog = mockLogger.log; const mockConsoleLog = vi.fn((output) => { consoleOutput.push(String(output)); @@ -67,7 +56,7 @@ describe("setupInfoCommand", () => { mocktFn.mockImplementation((key) => key); - console.log = mockConsoleLog; + mockLogger.log = mockConsoleLog; mockProgram = { command: vi.fn(() => mockProgram), @@ -112,25 +101,25 @@ describe("setupInfoCommand", () => { const pad = (label: string) => label.padEnd(27, " "); expect(consoleOutput).toEqual([ - "undefined", + "\n", `--- info.header.cli ---`, `${pad("info.cli.version")}: ${MOCKED_CLI_VERSION}`, - "undefined", + "\n", `--- info.header.runtime ---`, `${pad("info.runtime.runtime_name")}: ${MOCKED_SYSTEM_INFO.runtimeName}`, `${pad("info.runtime.runtime_version")}: ${MOCKED_SYSTEM_INFO.runtimeVersion}`, `${pad("info.runtime.package_manager")}: ${MOCKED_SYSTEM_INFO.packageManagerVersion}`, - "undefined", + "\n", `--- info.header.os_details ---`, `${pad("info.os.type_version")}: ${MOCKED_SYSTEM_INFO.os}`, `${pad("info.os.architecture")}: ${MOCKED_SYSTEM_INFO.arch}`, `${pad("info.os.shell")}: ${MOCKED_SYSTEM_INFO.shell}`, `${pad("info.os.home_dir")}: ${MOCKED_SYSTEM_INFO.homeDir}`, - "undefined", + "\n", `--- info.header.config_files ---`, - `${pad("info.config.global_path")}: ${GLOBAL_PATH} [GREEN: info.config.found]`, - `${pad("info.config.local_path")}: ${LOCAL_EXPECTED} [RED: info.config.not_found]`, - "undefined", + `${pad("info.config.global_path")}: ${GLOBAL_PATH} info.config.found`, + `${pad("info.config.local_path")}: ${LOCAL_EXPECTED} info.config.not_found`, + "\n", ]); }); diff --git a/packages/devkit/__tests__/units/commands/list.spec.ts b/packages/devkit/__tests__/units/commands/list.spec.ts index 0c57a69..9a46b7d 100644 --- a/packages/devkit/__tests__/units/commands/list.spec.ts +++ b/packages/devkit/__tests__/units/commands/list.spec.ts @@ -1,8 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import { setupListCommand } from "../../../src/commands/list"; import { DevkitError } from "../../../src/utils/errors/base"; import type { CliConfig } from "../../../src/utils/schema/schema"; -import { mockChalk, mockSpinner } from "../../../vitest.setup"; +import { mockSpinner, mockLogger } from "../../../vitest.setup.js"; const sampleLocalConfig: CliConfig = { settings: { @@ -42,7 +42,6 @@ const { mockValidateProgrammingLanguage, mockHandleErrorAndExit, mockProgram, - consoleLogSpy, } = vi.hoisted(() => { return { mockReadAndMergeConfigs: vi.fn(), @@ -57,7 +56,6 @@ const { option: vi.fn().mockReturnThis(), action: vi.fn().mockReturnThis(), }, - consoleLogSpy: vi.spyOn(console, "log").mockImplementation(() => {}), }; }); @@ -90,7 +88,6 @@ describe("list command", () => { mockProgram.action.mockImplementation((fn) => { actionFn = fn; }); - consoleLogSpy.mockClear(); }); it("should define the list command correctly", () => { @@ -251,8 +248,8 @@ describe("list command", () => { expect(mockSpinner.info).toHaveBeenCalledWith( "list.templates.using_local", ); - expect(consoleLogSpy).toHaveBeenCalledTimes(1); - expect(consoleLogSpy).toHaveBeenCalledWith("\nlist.templates.header"); + expect(mockLogger.log).toHaveBeenCalledTimes(1); + expect(mockLogger.log).toHaveBeenCalledWith("\nlist.templates.header"); expect(mockPrintTemplates).toHaveBeenCalledOnce(); expect(mockPrintTemplates).toHaveBeenCalledWith( @@ -325,9 +322,11 @@ describe("list command", () => { ); expect(mockSpinner.start).toHaveBeenCalled(); expect(mockSpinner.succeed).toHaveBeenCalledWith( - mockChalk.yellow("list.templates.not_found- options template:"), + (mockLogger.colors as { yellow: Mock }).yellow( + "list.templates.not_found- options template:", + ), ); - expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(mockLogger.log).not.toHaveBeenCalled(); expect(mockPrintTemplates).not.toHaveBeenCalled(); }); }); diff --git a/packages/devkit/__tests__/units/commands/new.spec.ts b/packages/devkit/__tests__/units/commands/new.spec.ts index 8215770..a3bc6c1 100644 --- a/packages/devkit/__tests__/units/commands/new.spec.ts +++ b/packages/devkit/__tests__/units/commands/new.spec.ts @@ -4,9 +4,14 @@ import { DevkitError } from "../../../src/utils/errors/base.js"; import { mockSpinner } from "../../../vitest.setup.js"; import type { CliConfig } from "../../../src/utils/schema/schema.js"; -const { mockHandleErrorAndExit, mockScaffoldProject } = vi.hoisted(() => ({ +const { + mockHandleErrorAndExit, + mockScaffoldProject, + mockValidateProgrammingLanguage, +} = vi.hoisted(() => ({ mockHandleErrorAndExit: vi.fn(), mockScaffoldProject: vi.fn(), + mockValidateProgrammingLanguage: vi.fn(), })); let actionFn: any; @@ -18,8 +23,8 @@ vi.mock("#scaffolding/javascript.js", () => ({ scaffoldProject: mockScaffoldProject, })); -vi.mock("#scaffolding/typescript.js", () => ({ - scaffoldProject: mockScaffoldProject, +vi.mock("#utils/validations/config.js", () => ({ + validateProgrammingLanguage: mockValidateProgrammingLanguage, })); describe("setupNewCommand", () => { @@ -169,12 +174,17 @@ describe("setupNewCommand", () => { const projectName = "my-python-project"; const cmdOptions = { template: "my-template" }; + const programmingLanguageError = new DevkitError( + "error.language_config_not_found- options language:python", + ); + mockValidateProgrammingLanguage.mockRejectedValueOnce( + programmingLanguageError, + ); + await actionFn(language, projectName, cmdOptions); expect(mockHandleErrorAndExit).toHaveBeenCalledWith( - new DevkitError( - "error.language_config_not_found- options language:python", - ), + programmingLanguageError, mockSpinner, ); }); diff --git a/packages/devkit/__tests__/units/core/info/project.spec.ts b/packages/devkit/__tests__/units/core/info/project.spec.ts index dd1a84f..253206a 100644 --- a/packages/devkit/__tests__/units/core/info/project.spec.ts +++ b/packages/devkit/__tests__/units/core/info/project.spec.ts @@ -1,5 +1,12 @@ import { vi, describe, it, expect, beforeEach } from "vitest"; import { getProjectVersion } from "../../../../src/core/info/project.js"; +import { mockLogger, mocktFn } from "../../../../vitest.setup.js"; + +vi.mock("path", () => ({ + default: { + join: vi.fn((...args) => args.join("/")), + }, +})); const { mockFindPackageRoot, mockFsReadJson } = vi.hoisted(() => ({ mockFindPackageRoot: vi.fn(), @@ -14,9 +21,15 @@ vi.mock("#utils/fs/finder.js", () => ({ findPackageRoot: mockFindPackageRoot, })); -describe("GetProjectVersion", () => { +vi.mock("#utils/schema/schema.js", () => ({ + FILE_NAMES: { packageJson: "package.json" }, +})); + +describe("getProjectVersion", () => { beforeEach(() => { vi.clearAllMocks(); + mockLogger.error.mockClear(); + mockLogger.dimmed.mockClear(); }); it("should return the project version from package.json", async () => { @@ -29,32 +42,31 @@ describe("GetProjectVersion", () => { expect(mockFsReadJson).toHaveBeenCalledWith( "/mock/project/root/package.json", ); + expect(mockLogger.error).not.toHaveBeenCalled(); expect(version).toBe("1.2.3"); }); it("should return '0.0.0' and log an error if package root is not found", async () => { - const consoleErrorSpy = vi - .spyOn(console, "error") - .mockImplementation(() => {}); + const rootNotFoundErrorMessage = mocktFn("error.package.root.not_found"); + const expectedErrorMessage = `error.version.read_fail: ${rootNotFoundErrorMessage}`; + mockFindPackageRoot.mockResolvedValue(null); const version = await getProjectVersion(); expect(mockFindPackageRoot).toHaveBeenCalled(); expect(mockFsReadJson).not.toHaveBeenCalled(); - expect(consoleErrorSpy).toHaveBeenCalledWith( - "error.version.read_fail", - new Error("error.package.root.not_found"), - ); + + expect(mockLogger.error).toHaveBeenCalledWith(expectedErrorMessage, "INFO"); + expect(mockLogger.dimmed).toHaveBeenCalled(); expect(version).toBe("0.0.0"); - consoleErrorSpy.mockRestore(); }); - it("should return '0.0.0' and log an error if reading package.json fails", async () => { - const consoleErrorSpy = vi - .spyOn(console, "error") - .mockImplementation(() => {}); + it("should return '0.0.0', log an error, and log stack if reading package.json fails", async () => { const readError = new Error("Failed to read file"); + readError.stack = "Mock stack trace"; + const expectedErrorMessage = `error.version.read_fail: Failed to read file`; + mockFindPackageRoot.mockResolvedValue("/mock/project/root"); mockFsReadJson.mockRejectedValue(readError); @@ -64,11 +76,24 @@ describe("GetProjectVersion", () => { expect(mockFsReadJson).toHaveBeenCalledWith( "/mock/project/root/package.json", ); - expect(consoleErrorSpy).toHaveBeenCalledWith( - "error.version.read_fail", - readError, - ); + + expect(mockLogger.error).toHaveBeenCalledWith(expectedErrorMessage, "INFO"); + + expect(mockLogger.dimmed).toHaveBeenCalledWith(readError.stack); + expect(version).toBe("0.0.0"); + }); + + it("should return '0.0.0' and log a generic error if non-Error is thrown", async () => { + const genericError = 12345; + const expectedErrorMessage = "error.version.read_fail"; + + mockFindPackageRoot.mockResolvedValue("/mock/project/root"); + mockFsReadJson.mockRejectedValue(genericError); + + const version = await getProjectVersion(); + + expect(mockLogger.error).toHaveBeenCalledWith(expectedErrorMessage, "INFO"); + expect(mockLogger.dimmed).not.toHaveBeenCalled(); expect(version).toBe("0.0.0"); - consoleErrorSpy.mockRestore(); }); }); diff --git a/packages/devkit/__tests__/units/core/template/printer.spec.ts b/packages/devkit/__tests__/units/core/template/printer.spec.ts index bf8bfe2..ff08e42 100644 --- a/packages/devkit/__tests__/units/core/template/printer.spec.ts +++ b/packages/devkit/__tests__/units/core/template/printer.spec.ts @@ -1,24 +1,18 @@ -import { - describe, - it, - expect, - vi, - beforeEach, - type MockInstance, -} from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { printSettings, printTemplates, } from "../../../../src/core/template/printer.js"; +import { mockLogger, mocktFn } from "../../../../vitest.setup.js"; describe("print-utils", () => { - let mockConsoleLog: MockInstance; - beforeEach(() => { - mockConsoleLog = vi.spyOn(console, "log").mockImplementation(() => {}); vi.clearAllMocks(); }); + const c: any = mockLogger.colors; + const t = mocktFn; + const templates = { "node-ts-api": { description: "A simple Node.js API with TypeScript", @@ -47,61 +41,103 @@ describe("print-utils", () => { describe("printTemplates", () => { it("should print all templates without a filter", () => { printTemplates("typescript", templates); - expect(mockConsoleLog).toHaveBeenCalledTimes(5); - expect(mockConsoleLog).toHaveBeenCalledWith("\nTYPESCRIPT:"); - expect(mockConsoleLog).toHaveBeenCalledWith( - " - node-ts-api (cli.add_template.options.alias: nta)\n cli.add_template.options.description: A simple Node.js API with TypeScript\n Location: https://github.com/devkit/node-ts-api\n cli.add_template.options.cache: daily\n cli.add_template.options.package_manager: npm\n", + + expect(mockLogger.log).toHaveBeenCalledTimes(5); + + printTemplates("typescript", templates); + + expect(mockLogger.log).toHaveBeenCalledWith( + `\n${c.boldBlue("TYPESCRIPT")}:`, + ); + + expect(mockLogger.log).toHaveBeenCalledWith( + ` - ${c.green("node-ts-api")} ${c.cyanDim(`(${t("cli.add_template.options.alias")}: nta)`)}${c.dim(`\n ${t("cli.add_template.options.description")}`)}: A simple Node.js API with TypeScript${c.dim("\n Location")}: https://github.com/devkit/node-ts-api${c.dim(`\n ${t("cli.add_template.options.cache")}`)}: daily${c.dim(`\n ${t("cli.add_template.options.package_manager")}`)}: npm\n`, + ); + + expect(mockLogger.log).toHaveBeenCalledWith( + ` - ${c.green("react-component")} ${c.dim(`\n ${t("cli.add_template.options.description")}`)}: A reusable React component${c.dim("\n Location")}: /local/path/to/template\n`, ); - expect(mockConsoleLog).toHaveBeenCalledWith( - " - react-component \n cli.add_template.options.description: A reusable React component\n Location: /local/path/to/template\n", + + expect(mockLogger.log).toHaveBeenCalledWith( + ` - ${c.green("next-app")} ${c.cyanDim(`(${t("cli.add_template.options.alias")}: nextjs)`)}${c.dim(`\n ${t("cli.add_template.options.description")}`)}: A Next.js application template\n`, ); - expect(mockConsoleLog).toHaveBeenCalledWith( - " - next-app (cli.add_template.options.alias: nextjs)\n cli.add_template.options.description: A Next.js application template\n", + + expect(mockLogger.log).toHaveBeenCalledWith( + ` - ${c.green("simple-template")} \n`, ); - expect(mockConsoleLog).toHaveBeenCalledWith(" - simple-template \n"); + + mockLogger.log.mockRestore(); }); it("should print only filtered templates by name", () => { printTemplates("typescript", templates, "react"); - expect(mockConsoleLog).toHaveBeenCalledTimes(2); - expect(mockConsoleLog).toHaveBeenCalledWith("\nTYPESCRIPT:"); - expect(mockConsoleLog).toHaveBeenCalledWith( - " - react-component \n cli.add_template.options.description: A reusable React component\n Location: /local/path/to/template\n", + + printTemplates("typescript", templates, "react"); + + expect(mockLogger.log).toHaveBeenCalledWith( + `\n${c.boldBlue("TYPESCRIPT")}:`, + ); + expect(mockLogger.log).toHaveBeenCalledTimes(4); + expect(mockLogger.log).toHaveBeenCalledWith( + ` - ${c.green("react-component")} ${c.dim(`\n ${t("cli.add_template.options.description")}`)}: A reusable React component${c.dim("\n Location")}: /local/path/to/template\n`, ); + + mockLogger.log.mockRestore(); }); it("should print only filtered templates by alias", () => { printTemplates("javascript", templates, "nta"); - expect(mockConsoleLog).toHaveBeenCalledTimes(2); - expect(mockConsoleLog).toHaveBeenCalledWith("\nJAVASCRIPT:"); - expect(mockConsoleLog).toHaveBeenCalledWith( - " - node-ts-api (cli.add_template.options.alias: nta)\n cli.add_template.options.description: A simple Node.js API with TypeScript\n Location: https://github.com/devkit/node-ts-api\n cli.add_template.options.cache: daily\n cli.add_template.options.package_manager: npm\n", + printTemplates("javascript", templates, "nta"); + + expect(mockLogger.log).toHaveBeenCalledWith( + `\n${c.boldBlue("JAVASCRIPT")}:`, + ); + expect(mockLogger.log).toHaveBeenCalledTimes(4); + expect(mockLogger.log).toHaveBeenCalledWith( + ` - ${c.green("node-ts-api")} ${c.cyanDim(`(${t("cli.add_template.options.alias")}: nta)`)}${c.dim(`\n ${t("cli.add_template.options.description")}`)}: A simple Node.js API with TypeScript${c.dim("\n Location")}: https://github.com/devkit/node-ts-api${c.dim(`\n ${t("cli.add_template.options.cache")}`)}: daily${c.dim(`\n ${t("cli.add_template.options.package_manager")}`)}: npm\n`, ); + + mockLogger.log.mockRestore(); }); it("should not print anything if no templates match the filter", () => { printTemplates("python", templates, "unrelated"); - expect(mockConsoleLog).not.toHaveBeenCalled(); + expect(mockLogger.log).not.toHaveBeenCalled(); + + printTemplates("python", templates, "unrelated"); + expect(mockLogger.log).not.toHaveBeenCalled(); + mockLogger.log.mockRestore(); }); it("should not print anything if templates are empty", () => { printTemplates("rust", {}); - expect(mockConsoleLog).not.toHaveBeenCalled(); + expect(mockLogger.log).not.toHaveBeenCalled(); + + printTemplates("rust", {}); + expect(mockLogger.log).not.toHaveBeenCalled(); + mockLogger.log.mockRestore(); }); }); describe("printSettings", () => { it("should print all settings correctly", () => { printSettings(settings); - expect(mockConsoleLog).toHaveBeenCalledTimes(3); - expect(mockConsoleLog).toHaveBeenCalledWith(" packageManager: pnpm"); - expect(mockConsoleLog).toHaveBeenCalledWith(" cacheStrategy: daily"); - expect(mockConsoleLog).toHaveBeenCalledWith(" language: en"); + expect(mockLogger.log).toHaveBeenCalledTimes(3); + + expect(mockLogger.log).toHaveBeenCalledWith( + `${c.yellowBold(" packageManager:")} ${c.cyan("pnpm")}`, + ); + expect(mockLogger.log).toHaveBeenCalledWith( + `${c.yellowBold(" cacheStrategy:")} ${c.cyan("daily")}`, + ); + expect(mockLogger.log).toHaveBeenCalledWith( + `${c.yellowBold(" language:")} ${c.cyan("en")}`, + ); }); it("should not print anything if settings object is empty", () => { printSettings({}); - expect(mockConsoleLog).not.toHaveBeenCalled(); + expect(mockLogger.log).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/devkit/__tests__/units/core/template/update-project-name.spec.ts b/packages/devkit/__tests__/units/core/template/update-project-name.spec.ts index 2d71ab9..4f03150 100644 --- a/packages/devkit/__tests__/units/core/template/update-project-name.spec.ts +++ b/packages/devkit/__tests__/units/core/template/update-project-name.spec.ts @@ -1,4 +1,5 @@ import { vi, describe, it, expect, beforeEach } from "vitest"; +import { mockLogger } from "../../../../vitest.setup.js"; const { mockExistsSync, mockReadJson, mockWriteJson } = vi.hoisted(() => ({ mockExistsSync: vi.fn(), @@ -17,10 +18,6 @@ vi.mock("#utils/fs/file.js", () => ({ import { updateJavascriptProjectName } from "../../../../src/core/template/update-project-name.js"; import { FILE_NAMES } from "../../../../src/utils/schema/schema.js"; -const mockConsoleError = vi - .spyOn(console, "error") - .mockImplementation(() => {}); - describe("update-project-name.ts", () => { const projectPath = "/test/path"; const newProjectName = "new-project"; @@ -32,7 +29,6 @@ describe("update-project-name.ts", () => { beforeEach(() => { vi.clearAllMocks(); - mockConsoleError.mockClear(); }); describe("updateJavascriptProjectName", () => { @@ -53,7 +49,7 @@ describe("update-project-name.ts", () => { ...mockPackageJson, name: newProjectName, }); - expect(mockConsoleError).not.toHaveBeenCalled(); + expect(mockLogger.error).not.toHaveBeenCalled(); }); it("should log an error if package.json does not exist", async () => { @@ -61,11 +57,17 @@ describe("update-project-name.ts", () => { await updateJavascriptProjectName(projectPath, newProjectName); + expect(mockExistsSync).toHaveBeenCalledOnce(); expect(mockExistsSync).toHaveBeenCalledWith(packageJsonPath); + expect(mockReadJson).not.toHaveBeenCalled(); expect(mockWriteJson).not.toHaveBeenCalled(); - expect(mockConsoleError).toHaveBeenCalledOnce(); - expect(mockConsoleError).toHaveBeenCalledWith(expect.any(String)); + + expect(mockLogger.error).toHaveBeenCalledOnce(); + expect(mockLogger.error).toHaveBeenCalledWith( + "error.package.file_not_found", + "TEMPL", + ); }); it("should log an error if writing to package.json fails", async () => { @@ -87,9 +89,9 @@ describe("update-project-name.ts", () => { ...mockPackageJson, name: newProjectName, }); - expect(mockConsoleError).toHaveBeenCalledWith( - expect.any(String), - writeError, + expect(mockLogger.error).toHaveBeenCalledWith( + "error.package.failed_to_update_project_name: Permission denied", + "TEMPL", ); }); }); diff --git a/packages/devkit/__tests__/units/scaffolding/javascript.spec.ts b/packages/devkit/__tests__/units/scaffolding/javascript.spec.ts index 3883142..8234b13 100644 --- a/packages/devkit/__tests__/units/scaffolding/javascript.spec.ts +++ b/packages/devkit/__tests__/units/scaffolding/javascript.spec.ts @@ -1,7 +1,10 @@ import { vi, describe, it, expect, beforeEach } from "vitest"; -import { scaffoldProject } from "../../../src/scaffolding/javascript.js"; +import { + scaffoldProject, + type ScaffoldJavascriptProjectOptions, +} from "../../../src/scaffolding/javascript.js"; import { DevkitError } from "../../../src/utils/errors/base.js"; -import { mockSpinner } from "../../../vitest.setup.js"; +import { mockSpinner, mockLogger } from "../../../vitest.setup.js"; const { mockRunCliCommand, @@ -36,12 +39,10 @@ describe("scaffoldProject", () => { projectName: "my-project", packageManager: "npm", cacheStrategy: "daily", - } as any; + } as ScaffoldJavascriptProjectOptions; beforeEach(() => { vi.clearAllMocks(); - vi.spyOn(console, "log").mockImplementation(() => {}); - vi.spyOn(console, "error").mockImplementation(() => {}); }); it("should run official CLI command for a {pm} template", async () => { @@ -88,13 +89,13 @@ describe("scaffoldProject", () => { ); await scaffoldProject({ ...options, templateConfig }); expect(mockSpinner.fail).toHaveBeenCalled(); - expect(console.error).toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalled(); }); it("should log success messages and next steps for non-CLI templates", async () => { const templateConfig = { location: "http://example.com" }; await scaffoldProject({ ...options, templateConfig }); expect(mockInstallDependencies).toHaveBeenCalled(); - expect(console.log).toHaveBeenCalledWith("scaffolding.complete.success"); + expect(mockLogger.log).toHaveBeenCalledWith("scaffolding.complete.success"); }); }); diff --git a/packages/devkit/__tests__/units/utils/errors/handler.spec.ts b/packages/devkit/__tests__/units/utils/errors/handler.spec.ts index 849d6ed..016e767 100644 --- a/packages/devkit/__tests__/units/utils/errors/handler.spec.ts +++ b/packages/devkit/__tests__/units/utils/errors/handler.spec.ts @@ -1,7 +1,14 @@ import { vi, describe, it, expect, beforeEach } from "vitest"; import { handleErrorAndExit } from "../../../../src/utils/errors/handler.js"; -import { ConfigError, GitError } from "../../../../src/utils/errors/base.js"; -import { mockLogger } from "../../../../vitest.setup.js"; +import { + ConfigError, + GitError, + DevkitError, +} from "../../../../src/utils/errors/base.js"; +import { mockLogger, mocktFn } from "../../../../vitest.setup.js"; +import type { ErrorType } from "../../../../src/utils/logger.js"; + +mockLogger.dimmed = vi.fn(); const mockExit = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit was called."); @@ -17,7 +24,8 @@ describe("handleErrorAndExit", () => { const testErrorHandling = async ( error: unknown, - expectedLog: string[], + expectedErrorCall: { message: string; type: ErrorType }, + expectedDimmedCalls: string[] = [], expectedExitCode = 1, ) => { try { @@ -27,40 +35,86 @@ describe("handleErrorAndExit", () => { } expect(mockSpinner.stop).toHaveBeenCalled(); - expect(mockLogger.error).toHaveBeenCalledTimes(expectedLog.length); - expectedLog.forEach((log, index) => { - expect(mockLogger.error.mock.calls[index]![0]).toBe(log); - }); expect(mockExit).toHaveBeenCalledWith(expectedExitCode); + + expect(mockLogger.error).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith( + expectedErrorCall.message, + expectedErrorCall.type, + ); + + expect(mockLogger.dimmed).toHaveBeenCalledTimes(expectedDimmedCalls.length); + expectedDimmedCalls.forEach((log, index) => { + expect(mockLogger.dimmed.mock.calls[index]![0]).toBe(log); + }); }; it("should handle ConfigError with filePath correctly", async () => { const error = new ConfigError("Invalid config", "/path/to/config.json"); - const expectedLog = [ - "error.config.generic: Invalid config", - "File path: /path/to/config.json", - ]; - await testErrorHandling(error, expectedLog); + const expectedErrorCall = { + message: `error.config.generic: Invalid config`, + type: "CONFIG" as ErrorType, + }; + const expectedDimmedCalls = ["File path: /path/to/config.json"]; + + await testErrorHandling(error, expectedErrorCall, expectedDimmedCalls); }); it("should handle GitError with url correctly", async () => { const error = new GitError("Clone failed", "https://github.com/repo.git"); - const expectedLog = [ - "error.git.generic: Clone failed", - "Repository URL: https://github.com/repo.git", - ]; - await testErrorHandling(error, expectedLog); + const expectedErrorCall = { + message: `error.git.generic: Clone failed`, + type: "GIT" as ErrorType, + }; + const expectedDimmedCalls = ["Repository URL: https://github.com/repo.git"]; + + await testErrorHandling(error, expectedErrorCall, expectedDimmedCalls); + }); + + it("should handle DevkitError correctly", async () => { + const error = new DevkitError("CLI specific issue"); + const expectedErrorCall = { + message: `error.devkit_specific: CLI specific issue`, + type: "DEV" as ErrorType, + }; + + await testErrorHandling(error, expectedErrorCall); }); it("should handle a generic Error correctly", async () => { const error = new Error("Something went wrong"); - const expectedLog = ["error.unexpected: Something went wrong"]; - await testErrorHandling(error, expectedLog); + const expectedErrorCall = { + message: `error.unexpected: Something went wrong`, + type: "ERR" as ErrorType, + }; + + await testErrorHandling(error, expectedErrorCall); }); it("should handle an unknown error correctly", async () => { const error = "A string error"; - const expectedLog = ["error.unknown"]; - await testErrorHandling(error, expectedLog); + const expectedErrorCall = { + message: "error.unknown", + type: "UNKNOWN" as ErrorType, + }; + + await testErrorHandling(error, expectedErrorCall); + }); + + it("should log the cause if the error has one", async () => { + const causeError = new Error("Underlying system failure"); + const error = new ConfigError("Invalid config", "/path/to/config.json", { + cause: causeError, + }); + const expectedErrorCall = { + message: `error.config.generic: Invalid config`, + type: "CONFIG" as ErrorType, + }; + const expectedDimmedCalls = [ + "File path: /path/to/config.json", + "Cause: Underlying system failure", + ]; + + await testErrorHandling(error, expectedErrorCall, expectedDimmedCalls); }); }); diff --git a/packages/devkit/__tests__/units/utils/logger.spec.ts b/packages/devkit/__tests__/units/utils/logger.spec.ts index d1c5084..3aaea46 100644 --- a/packages/devkit/__tests__/units/utils/logger.spec.ts +++ b/packages/devkit/__tests__/units/utils/logger.spec.ts @@ -7,23 +7,85 @@ import { afterEach, type MockInstance, beforeAll, + afterAll, + type MockedFunction, } from "vitest"; import { logger } from "../../../src/utils/logger.js"; -const { mockChalk, mockOra } = vi.hoisted(() => ({ - mockChalk: { - blue: vi.fn((m) => `[blue] ${m}`), - green: vi.fn((m) => `[green] ${m}`), - yellow: vi.fn((m) => `[yellow] ${m}`), - red: vi.fn((m) => `[red] ${m}`), - dim: vi.fn((m) => `[dim] ${m}`), - }, - mockOra: vi.fn(() => ({ - start: vi.fn(), - stop: vi.fn(), - succeed: vi.fn(), - })), -})); +const MOCK_TIME = "10:00:00"; +const MOCK_TIMESTAMP = `[dim] [${MOCK_TIME}]`; + +const { mockChalk, mockOra } = vi.hoisted(() => { + type MockColorFn = MockedFunction<(msg: string) => string>; + + const createColorMock = (name: string): MockColorFn => + vi.fn((m) => `[${name}] ${m}`) as MockColorFn; + + const simpleMocks = { + blue: createColorMock("blue"), + green: createColorMock("green"), + yellow: createColorMock("yellow"), + red: createColorMock("red"), + cyan: createColorMock("cyan"), + dim: createColorMock("dim"), + bold: createColorMock("bold"), + italic: createColorMock("italic"), + redBright: createColorMock("redBright"), + greenBright: createColorMock("greenBright"), + magenta: createColorMock("magenta"), + magentaBright: createColorMock("magentaBright"), + white: createColorMock("white"), + }; + + const compositeMocks = { + boldRed: createColorMock("bold_red"), + boldBlue: createColorMock("bold_blue"), + boldYellow: createColorMock("bold_yellow"), + cyanDim: createColorMock("cyan_dim"), + }; + + const timestampDimMock = vi.fn((m) => `[dim] ${m}`) as MockColorFn; + + const boldMock = simpleMocks.bold as unknown as MockColorFn & { + red: MockColorFn; + blue: MockColorFn; + yellow: MockColorFn; + }; + boldMock.red = compositeMocks.boldRed; + boldMock.blue = compositeMocks.boldBlue; + boldMock.yellow = compositeMocks.boldYellow; + + const cyanMock = simpleMocks.cyan as unknown as MockColorFn & { + dim: MockColorFn; + }; + cyanMock.dim = compositeMocks.cyanDim; + + const mockChalk = { + ...simpleMocks, + dim: timestampDimMock, + bold: boldMock, + cyan: cyanMock, + }; + + mockChalk.blue = simpleMocks.blue; + mockChalk.green = simpleMocks.green; + mockChalk.yellow = simpleMocks.yellow; + mockChalk.red = simpleMocks.red; + mockChalk.redBright = simpleMocks.redBright; + + return { + mockChalk: mockChalk, + mockOra: vi.fn((text) => ({ + text: text, + start: vi.fn(), + stop: vi.fn(), + succeed: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + fail: vi.fn(), + })), + }; +}); vi.mock("chalk", () => ({ default: mockChalk })); vi.mock("ora", () => ({ default: mockOra })); @@ -31,15 +93,26 @@ vi.mock("ora", () => ({ default: mockOra })); describe("logger utility", () => { let mockConsoleLog: MockInstance; let mockConsoleError: MockInstance; + let timestampDimMock: MockInstance; beforeAll(() => { vi.unmock("#utils/logger.js"); + + vi.useFakeTimers(); + vi.setSystemTime(new Date(`2024-01-01T${MOCK_TIME}`)); + }); + + afterAll(() => { + vi.useRealTimers(); }); beforeEach(() => { vi.clearAllMocks(); mockConsoleLog = vi.spyOn(console, "log").mockImplementation(() => {}); mockConsoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + timestampDimMock = mockChalk.dim as MockInstance; + timestampDimMock.mockImplementation((m: string) => `[dim] ${m}`); }); afterEach(() => { @@ -54,15 +127,14 @@ describe("logger utility", () => { expect(mockChalk.blue).toHaveBeenCalledOnce(); expect(mockChalk.blue).toHaveBeenCalledWith(message); expect(mockConsoleLog).toHaveBeenCalledWith(`[blue] ${message}`); - expect(mockConsoleError).not.toHaveBeenCalled(); }); - it("success() should call console.log with green checkmark", () => { + it("success() should call console.log with green checkmark and a newline", () => { const message = "Operation successful"; logger.success(message); - expect(mockChalk.green).toHaveBeenCalledWith(`✔ ${message}`); - expect(mockConsoleLog).toHaveBeenCalledWith(`[green] ✔ ${message}`); + expect(mockChalk.green).toHaveBeenCalledWith(`\n✔ ${message}`); + expect(mockConsoleLog).toHaveBeenCalledWith(`[green] \n✔ ${message}`); }); it("warning() should call console.log with yellow warning emoji", () => { @@ -73,29 +145,33 @@ describe("logger utility", () => { expect(mockConsoleLog).toHaveBeenCalledWith(`[yellow] ⚠️ ${message}`); }); - it("error() should call console.error with red cross emoji and a newline", () => { + it("error() should call console.error with timestamp, type tag, and redBright message", () => { const message = "File access denied"; - logger.error(message); + const errorType = "CONFIG"; + logger.error(message, errorType); - expect(mockChalk.red).toHaveBeenCalledWith(`\n❌ ${message}`); - expect(mockConsoleError).toHaveBeenCalledWith(`[red] \n❌ ${message}`); - expect(mockConsoleLog).not.toHaveBeenCalled(); + expect(timestampDimMock).toHaveBeenCalledWith(`[${MOCK_TIME}]`); + expect(mockChalk.bold.red).toHaveBeenCalledWith(`[${errorType}]`); + + expect(mockChalk.redBright).toHaveBeenCalledWith(`❌ ${message}`); + + const expectedOutput = `${MOCK_TIMESTAMP} [bold_red] [${errorType}] [redBright] ❌ ${message}`; + expect(mockConsoleError).toHaveBeenCalledWith(expectedOutput); }); - it("log() should call console.log directly without chalk", () => { - const message = "A plain log message"; - logger.log(message); + it("error() should default to 'UNKNOWN' type", () => { + const message = "Generic error"; + logger.error(message); - expect(mockConsoleLog).toHaveBeenCalledWith(message); - expect(mockChalk.blue).not.toHaveBeenCalled(); + expect(mockChalk.bold.red).toHaveBeenCalledWith(`[UNKNOWN]`); }); - it("dimmed() should call console.log with dim chalk", () => { - const message = "Extra details..."; + it("dimmed() should call console.log with dim chalk and trim the message", () => { + const message = " Extra details... "; logger.dimmed(message); - expect(mockChalk.dim).toHaveBeenCalledWith(message); - expect(mockConsoleLog).toHaveBeenCalledWith(`[dim] ${message}`); + expect(timestampDimMock).toHaveBeenCalledWith(message.trim()); + expect(mockConsoleLog).toHaveBeenCalledWith(`[dim] Extra details...`); }); it("spinner() should call ora with the provided text", () => { @@ -105,4 +181,33 @@ describe("logger utility", () => { expect(mockOra).toHaveBeenCalledWith(text); expect(spinnerInstance.start).toBeInstanceOf(Function); }); + + describe("Colors object", () => { + const colors = logger.colors; + const testString = "Test"; + + it("should have all specified color methods and return mocked strings", () => { + expect(colors.white(testString)).toBe(`[white] ${testString}`); + expect(colors.blue(testString)).toBe(`[blue] ${testString}`); + expect(colors.green(testString)).toBe(`[green] ${testString}`); + expect(colors.yellow(testString)).toBe(`[yellow] ${testString}`); + expect(colors.red(testString)).toBe(`[red] ${testString}`); + expect(colors.cyan(testString)).toBe(`[cyan] ${testString}`); + expect(colors.dim(testString)).toBe(`[dim] ${testString}`); + expect(colors.bold(testString)).toBe(`[bold] ${testString}`); + expect(colors.italic(testString)).toBe(`[italic] ${testString}`); + expect(colors.redBright(testString)).toBe(`[redBright] ${testString}`); + expect(colors.greenBright(testString)).toBe( + `[greenBright] ${testString}`, + ); + expect(colors.magenta(testString)).toBe(`[magenta] ${testString}`); + expect(colors.magentaBright(testString)).toBe( + `[magentaBright] ${testString}`, + ); + + expect(colors.boldBlue(testString)).toBe(`[bold_blue] ${testString}`); + expect(colors.cyanDim(testString)).toBe(`[cyan_dim] ${testString}`); + expect(colors.yellowBold(testString)).toBe(`[bold_yellow] ${testString}`); + }); + }); }); diff --git a/packages/devkit/locales/en.json b/packages/devkit/locales/en.json index 75106eb..f39963b 100644 --- a/packages/devkit/locales/en.json +++ b/packages/devkit/locales/en.json @@ -362,7 +362,8 @@ "description_required": "Description is required.", "missing_required_options": { "add_template": "Missing required options. Please provide all of the following: {fields}.\nAlternatively, run with the '-i' or '--interactive' flag for guided setup." - } + }, + "devkit_specific": "Devkit encountered an unexpected internal issue" }, "cache": { "clone": { diff --git a/packages/devkit/locales/fr.json b/packages/devkit/locales/fr.json index 6f5373e..43eb59a 100644 --- a/packages/devkit/locales/fr.json +++ b/packages/devkit/locales/fr.json @@ -362,7 +362,8 @@ "description_required": "La description est requise.", "missing_required_options": { "add_template": "Options requises manquantes. Veuillez fournir tous les champs suivants : {fields}.\nAlternativement, exécutez avec le drapeau '-i' ou '--interactive' pour une configuration guidée." - } + }, + "devkit_specific": "Problème interne inattendu rencontré par Devkit" }, "cache": { "clone": { diff --git a/packages/devkit/src/commands/config/add.ts b/packages/devkit/src/commands/config/add.ts index 57b1b2a..f5a531e 100644 --- a/packages/devkit/src/commands/config/add.ts +++ b/packages/devkit/src/commands/config/add.ts @@ -1,5 +1,4 @@ -import ora from "ora"; -import chalk from "chalk"; +import { logger, type TSpinner } from "#utils/logger.js"; import { handleErrorAndExit } from "#utils/errors/handler.js"; import { t } from "#utils/i18n/translator.js"; import { readAndMergeConfigs } from "#core/config/loader.js"; @@ -47,9 +46,11 @@ export function setupAddCommand(configCommand: Command): void { const parentOpts = childCommand?.parent?.opts(); const isGlobal = !!parentOpts?.global; - const spinner = ora( - chalk.cyan(t("cli.add_template.adding", { templateName })), - ).start(); + const spinner: TSpinner = logger + .spinner( + logger.colors.cyan(t("cli.add_template.adding", { templateName })), + ) + .start(); try { if (!description || !location) { diff --git a/packages/devkit/src/commands/config/index.ts b/packages/devkit/src/commands/config/index.ts index 5ba16de..674636f 100644 --- a/packages/devkit/src/commands/config/index.ts +++ b/packages/devkit/src/commands/config/index.ts @@ -3,8 +3,7 @@ import { readAndMergeConfigs } from "#core/config/loader.js"; import { handleErrorAndExit } from "#utils/errors/handler.js"; import { handleNonInteractiveSettingsUpdate } from "./logic.js"; import { type Command } from "commander"; -import ora, { type Ora } from "ora"; -import chalk from "chalk"; +import { logger, type TSpinner } from "#utils/logger.js"; import { setupAddCommand } from "./add.js"; import { setupRemoveCommand } from "./remove.js"; @@ -19,7 +18,7 @@ interface ConfigOptions { async function handleConfigAction( keys: string[], cmdOptions: ConfigOptions, - spinner: Ora, + spinner: TSpinner, ): Promise { const { global: isGlobal, set: bulkSetValues } = cmdOptions; @@ -28,7 +27,9 @@ async function handleConfigAction( if (bulkSetValues && bulkSetValues.length > 0) { if (bulkSetValues.length % 2 !== 0) { - spinner.fail(chalk.redBright(t("error.command.set.invalid_format"))); + spinner.fail( + logger.colors.redBright(t("error.command.set.invalid_format")), + ); return; } for (let i = 0; i < bulkSetValues.length; i += 2) { @@ -36,7 +37,7 @@ async function handleConfigAction( const bulkValue = bulkSetValues[i + 1]; await handleNonInteractiveSettingsUpdate(bulkKey, bulkValue, !!isGlobal); } - spinner.succeed(chalk.green(t("config.set.success"))); + spinner.succeed(logger.colors.green(t("config.set.success"))); return; } @@ -44,12 +45,12 @@ async function handleConfigAction( keys.forEach((key) => { const configValue = config.settings[key as keyof typeof config.settings]; if (configValue !== undefined) { - console.log(chalk.yellow.bold(key) + ": " + configValue); + logger.log(logger.colors.yellowBold(key) + ": " + configValue); } else { - console.log(chalk.redBright(t("config.get.not_found", { key }))); + logger.log(logger.colors.redBright(t("config.get.not_found", { key }))); } }); - spinner.succeed(chalk.green(t("config.get.success"))); + spinner.succeed(logger.colors.green(t("config.get.success"))); return; } @@ -64,7 +65,9 @@ export function setupConfigCommand(program: Command): void { .option("-g, --global", t("config.update.option.global"), false) .option("-s, --set ", t("config.set.option.bulk"), false) .action(async (keys: string[], cmdOptions: ConfigOptions) => { - const spinner = ora(chalk.cyan(t("config.get.loading"))).start(); + const spinner: TSpinner = logger + .spinner() + .start(logger.colors.cyan(t("config.get.loading"))); try { await handleConfigAction(keys, cmdOptions, spinner); } catch (error: unknown) { diff --git a/packages/devkit/src/commands/config/list.ts b/packages/devkit/src/commands/config/list.ts index cf6ba49..9fe45a7 100644 --- a/packages/devkit/src/commands/config/list.ts +++ b/packages/devkit/src/commands/config/list.ts @@ -1,8 +1,7 @@ import { t } from "#utils/i18n/translator.js"; import { handleErrorAndExit } from "#utils/errors/handler.js"; import { DevkitError } from "#utils/errors/base.js"; -import ora from "ora"; -import chalk from "chalk"; +import { logger, type TSpinner } from "#utils/logger.js"; import { readAndMergeConfigs } from "#core/config/loader.js"; import { printSettings, printTemplates } from "#core/template/printer.js"; import { type Command } from "commander"; @@ -58,7 +57,7 @@ export function setupListCommand(configCommand: Command): void { const parentOpts = childCommand?.parent?.opts(); const isGlobal = !!parentOpts?.global; - const spinner = ora(t("config.loading")).start(); + const spinner: TSpinner = logger.spinner(t("config.loading")).start(); try { if (isGlobal && showAll) { throw new DevkitError( @@ -87,12 +86,12 @@ export function setupListCommand(configCommand: Command): void { spinner.info(t(startMessageKey)).start(); - console.log(chalk.bold("\n" + t("list.config.settings_header"))); + logger.log(logger.colors.bold("\n" + t("list.config.settings_header"))); printSettings(config?.settings || {}); - console.log(chalk.bold("\n" + t("list.templates.header"))); + logger.log(logger.colors.bold("\n" + t("list.templates.header"))); if (Object.keys(config?.templates || {}).length === 0) { - console.log(chalk.yellow(t("list.templates.not_found"))); + logger.log(logger.colors.yellow(t("list.templates.not_found"))); } else { Object.entries(config?.templates || {}).forEach( ([lang, langTemplates]) => { diff --git a/packages/devkit/src/commands/config/prompts.ts b/packages/devkit/src/commands/config/prompts.ts index bd23545..440b6f1 100644 --- a/packages/devkit/src/commands/config/prompts.ts +++ b/packages/devkit/src/commands/config/prompts.ts @@ -10,19 +10,19 @@ import { promptForLanguage, promptForPackageManager, } from "#core/prompts/prompts.js"; -import chalk from "chalk"; +import { logger } from "#utils/logger.js"; const SETTINGS_CHOICES = [ { - name: `pm (${chalk.gray("package manager")})`, + name: `pm (${logger.colors.dim("package manager")})`, value: "packageManager", }, { - name: `cache (${chalk.gray("cache strategy")})`, + name: `cache (${logger.colors.dim("cache strategy")})`, value: "cacheStrategy", }, { - name: `lg (${chalk.gray("language")})`, + name: `lg (${logger.colors.dim("language")})`, value: "language", }, ]; @@ -36,7 +36,6 @@ async function handleInteractiveSettings( choices: SETTINGS_CHOICES, }); - console.log(settingKey); let newValue; switch (settingKey) { case "packageManager": @@ -58,7 +57,7 @@ async function handleInteractiveSettings( } await handleNonInteractiveSettingsUpdate(settingKey, newValue!, isGlobal); - console.log(t("config.set.success")); + logger.log(t("config.set.success")); } async function handleInteractiveTemplates( @@ -108,7 +107,7 @@ async function handleInteractiveTemplates( updates, isGlobal, ); - console.log(t("config.update.success", { templateName })); + logger.log(t("config.update.success", { templateName })); } export async function handleInteractiveConfig( diff --git a/packages/devkit/src/commands/config/remove.ts b/packages/devkit/src/commands/config/remove.ts index 680546d..2d7c560 100644 --- a/packages/devkit/src/commands/config/remove.ts +++ b/packages/devkit/src/commands/config/remove.ts @@ -1,10 +1,9 @@ import { t } from "#utils/i18n/translator.js"; import { DevkitError } from "#utils/errors/base.js"; import { handleErrorAndExit } from "#utils/errors/handler.js"; -import ora from "ora"; +import { logger, type TSpinner } from "#utils/logger.js"; import { readAndMergeConfigs } from "#core/config/loader.js"; import { saveGlobalConfig, saveLocalConfig } from "#core/config/writer.js"; -import chalk from "chalk"; import { type Command } from "commander"; import { type CliConfig } from "#utils/schema/schema.js"; import { type RemoveCommandOptions } from "./types.js"; @@ -36,7 +35,9 @@ export function setupRemoveCommand(configCommand: Command): void { const parentOpts = childCommand?.parent?.opts(); const isGlobal = !!parentOpts?.global; - const spinner = ora().start(chalk.cyan(t("remove_template.start"))); + const spinner: TSpinner = logger + .spinner() + .start(logger.colors.cyan(t("remove_template.start"))); try { validateProgrammingLanguage(language); @@ -92,6 +93,7 @@ export function setupRemoveCommand(configCommand: Command): void { languageTemplates.templates = templatesToKeep; await saveConfig(targetConfig, !!isGlobal); + spinner.succeed( t("remove_template.success", { count: templatesToRemove.length.toString(), @@ -101,8 +103,8 @@ export function setupRemoveCommand(configCommand: Command): void { ); if (notFound.length > 0) { - console.log( - chalk.yellow( + logger.log( + logger.colors.yellow( t("remove_template.not_found_warning", { template: notFound.join(", "), }), diff --git a/packages/devkit/src/commands/config/update.ts b/packages/devkit/src/commands/config/update.ts index 96446e1..36a6288 100644 --- a/packages/devkit/src/commands/config/update.ts +++ b/packages/devkit/src/commands/config/update.ts @@ -1,6 +1,5 @@ import { t } from "#utils/i18n/translator.js"; -import ora from "ora"; -import chalk from "chalk"; +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"; @@ -32,8 +31,8 @@ export function setupUpdateCommand(configCommand: Command): void { cmdOptions: UpdateCommandOptions, childCommand: Command, ) => { - const spinner = ora().start( - chalk.cyan( + const spinner: TSpinner = logger.spinner().start( + logger.colors.cyan( t("config.update.updating", { templateName: templateNames.join(", "), }), @@ -65,14 +64,14 @@ export function setupUpdateCommand(configCommand: Command): void { } catch (error: unknown) { hasErrors = true; if (error instanceof DevkitError) { - console.log( - chalk.yellow( + logger.log( + logger.colors.yellow( `\n${t("config.update.single_fail", { templateName, error: error.message })}`, ), ); } else { - console.log( - chalk.yellow( + logger.log( + logger.colors.yellow( `\n${t("config.update.single_fail", { templateName, error: "unknown error" })}`, ), ); @@ -83,8 +82,8 @@ export function setupUpdateCommand(configCommand: Command): void { spinner.stop(); if (successfullyUpdatedCount > 0) { - console.log( - chalk.green( + logger.log( + logger.colors.green( `\n✔ ${t("config.update.success_summary", { count: successfullyUpdatedCount.toString(), templateName: templateNames.join(", "), diff --git a/packages/devkit/src/commands/config/validate-and-save.ts b/packages/devkit/src/commands/config/validate-and-save.ts index b084fd9..a85230b 100644 --- a/packages/devkit/src/commands/config/validate-and-save.ts +++ b/packages/devkit/src/commands/config/validate-and-save.ts @@ -1,5 +1,4 @@ -import chalk from "chalk"; -import type { Ora } from "ora"; +import { logger, type TSpinner } from "#utils/logger.js"; import { saveCliConfig } from "#core/config/writer.js"; import { t } from "#utils/i18n/translator.js"; import { DevkitError } from "#utils/errors/base.js"; @@ -20,7 +19,7 @@ export async function validateAndSaveTemplate( templateDetails: AddTemplateSchema, targetConfig: CliConfig, isGlobal: boolean, - addSpinner: Ora, + addSpinner: TSpinner, ) { const { description, @@ -79,6 +78,6 @@ export async function validateAndSaveTemplate( await saveCliConfig(targetConfig, isGlobal); addSpinner.succeed( - chalk.green(t("cli.add_template.success", { templateName })), + logger.colors.green(t("cli.add_template.success", { templateName })), ); } diff --git a/packages/devkit/src/commands/index.ts b/packages/devkit/src/commands/index.ts index 9b8ed67..88873a8 100644 --- a/packages/devkit/src/commands/index.ts +++ b/packages/devkit/src/commands/index.ts @@ -1,8 +1,7 @@ import { Command } from "commander"; import { readAndMergeConfigs } from "#core/config/loader.js"; import { t } from "#utils/i18n/translator.js"; -import ora from "ora"; -import chalk from "chalk"; +import { logger, TSpinner } from "#utils/logger.js"; import { getProjectVersion } from "#core/info/project.js"; import { handleErrorAndExit } from "#utils/errors/handler.js"; import { setupNewCommand } from "#commands/new.js"; @@ -21,9 +20,13 @@ export async function setupAndParse() { program.parseOptions(process.argv); const isVerbose = !!program.opts().verbose; - const spinner = ora().start( - isVerbose ? chalk.bold.cyan("Initializing CLI...") : "", - ); + const spinner: TSpinner = logger + .spinner() + .start( + isVerbose + ? logger.colors.cyan(logger.colors.bold("Initializing CLI...")) + : "", + ); try { const { config, source } = await readAndMergeConfigs({ @@ -38,13 +41,14 @@ export async function setupAndParse() { await loadTranslations(locale); - isVerbose && spinner.succeed(chalk.bold.green(t("program.initialized"))); + isVerbose && + spinner.succeed( + logger.colors.green(logger.colors.bold(t("program.initialized"))), + ); if (source === "default") { - console.warn( - "\n", - chalk.italic.bold.yellow(t("warning.no_config_found")), - "\n", + logger.warning( + `\n${logger.colors.yellowBold(logger.colors.italic(t("warning.no_config_found")))}\n`, ); } diff --git a/packages/devkit/src/commands/info.ts b/packages/devkit/src/commands/info.ts index efe4f79..9e66cc4 100644 --- a/packages/devkit/src/commands/info.ts +++ b/packages/devkit/src/commands/info.ts @@ -1,7 +1,6 @@ import { t } from "#utils/i18n/translator.js"; import { handleErrorAndExit } from "#utils/errors/handler.js"; -import ora from "ora"; -import chalk from "chalk"; +import { logger, type TSpinner } from "#utils/logger.js"; import type { SetupCommandOptions } from "#utils/schema/schema.js"; import { collectSystemInfo, type SystemInfo } from "#core/info/info.js"; @@ -40,10 +39,12 @@ const printInfo = (info: SystemInfo): void => { }, ]; - console.log(); + logger.log("\n"); sections.forEach((section) => { - console.log(chalk.bold.cyan(`--- ${t(section.titleKey)} ---`)); + logger.log( + logger.colors.cyan(logger.colors.bold(`--- ${t(section.titleKey)} ---`)), + ); section.items.forEach(([label, value]) => { let displayValue: string; @@ -53,14 +54,14 @@ const printInfo = (info: SystemInfo): void => { displayValue = value; } else { const status = value.exists - ? chalk.green(t("info.config.found")) - : chalk.red(t("info.config.not_found")); + ? logger.colors.green(t("info.config.found")) + : logger.colors.red(t("info.config.not_found")); displayValue = `${value.path} ${status}`; } - console.log(`${chalk.yellow(labelPadded)}: ${displayValue}`); + logger.log(`${logger.colors.yellow(labelPadded)}: ${displayValue}`); }); - console.log(); + logger.log("\n"); }); }; @@ -73,7 +74,7 @@ export function setupInfoCommand(options: SetupCommandOptions): void { .alias("in") .description(t("info.command.description")) .action(async () => { - const spinner = ora(t("info.loading")).start(); + const spinner: TSpinner = logger.spinner(t("info.loading")).start(); try { const info = await collectSystemInfo(cliVersion); diff --git a/packages/devkit/src/commands/init.ts b/packages/devkit/src/commands/init.ts index 89e602b..c4c1f1d 100644 --- a/packages/devkit/src/commands/init.ts +++ b/packages/devkit/src/commands/init.ts @@ -9,8 +9,7 @@ import { ConfigError } from "#utils/errors/base.js"; import fs from "#utils/fs/file.js"; import path from "path"; import os from "os"; -import ora, { type Ora } from "ora"; -import chalk from "chalk"; +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"; @@ -21,7 +20,7 @@ import { getPackageManager } from "#utils/package-manager/index.js"; async function promptForStandardOverwrite(filePath: string): Promise { const response = await select({ - message: chalk.yellow( + message: logger.colors.yellow( t("config.init.confirm_overwrite", { path: filePath }), ), choices: [ @@ -46,7 +45,7 @@ async function getUpdatedConfig(): Promise { }; } -async function handleGlobalInit(spinner: Ora): Promise { +async function handleGlobalInit(spinner: TSpinner): Promise { let finalPath = await findGlobalConfigFile(); if (!finalPath) { finalPath = path.join(os.homedir(), CONFIG_FILE_NAMES[0]); @@ -59,16 +58,16 @@ async function handleGlobalInit(spinner: Ora): Promise { if (shouldOverwrite) { const configToSave = await getUpdatedConfig(); spinner.start( - chalk.cyan(t("config.init.initializing", { path: finalPath })), + logger.colors.cyan(t("config.init.initializing", { path: finalPath })), ); await saveConfig(configToSave, finalPath); - spinner.succeed(chalk.green(t("config.init.success"))); + spinner.succeed(logger.colors.green(t("config.init.success"))); } else { - spinner.info(chalk.yellow(t("config.init.aborted"))); + spinner.info(logger.colors.yellow(t("config.init.aborted"))); } } -async function handleLocalInit(spinner: Ora): Promise { +async function handleLocalInit(spinner: TSpinner): Promise { const allConfigFiles = [...CONFIG_FILE_NAMES]; const currentPath = process.cwd(); const monorepoRoot = await findMonorepoRoot(); @@ -94,12 +93,12 @@ async function handleLocalInit(spinner: Ora): Promise { if (shouldOverwrite && finalPath) { const configToSave = await getUpdatedConfig(); spinner.start( - chalk.cyan(t("config.init.initializing", { path: finalPath })), + logger.colors.cyan(t("config.init.initializing", { path: finalPath })), ); await saveConfig(configToSave, finalPath); - spinner.succeed(chalk.green(t("config.init.success"))); + spinner.succeed(logger.colors.green(t("config.init.success"))); } else { - spinner.info(chalk.yellow(t("config.init.aborted"))); + spinner.info(logger.colors.yellow(t("config.init.aborted"))); } } @@ -114,7 +113,7 @@ export function setupInitCommand(options: SetupCommandOptions): void { .action(async (cmdOptions: { local: boolean; global: boolean }) => { const isLocal: boolean = cmdOptions.local; const isGlobal: boolean = cmdOptions.global; - const spinner: Ora = ora(); + const spinner: TSpinner = logger.spinner(); try { if (isLocal && isGlobal) { diff --git a/packages/devkit/src/commands/list.ts b/packages/devkit/src/commands/list.ts index c51c576..13120d4 100644 --- a/packages/devkit/src/commands/list.ts +++ b/packages/devkit/src/commands/list.ts @@ -1,8 +1,7 @@ import { t } from "#utils/i18n/translator.js"; import { handleErrorAndExit } from "#utils/errors/handler.js"; import { DevkitError } from "#utils/errors/base.js"; -import ora from "ora"; -import chalk from "chalk"; +import { logger, type TSpinner } from "#utils/logger.js"; import { readAndMergeConfigs } from "#core/config/loader.js"; import { printTemplates } from "#core/template/printer.js"; import type { SetupCommandOptions } from "#utils/schema/schema.js"; @@ -64,7 +63,9 @@ export function setupListCommand(options: SetupCommandOptions): void { .action(async (language, cmdOptions: ListCommandOptions) => { const { global: isGlobal, all: showAll, filter } = cmdOptions; - const spinner = ora(t("list.templates.loading")).start(); + const spinner: TSpinner = logger + .spinner(t("list.templates.loading")) + .start(); try { if (isGlobal && showAll) { @@ -87,7 +88,7 @@ export function setupListCommand(options: SetupCommandOptions): void { const startMessageKey = getStartMessage(!!isGlobal, !!showAll, source); if (startMessageKey.startsWith("list.templates.no_")) { - spinner.succeed(chalk.yellow(t(startMessageKey))); + spinner.succeed(logger.colors.yellow(t(startMessageKey))); return; } @@ -95,7 +96,7 @@ export function setupListCommand(options: SetupCommandOptions): void { if (Object.keys(config?.templates || {}).length === 0) { spinner.succeed( - chalk.yellow( + logger.colors.yellow( t("list.templates.not_found", { template: "", }), @@ -104,7 +105,7 @@ export function setupListCommand(options: SetupCommandOptions): void { return; } - console.log(chalk.bold("\n" + t("list.templates.header"))); + logger.log(logger.colors.bold("\n" + t("list.templates.header"))); Object.entries(config?.templates || {}).forEach( ([lang, langTemplates]) => { diff --git a/packages/devkit/src/commands/new.ts b/packages/devkit/src/commands/new.ts index e33c7bd..bf79da2 100644 --- a/packages/devkit/src/commands/new.ts +++ b/packages/devkit/src/commands/new.ts @@ -2,8 +2,8 @@ import { type SetupCommandOptions } from "#utils/schema/schema.js"; import { t } from "#utils/i18n/translator.js"; import { DevkitError } from "#utils/errors/base.js"; import { handleErrorAndExit } from "#utils/errors/handler.js"; -import ora from "ora"; -import chalk from "chalk"; +import { logger, type TSpinner } from "#utils/logger.js"; +import { validateProgrammingLanguage } from "#utils/validations/config.js"; const getScaffolder = async (language: string) => { if (language === "javascript") { @@ -27,16 +27,21 @@ export function setupNewCommand(options: SetupCommandOptions) { ) .action(async (language, projectName, cmdOptions) => { const { template } = cmdOptions; - const scaffoldSpinner = ora( - chalk.cyan( - t("new.project.scaffolding", { - projectName, - template: template, - }), - ), - ).start(); + + const scaffoldSpinner: TSpinner = logger + .spinner( + logger.colors.cyan( + t("new.project.scaffolding", { + projectName, + template: template, + }), + ), + ) + .start(); try { + validateProgrammingLanguage(language); + const languageTemplates = config.templates[language]; if (!languageTemplates) { throw new DevkitError( @@ -56,6 +61,7 @@ export function setupNewCommand(options: SetupCommandOptions) { const scaffoldAppropriateProject = await getScaffolder(language); scaffoldSpinner.stop(); + await scaffoldAppropriateProject({ projectName, templateConfig, @@ -69,7 +75,7 @@ export function setupNewCommand(options: SetupCommandOptions) { }); scaffoldSpinner.succeed( - chalk.green(t("new.project.success", { projectName })), + logger.colors.green(t("new.project.success", { projectName })), ); } catch (error) { handleErrorAndExit(error, scaffoldSpinner); diff --git a/packages/devkit/src/core/cache/index.ts b/packages/devkit/src/core/cache/index.ts index b04e2d6..250d993 100644 --- a/packages/devkit/src/core/cache/index.ts +++ b/packages/devkit/src/core/cache/index.ts @@ -1,9 +1,9 @@ -import chalk from "chalk"; -import type { Ora } from "ora"; import os from "os"; import path from "path"; import type { CacheStrategy } from "#utils/schema/schema.js"; import { t } from "#utils/i18n/translator.js"; +import { logger } from "#utils/logger.js"; +import type { Ora } from "ora"; import { cloneRepo, pullRepo, isRepoFresh, getRepoNameFromUrl } from "./git.js"; import { doesRepoExist } from "./fs-manager.js"; import { updateJavascriptProjectName } from "../template/update-project-name.js"; @@ -31,34 +31,50 @@ export async function getTemplateFromCache( const repoName = getRepoNameFromUrl(url); const repoPath = path.join(CACHE_DIR, repoName); - spinner.text = chalk.bold.cyan(`Checking cache for: ${repoName}...`); + spinner.text = logger.colors.cyan( + logger.colors.bold(`Checking cache for: ${repoName}...`), + ); spinner.start(); const repoExists = await doesRepoExist(repoPath); if (!repoExists) { - spinner.text = chalk.italic.cyan(t("cache.clone.start", { url })); + spinner.text = logger.colors.cyan( + logger.colors.italic(t("cache.clone.start", { url })), + ); await cloneRepo(url, repoPath); - spinner.succeed(chalk.bold.green(t("cache.clone.success"))); + spinner.succeed( + logger.colors.green(logger.colors.bold(t("cache.clone.success"))), + ); } else { const fresh = await isRepoFresh(repoPath, strategy); if (!fresh) { - spinner.text = chalk.cyan(t("cache.refresh.start")); + spinner.text = logger.colors.cyan(t("cache.refresh.start")); await pullRepo(repoPath); - spinner.succeed(chalk.green(t("cache.refresh.success"))); + spinner.succeed(logger.colors.green(t("cache.refresh.success"))); } else { - spinner.info(chalk.yellow(t("cache.use.info", { repoName }))); + spinner.info(logger.colors.yellow(t("cache.use.info", { repoName }))); } } - spinner.text = chalk.cyan(t("cache.copy.start")); + spinner.text = logger.colors.cyan(t("cache.copy.start")); await copyJavascriptTemplate(repoPath, destination); await updateJavascriptProjectName(destination, projectName); - spinner.succeed(chalk.bold.green(t("cache.copy.success"))); + spinner.succeed( + logger.colors.green(logger.colors.bold(t("cache.copy.success"))), + ); } catch (error: any) { - spinner.fail(chalk.red(t("cache.copy.fail"))); + spinner.fail(logger.colors.red(t("cache.copy.fail"))); + + const message = t("cache.copy.fail"); + if (error instanceof Error) { + logger.error(`${message}: ${error.message}`, "CACHE"); + } else { + logger.error(message, "CACHE"); + } + throw error; } } diff --git a/packages/devkit/src/core/info/project.ts b/packages/devkit/src/core/info/project.ts index 5baa780..86786f8 100644 --- a/packages/devkit/src/core/info/project.ts +++ b/packages/devkit/src/core/info/project.ts @@ -2,7 +2,7 @@ import fs from "#utils/fs/file.js"; import path from "path"; import { findPackageRoot } from "#utils/fs/finder.js"; import { t } from "#utils/i18n/translator.js"; -import chalk from "chalk"; +import { logger } from "#utils/logger.js"; import { FILE_NAMES } from "#utils/schema/schema.js"; export async function getProjectVersion(): Promise { @@ -17,7 +17,18 @@ export async function getProjectVersion(): Promise { return packageJson.version; } catch (error) { - console.error(chalk.red(t("error.version.read_fail")), error); + const errorMessage = t("error.version.read_fail"); + + if (error instanceof Error) { + logger.error(`${errorMessage}: ${error.message}`, "INFO"); + } else { + logger.error(errorMessage, "INFO"); + } + + if (error instanceof Error && error.stack) { + logger.dimmed(error.stack); + } + return "0.0.0"; } } diff --git a/packages/devkit/src/core/prompts/prompts.ts b/packages/devkit/src/core/prompts/prompts.ts index 780d3e4..be6456e 100644 --- a/packages/devkit/src/core/prompts/prompts.ts +++ b/packages/devkit/src/core/prompts/prompts.ts @@ -8,7 +8,7 @@ import { VALID_PACKAGE_MANAGERS, } from "#utils/schema/schema.js"; import { t } from "#utils/i18n/translator.js"; -import chalk from "chalk"; +import { logger } from "#utils/logger.js"; /** * Prompts the user to select a programming language. @@ -20,7 +20,7 @@ export async function promptForLanguage( defaultValue?: SupportedProgrammingLanguageValues, ): Promise { const message = `${t("cli.add_template.prompts.language")} ${ - required ? chalk.red("(required)") : chalk.gray("(optional)") + required ? logger.colors.red("(required)") : logger.colors.dim("(optional)") }`; const choices = Object.values(ProgrammingLanguage).map((lang) => ({ name: lang, @@ -44,7 +44,7 @@ export async function promptForPackageManager( ): Promise { const message = `${t( "cli.add_template.prompts.package_manager", - )} ${required ? chalk.red("(required)") : chalk.gray("(optional)")}`; + )} ${required ? logger.colors.red("(required)") : logger.colors.dim("(optional)")}`; return (await select({ message, choices: [ @@ -66,7 +66,7 @@ export async function promptForCacheStrategy( ): Promise { const message = `${t( "cli.add_template.prompts.cache_strategy", - )} ${required ? chalk.red("(required)") : chalk.gray("(optional)")}`; + )} ${required ? logger.colors.red("(required)") : logger.colors.dim("(optional)")}`; return (await select({ message, choices: [ diff --git a/packages/devkit/src/core/template/printer.ts b/packages/devkit/src/core/template/printer.ts index 3b6cbb0..46d5c98 100644 --- a/packages/devkit/src/core/template/printer.ts +++ b/packages/devkit/src/core/template/printer.ts @@ -1,6 +1,6 @@ -import chalk from "chalk"; import { t } from "#utils/i18n/translator.js"; import { type CliConfig, type LanguageConfig } from "#utils/schema/schema.js"; +import { logger } from "#utils/logger.js"; type TemplateMap = LanguageConfig["templates"]; @@ -9,49 +9,59 @@ export function printTemplates( templates: TemplateMap, filter?: string, ): void { - const filteredTemplates = Object.entries(templates).filter( - ([templateName, templateConfig]) => { - if (!filter) return true; - const name = templateName.toLowerCase(); - const alias = templateConfig?.alias?.toLowerCase() ?? ""; - return ( - name.includes(filter.toLowerCase()) || - alias.includes(filter.toLowerCase()) - ); - }, - ); + let filteredTemplates = Object.entries(templates); + if (filter) { + filteredTemplates = filteredTemplates.filter( + ([templateName, templateConfig]) => { + const name = templateName.toLowerCase(); + const alias = templateConfig?.alias?.toLowerCase() ?? ""; + return ( + name.includes(filter.toLowerCase()) || + alias.includes(filter.toLowerCase()) + ); + }, + ); + } if (filteredTemplates.length === 0) return; - console.log(`\n${chalk.blue.bold(language.toUpperCase())}:`); + logger.log(`\n${logger.colors.boldBlue(language.toUpperCase())}:`); filteredTemplates.forEach(([templateName, templateConfig]) => { + const dim = logger.colors.dim; + const cyanDim = logger.colors.cyanDim; + const alias = templateConfig?.alias - ? chalk.cyan.dim( + ? cyanDim( `(${t("cli.add_template.options.alias")}: ${templateConfig.alias})`, ) : ""; + const description = templateConfig?.description - ? `\n ${chalk.dim(t("cli.add_template.options.description"))}: ${templateConfig.description}` + ? `\n ${dim(t("cli.add_template.options.description"))}: ${templateConfig.description}` : ""; const location = templateConfig?.location - ? `\n ${chalk.dim("Location:")} ${templateConfig.location}` + ? `\n ${dim("Location")}: ${templateConfig.location}` : ""; const cacheStrategy = templateConfig?.cacheStrategy - ? `\n ${chalk.dim(t("cli.add_template.options.cache"))}: ${templateConfig.cacheStrategy}` + ? `\n ${dim(t("cli.add_template.options.cache"))}: ${templateConfig.cacheStrategy}` : ""; const packageManager = templateConfig?.packageManager - ? `\n ${chalk.dim(t("cli.add_template.options.package_manager"))}: ${templateConfig.packageManager}` + ? `\n ${dim(t("cli.add_template.options.package_manager"))}: ${templateConfig.packageManager}` : ""; - console.log( - ` - ${chalk.green(templateName)} ${alias}${description}${location}${cacheStrategy}${packageManager}\n`, + const coloredName = logger.colors.green(templateName); + + logger.log( + ` - ${coloredName} ${alias}${description}${location}${cacheStrategy}${packageManager}\n`, ); }); } export function printSettings(settings: CliConfig["settings"]): void { Object.entries(settings).forEach(([key, value]) => { - console.log(`${chalk.bold.yellow(` ${key}:`)} ${chalk.cyan(value)}`); + const keyString = logger.colors.yellowBold(` ${key}:`); + const valueString = logger.colors.cyan(value); + logger.log(`${keyString} ${valueString}`); }); } diff --git a/packages/devkit/src/core/template/update-project-name.ts b/packages/devkit/src/core/template/update-project-name.ts index cbc655d..6d719ce 100644 --- a/packages/devkit/src/core/template/update-project-name.ts +++ b/packages/devkit/src/core/template/update-project-name.ts @@ -2,7 +2,7 @@ import fs from "#utils/fs/file.js"; import path from "path"; import { FILE_NAMES } from "#utils/schema/schema.js"; import { t } from "#utils/i18n/translator.js"; -import chalk from "chalk"; +import { logger } from "#utils/logger.js"; export async function updateJavascriptProjectName( projectPath: string, @@ -11,7 +11,7 @@ export async function updateJavascriptProjectName( const packageJsonPath = path.join(projectPath, FILE_NAMES.packageJson); if (!fs.existsSync(packageJsonPath)) { - console.error(chalk.redBright(t("error.package.file_not_found"))); + logger.error(t("error.package.file_not_found"), "TEMPL"); return; } @@ -21,9 +21,16 @@ export async function updateJavascriptProjectName( await fs.writeJson(packageJsonPath, packageJson); } catch (error) { - console.error( - chalk.red(t("error.package.failed_to_update_project_name")), - error, - ); + const errorMessage = t("error.package.failed_to_update_project_name"); + + if (error instanceof Error) { + logger.error(`${errorMessage}: ${error.message}`, "TEMPL"); + } else { + logger.error(errorMessage, "TEMPL"); + } + + if (error instanceof Error && error.stack) { + logger.dimmed(error.stack); + } } } diff --git a/packages/devkit/src/scaffolding/javascript.ts b/packages/devkit/src/scaffolding/javascript.ts index ad7e3ba..8298ea4 100644 --- a/packages/devkit/src/scaffolding/javascript.ts +++ b/packages/devkit/src/scaffolding/javascript.ts @@ -1,17 +1,16 @@ -import ora from "ora"; -import chalk from "chalk"; import { t } from "#utils/i18n/translator.js"; import { getTemplateFromCache } from "#core/cache/index.js"; import { runCliCommand } from "#scaffolding/cli-runner.js"; import { copyLocalTemplate } from "#scaffolding/local-template.js"; import { installDependencies } from "#scaffolding/dependencies.js"; +import { logger } from "#utils/logger.js"; import type { TemplateConfig, CacheStrategy, SupportedJavascriptPackageManager, } from "#utils/schema/schema.js"; -interface ScaffoldJavascriptProjectOptions { +export interface ScaffoldJavascriptProjectOptions { projectName: string; templateConfig: TemplateConfig; packageManager: SupportedJavascriptPackageManager; @@ -23,14 +22,16 @@ export async function scaffoldProject( ): Promise { const { projectName, templateConfig, packageManager, cacheStrategy } = options; - const spinner = ora(); + const spinner = logger.spinner(t("scaffolding.run.start")); let isOfficialCli = false; try { if (templateConfig.location.includes("{pm}")) { isOfficialCli = true; - spinner.text = chalk.bold.cyan( - t("scaffolding.run.start", { command: templateConfig.location }), + spinner.text = logger.colors.cyan( + logger.colors.bold( + t("scaffolding.run.start", { command: templateConfig.location }), + ), ); spinner.stop(); await runCliCommand({ @@ -50,38 +51,55 @@ export async function scaffoldProject( strategy: cacheStrategy, }); } else { - spinner.text = chalk.cyan(t("scaffolding.copy.start")); + spinner.text = logger.colors.cyan(t("scaffolding.copy.start")); spinner.start(); await copyLocalTemplate({ sourcePath: templateConfig.location, projectName, spinner, }); - spinner.succeed(chalk.green(t("scaffolding.copy.success"))); + spinner.succeed(logger.colors.green(t("scaffolding.copy.success"))); } if (!isOfficialCli) { - spinner.text = chalk.bold.cyan( - t("scaffolding.install.start", { pm: packageManager }), - "\n", + spinner.text = logger.colors.cyan( + logger.colors.bold( + `${t("scaffolding.install.start", { pm: packageManager })}\n`, + ), ); spinner.stop(); await installDependencies({ projectName, packageManager, spinner }); } if (!isOfficialCli) { - console.log(chalk.bold.green(t("scaffolding.complete.success"))); - console.log( - chalk.italic.bold.white(t("scaffolding.complete.next_steps")), + logger.log( + logger.colors.green( + logger.colors.bold(t("scaffolding.complete.success")), + ), + ); + logger.log( + logger.colors.white( + logger.colors.bold( + logger.colors.italic(t("scaffolding.complete.next_steps")), + ), + ), ); - console.log( - chalk.bold.green( - ` cd ${projectName}\n git init && git add -A && git commit -m "Initial commit"\n`, + logger.log( + logger.colors.green( + logger.colors.bold( + ` cd ${projectName}\n git init && git add -A && git commit -m "Initial commit"\n`, + ), ), ); } - } catch (err) { - spinner.fail(chalk.red(t("error.scaffolding.unexpected"))); - console.error(err); + } catch (err: any) { + spinner.fail(logger.colors.red(t("error.scaffolding.unexpected"))); + + const message = t("error.scaffolding.unexpected"); + if (err instanceof Error) { + logger.error(`${message}: ${err.message}`, "UNKNOWN"); + } else { + logger.error(message, "UNKNOWN"); + } } } diff --git a/packages/devkit/src/utils/errors/handler.ts b/packages/devkit/src/utils/errors/handler.ts index f178ffa..375b66f 100644 --- a/packages/devkit/src/utils/errors/handler.ts +++ b/packages/devkit/src/utils/errors/handler.ts @@ -1,25 +1,32 @@ -import { logger } from "#utils/logger.js"; -import { ConfigError, GitError } from "./base.js"; +import { logger, type TSpinner } from "../logger.js"; +import { ConfigError, GitError, DevkitError } from "./base.js"; import { t } from "../i18n/translator.js"; -import type { Ora } from "ora"; -export function handleErrorAndExit(error: unknown, spinner?: Ora): void { +export function handleErrorAndExit(error: unknown, spinner?: TSpinner): void { spinner?.stop(); if (error instanceof ConfigError) { - logger.error(`${t("error.config.generic")}: ${error.message}`); + logger.error(`${t("error.config.generic")}: ${error.message}`, "CONFIG"); + if (error.filePath) { - logger.error(`File path: ${error.filePath}`); + logger.dimmed(`File path: ${error.filePath}`); } } else if (error instanceof GitError) { - logger.error(`${t("error.git.generic")}: ${error.message}`); + logger.error(`${t("error.git.generic")}: ${error.message}`, "GIT"); if (error.url) { - logger.error(`Repository URL: ${error.url}`); + logger.dimmed(`Repository URL: ${error.url}`); } + } else if (error instanceof DevkitError) { + logger.error(`${t("error.devkit_specific")}: ${error.message}`, "DEV"); } else if (error instanceof Error) { - logger.error(`${t("error.unexpected")}: ${error.message}`); + logger.error(`${t("error.unexpected")}: ${error.message}`, "ERR"); } else { - logger.error(t("error.unknown")); + logger.error(t("error.unknown"), "UNKNOWN"); + } + + const cause = error instanceof Error ? error.cause : undefined; + if (cause instanceof Error) { + logger.dimmed(`Cause: ${cause.message}`); } process.exit(1); diff --git a/packages/devkit/src/utils/logger.ts b/packages/devkit/src/utils/logger.ts index c7dd799..4e00639 100644 --- a/packages/devkit/src/utils/logger.ts +++ b/packages/devkit/src/utils/logger.ts @@ -1,32 +1,77 @@ import chalk from "chalk"; import ora, { type Ora } from "ora"; +function getTimestamp(): string { + const date = new Date(); + const time = date.toTimeString().split(" ")[0]; + return chalk.dim(`[${time}]`); +} + +function formatError(message: string, errorType: ErrorType): string { + const timestamp = getTimestamp(); + const typeTag = chalk.bold.red(`[${errorType}]`); + const coloredMessage = chalk.redBright(`❌ ${message}`); + + return `${timestamp} ${typeTag} ${coloredMessage}`; +} + +export type ErrorType = + | "UNKNOWN" + | "WARNING" + | "INFO" + | "DEV" + | "GIT" + | "ERR" + | "CONFIG" + | "TEMPL" + | "CACHE"; +export type TSpinner = Ora; + export const logger = { info(message: string) { console.log(chalk.blue(message)); }, success(message: string) { - console.log(chalk.green(`✔ ${message}`)); + console.log(chalk.green(`\n✔ ${message}`)); }, warning(message: string) { console.log(chalk.yellow(`⚠️ ${message}`)); }, - error(message: string) { - console.error(chalk.red(`\n❌ ${message}`)); + error(message: string, errorType: ErrorType = "UNKNOWN") { + console.error(formatError(message, errorType)); }, log(message: string) { console.log(message); }, - spinner(text: string): Ora { + spinner(text?: string): TSpinner { return ora(text); }, dimmed(message: string) { - console.log(chalk.dim(message)); + console.log(chalk.dim(message.trim())); + }, + + colors: { + white: (message: string) => chalk.white(message), + blue: (message: string) => chalk.blue(message), + green: (message: string) => chalk.green(message), + yellow: (message: string) => chalk.yellow(message), + red: (message: string) => chalk.red(message), + cyan: (message: string) => chalk.cyan(message), + dim: (message: string) => chalk.dim(message), + bold: (message: string) => chalk.bold(message), + italic: (message: string) => chalk.italic(message), + boldBlue: (message: string) => chalk.bold.blue(message), + cyanDim: (message: string) => chalk.cyan.dim(message), + yellowBold: (message: string) => chalk.bold.yellow(message), + redBright: (message: string) => chalk.redBright(message), + greenBright: (message: string) => chalk.greenBright(message), + magenta: (message: string) => chalk.magenta(message), + magentaBright: (message: string) => chalk.magentaBright(message), }, }; diff --git a/packages/devkit/vitest.setup.ts b/packages/devkit/vitest.setup.ts index 7270eda..f038119 100644 --- a/packages/devkit/vitest.setup.ts +++ b/packages/devkit/vitest.setup.ts @@ -1,110 +1,124 @@ -import type { Ora } from "ora"; import { vi } from "vitest"; +import type { TSpinner } from "./src/utils/logger"; -const { mocktFn, mockLoadTranslations, mockProgram, mockSpinner } = vi.hoisted( - () => { - const mockSpinner = { - text: "", - start: vi.fn(() => mockSpinner), - succeed: vi.fn(), - warn: vi.fn(), - info: vi.fn(() => mockSpinner), - fail: vi.fn(), - stop: vi.fn(), - } as unknown as Ora; +const { + mocktFn, + mockLoadTranslations, + mockProgram, + mockSpinner, + mockExeca, + mockExecuteCommand, + mockLogger, +} = vi.hoisted(() => { + const mocktFn = vi.fn().mockImplementation( + (key: string, options?: Record) => + `${key}` + + (options + ? `- options ${Object.entries(options) + .map(([k, v]) => `${k}:${v}`) + .join(", ")}` + : ""), + ); - return { - mocktFn: vi.fn().mockImplementation( - (key: string, options?: Record) => - `${key}` + - (options - ? `- options ${Object.entries(options) - .map(([k, v]) => `${k}:${v}`) - .join(", ")}` - : ""), - ), - mockLoadTranslations: vi.fn(), - mockProgram: { - name: vi.fn(() => mockProgram), - alias: vi.fn(() => mockProgram), - description: vi.fn(() => mockProgram), - version: vi.fn(() => mockProgram), - helpOption: vi.fn(() => mockProgram), - command: vi.fn(() => mockProgram), - requiredOption: vi.fn(() => mockProgram), - option: vi.fn(() => mockProgram), - parse: vi.fn(() => mockProgram), - opts: vi.fn(), - parseOptions: vi.fn(), - }, - mockSpinner, - }; - }, -); + const mockLoadTranslations = vi.fn(); -const mockChalk = vi.hoisted(() => { - const handler = { - get: (target: any, prop: any) => { - return typeof target[prop] !== "undefined" - ? target[prop] - : (...args: any[]) => target(...args); - }, - apply: (_: any, __: any, args: any[]) => { - return `${args.join("_")}`; - }, + const mockSpinner = { + text: "", + start: vi.fn(() => { + return mockSpinner; + }), + succeed: vi.fn(), + warn: vi.fn(), + info: vi.fn(() => { + return mockSpinner; + }), + fail: vi.fn(), + stop: vi.fn(), + } as unknown as TSpinner; + + const mockProgram = { + name: vi.fn(() => { + return mockProgram; + }), + alias: vi.fn(() => { + return mockProgram; + }), + description: vi.fn(() => { + return mockProgram; + }), + version: vi.fn(() => { + return mockProgram; + }), + helpOption: vi.fn(() => { + return mockProgram; + }), + command: vi.fn(() => { + return mockProgram; + }), + requiredOption: vi.fn(() => { + return mockProgram; + }), + option: vi.fn(() => { + return mockProgram; + }), + parse: vi.fn(() => { + return mockProgram; + }), + opts: vi.fn(), + parseOptions: vi.fn(), }; - const baseMock = (...args: any[]) => `mocked_chalk_string_${args.join("_")}`; + const mockExeca = vi.fn(); + const mockExecuteCommand = vi.fn(); - const chainableMock = new Proxy(baseMock, handler); - const methods = [ - "bold", - "blue", - "cyan", - "green", - "gray", - "yellow", - "magenta", - "red", - "dim", - "italic", - "redBright", - "white", - ]; - methods.forEach((method) => { - (chainableMock as any)[method] = new Proxy(baseMock, handler); - }); + const mockLogger = { + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + log: vi.fn(), + warning: vi.fn(), + dimmed: vi.fn(), + spinner: vi.fn(() => mockSpinner), + colors: { + yellow: vi.fn((text: string) => text), + yellowBold: vi.fn((text: string) => text), + green: vi.fn((text: string) => text), + cyan: vi.fn((text: string) => text), + red: vi.fn((text: string) => text), + magenta: vi.fn((text: string) => text), + bold: vi.fn((text: string) => text), + italic: vi.fn((text: string) => text), + blue: vi.fn((text: string) => text), + dim: vi.fn((text: string) => text), + cyanDim: vi.fn((text: string) => text), + redBright: vi.fn((text: string) => text), + boldBlue: vi.fn((text: string) => text), + }, + }; - return chainableMock; + return { + mocktFn, + mockLoadTranslations, + mockProgram, + mockSpinner, + mockExeca, + mockExecuteCommand, + mockLogger, + }; }); vi.mock("commander", () => ({ Command: vi.fn(() => mockProgram) })); -vi.mock("ora", () => ({ default: () => mockSpinner })); -vi.mock("chalk", () => ({ default: mockChalk })); vi.mock("#utils/i18n/translator.js", () => ({ loadTranslations: mockLoadTranslations, t: mocktFn, })); -const { mockExeca, mockExecuteCommand } = vi.hoisted(() => ({ - mockExeca: vi.fn(), - mockExecuteCommand: vi.fn(), -})); - vi.mock("#utils/shell.js", () => ({ execute: mockExeca, executeCommand: mockExecuteCommand, })); -const { mockLogger } = vi.hoisted(() => ({ - mockLogger: { - error: vi.fn(), - info: vi.fn(), - success: vi.fn(), - }, -})); - vi.mock("#utils/logger.js", () => ({ logger: mockLogger, })); @@ -112,7 +126,6 @@ vi.mock("#utils/logger.js", () => ({ export { mockProgram, mockSpinner, - mockChalk, mockLoadTranslations, mocktFn, mockExeca,