From b30982e882e4b7d515b1b17ca28d8b6a62a45244 Mon Sep 17 00:00:00 2001 From: IT-WIBRC Date: Wed, 1 Oct 2025 03:52:55 +0100 Subject: [PATCH] feat: add --mode option to and strengthen logger table --- .changeset/late-sloths-flash.md | 2 +- .changeset/tiny-dogs-do.md | 5 + packages/devkit/README.md | 6 +- packages/devkit/TODO.md | 30 +-- .../__tests__/integrations/list.spec.ts | 81 +++++++ .../units/commands/config/list.spec.ts | 37 ++- .../__tests__/units/commands/list.spec.ts | 214 ++++++++++++------ .../units/core/template/printer.spec.ts | 151 ++++++++++-- .../__tests__/units/utils/logger.spec.ts | 83 +++++-- .../units/utils/schema/schema.spec.ts | 4 +- .../units/utils/validations/config.spec.ts | 25 ++ packages/devkit/locales/en.json | 4 + packages/devkit/locales/fr.json | 4 + packages/devkit/src/commands/config/list.ts | 5 +- packages/devkit/src/commands/list.ts | 40 +++- packages/devkit/src/core/template/printer.ts | 89 +++++++- packages/devkit/src/utils/logger.ts | 57 +++++ packages/devkit/src/utils/schema/schema.ts | 8 +- .../devkit/src/utils/validations/config.ts | 18 ++ packages/devkit/vitest.setup.ts | 1 + 20 files changed, 701 insertions(+), 163 deletions(-) create mode 100644 .changeset/tiny-dogs-do.md diff --git a/.changeset/late-sloths-flash.md b/.changeset/late-sloths-flash.md index fbfc442..adf2956 100644 --- a/.changeset/late-sloths-flash.md +++ b/.changeset/late-sloths-flash.md @@ -1,5 +1,5 @@ --- -"scaffolder-toolkit": minor +"scaffolder-toolkit": major --- feat: Centralize configuration management under new 'dk config' command and enhance 'dk list' diff --git a/.changeset/tiny-dogs-do.md b/.changeset/tiny-dogs-do.md new file mode 100644 index 0000000..e5fb4ae --- /dev/null +++ b/.changeset/tiny-dogs-do.md @@ -0,0 +1,5 @@ +--- +"scaffolder-toolkit": minor +--- + +feat: Add --mode option to `dk list` and strengthen logger table diff --git a/packages/devkit/README.md b/packages/devkit/README.md index 07f90db..511f6fd 100644 --- a/packages/devkit/README.md +++ b/packages/devkit/README.md @@ -110,7 +110,7 @@ Scaffolder comes with a set of pre-configured templates for popular frameworks a > **Note:** All templates currently support Node.js projects and must be configured under the `javascript` key. | Template Name | Description | Alias | -| -------------- | ------------------------------------------------------ | ------ | +| :------------- | :----------------------------------------------------- | :----- | | `vue` | An official Vue.js project. | | | `nuxt` | An official Nuxt.js project. | `nx` | | `nest` | An official Nest.js project. | | @@ -180,6 +180,7 @@ The `dk list` command now uses the following options to control which templates - **`--global`**: Only list templates from the global configuration file (`~/.devkitrc`). - **`--all`**: List templates from both the local and global configurations, merging them into a single list. - **`--filter `**: Filter templates by name or alias substring. +- **`--mode `**: Sets the display mode for the template list. Options are **`tree`** (default, detailed view) or **`table`** (compact, column-based view). #### Examples @@ -203,6 +204,9 @@ dk list javascript --filter react # List javascript templates and filter by name starting or containing dk list javascript --filter r + +# List templates in a compact table format +dk list --mode table ``` --- diff --git a/packages/devkit/TODO.md b/packages/devkit/TODO.md index a5078c9..a1519b9 100644 --- a/packages/devkit/TODO.md +++ b/packages/devkit/TODO.md @@ -8,7 +8,7 @@ This document tracks all planned and completed tasks for the Dev Kit project. #### Core CLI & Configuration -- **Improved `add-template` Command:** Added an interactive, guided flow that uses command-line options to pre-fill prompts. This enhancement is now marked as complete. +- **Improved `add-template` Command:** Added an interactive, guided flow that uses command-line options to pre-fill prompts. This enhancement is now marked as complete. (Deactivate for now to focus on the automate part) - **Enhanced `list` Command:** The command now includes a filter option and has improved output for better readability. - **Auto-Detect Package Manager:** The CLI now automatically detects the user's default package manager at initialization and saves it to the configuration file. - **Refactor `new` Command:** The command now accepts language and project name as arguments with a `--template` option. @@ -47,29 +47,26 @@ This document tracks all planned and completed tasks for the Dev Kit project. #### Core CLI & Configuration -- [ ] **CLI Self-Update**: Implement a command to allow users to update the CLI itself. `dk upgrade` - [x] `dk info`: A command to display system and environment information that could be useful for debugging issues. - [x] change the `config` alias from `cf` to `conf` - [x] **Unified `config` Command**: Complete the refactoring of all configuration-related commands into the new `git`-like pattern under `dk config`. This includes implementing: - **Core Operations**: `dk config [value]` for set and get. - **Subcommands**: `dk config add`, `dk config update`, and `dk config remove` to manage templates. - **Listing**: `dk config --list` with `--all` and `--global` flags. -- [ ] **Enhance `list` Command**: Add support for **different display modes** (e.g., table or tree structure). Also, add options to **filter by property** (e.g., `packageManager`) -- [ ] 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, 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. -- [ ] **Skip Confirmation**: Add a global `-y` or `--yes` option to skip confirmation prompts in commands like `dk init`. -- [ ] **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 - [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 - [x] Better json structure for languages translation +- [x] **Enhance `list` Command**: Add support for **different display modes** (e.g., table or tree structure). `tree` as default +- [ ] **Enhance `list` Command**: Add options to **filter by properties** (e.g., `packageManager`, `alias`, etc.). +- [ ] **Enhance `list` Command**: Add flag to also see default config `--with-defaults`. +- [ ] Add wildcard support for template name in the `dk config update` and `dk config remove` commands. +- [ ] ** 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 a configuration validation step when initializing or updating the config file to ensure all required fields are present and correctly formatted. +- [ ] **Dynamic Help Text**: Programmatically generate help text for options with constrained values (e.g., `--cache-strategy`) to ensure it's always up to date. +- [ ] **Skip Confirmation**: Add a global `-y` or `--yes` option to skip confirmation prompts in commands like `dk init`. +- [ ] **CLI Self-Update**: Implement a command to allow users to update the CLI itself. `dk upgrade` +- [ ] **Testing**: Stabilize the integration test of the `new` command #### Multi-Language Support @@ -81,6 +78,11 @@ This document tracks all planned and completed tasks for the Dev Kit project. - [ ] **Security Documentation**: Add a new section to the documentation outlining the security measures taken to prevent supply chain attacks. - [x] **Package Updates**: Ensure the root `package.json` includes all new packages and that any corrupted packages are replaced. +### Debating + +- [ ] **Color Configuration**: Add a feature to allow users to configure the colors for templates. +- [ ] Use the interactive approach for the `dk config add` command (code already there) + --- # **New** diff --git a/packages/devkit/__tests__/integrations/list.spec.ts b/packages/devkit/__tests__/integrations/list.spec.ts index d7269f3..8724a23 100644 --- a/packages/devkit/__tests__/integrations/list.spec.ts +++ b/packages/devkit/__tests__/integrations/list.spec.ts @@ -278,4 +278,85 @@ describe("dk list", () => { "Using templates from both local and global configurations.", ); }); + + describe("dk list (`Table` mode)", () => { + it("should list templates from local config by default when it exists", async () => { + await fs.writeJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + localConfig, + ); + await fs.writeJson( + path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + globalConfig, + ); + + const { all, exitCode } = await execa( + "bun", + [CLI_PATH, "list", "--mode", "table"], + { + all: true, + env: { HOME: globalConfigDir }, + }, + ); + + expect(exitCode).toBe(0); + expect(all).toContain("Using local configuration."); + expect(all).toContain("Available Templates:"); + expect(all).toContain("Javascript"); + expect(all).toContain("Node"); + expect(all).not.toContain("Python"); + }); + + it("should handle both local and global configs being empty", async () => { + await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), { + templates: {}, + }); + await fs.writeJson(path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), { + templates: {}, + }); + + const { all, exitCode } = await execa( + "bun", + [CLI_PATH, "list", "--all", "--mode", "table"], + { + all: true, + env: { HOME: globalConfigDir }, + }, + ); + + expect(exitCode).toBe(0); + expect(all).toContain("No templates found in the configuration file."); + expect(all).toContain( + "Using templates from both local and global configurations.", + ); + }); + + it("should filter templates by name when --filter is used", async () => { + await fs.writeJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + localConfig, + ); + await fs.writeJson( + path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + globalConfig, + ); + + const { all, exitCode } = await execa( + "bun", + [CLI_PATH, "list", "--filter", "vue", "--mode", "table"], + { + all: true, + env: { HOME: globalConfigDir }, + }, + ); + + expect(exitCode).toBe(0); + expect(all).toContain("Using local configuration."); + expect(all).toContain("Javascript"); + expect(all).toContain("vue-basic"); + expect(all).not.toContain("react-ts"); + expect(all).not.toContain("NODE"); + expect(all).not.toContain("PYTHON"); + }); + }); }); diff --git a/packages/devkit/__tests__/units/commands/config/list.spec.ts b/packages/devkit/__tests__/units/commands/config/list.spec.ts index 2573771..de1be40 100644 --- a/packages/devkit/__tests__/units/commands/config/list.spec.ts +++ b/packages/devkit/__tests__/units/commands/config/list.spec.ts @@ -129,14 +129,14 @@ describe("setupListCommand", () => { expect(consoleLogSpy).toHaveBeenCalledWith( mockLogger.colors.bold("\n" + mocktFn(TEMPLATES_HEADER)), ); - expect(mockPrintTemplates).toHaveBeenCalledWith( - "javascript", - sampleConfig.templates.javascript.templates, - ); - expect(mockPrintTemplates).toHaveBeenCalledWith( - "typescript", - sampleConfig.templates.typescript.templates, - ); + + expect(mockPrintTemplates).toHaveBeenCalledTimes(2); + expect(mockPrintTemplates).toHaveBeenCalledWith([ + ["javascript", sampleConfig.templates.javascript.templates], + ]); + expect(mockPrintTemplates).toHaveBeenCalledWith([ + ["typescript", sampleConfig.templates.typescript.templates], + ]); expect(mockSpinner.stop).toHaveBeenCalled(); }); @@ -156,10 +156,9 @@ describe("setupListCommand", () => { mocktFn(CONFIG_SOURCE_GLOBAL), ); expect(mockPrintSettings).toHaveBeenCalledWith(sampleConfig.settings); - expect(mockPrintTemplates).toHaveBeenCalledWith( - "javascript", - sampleConfig.templates.javascript.templates, - ); + expect(mockPrintTemplates).toHaveBeenCalledWith([ + ["javascript", sampleConfig.templates.javascript.templates], + ]); }); it("should display both local and global configs with --all flag", async () => { @@ -177,14 +176,12 @@ describe("setupListCommand", () => { mocktFn(CONFIG_SOURCE_MERGED), ); expect(mockPrintSettings).toHaveBeenCalledWith(sampleConfig.settings); - expect(mockPrintTemplates).toHaveBeenCalledWith( - "javascript", - sampleConfig.templates.javascript.templates, - ); - expect(mockPrintTemplates).toHaveBeenCalledWith( - "typescript", - sampleConfig.templates.typescript.templates, - ); + expect(mockPrintTemplates).toHaveBeenCalledWith([ + ["javascript", sampleConfig.templates.javascript.templates], + ]); + expect(mockPrintTemplates).toHaveBeenCalledWith([ + ["typescript", sampleConfig.templates.typescript.templates], + ]); }); it("should handle no templates found gracefully", async () => { diff --git a/packages/devkit/__tests__/units/commands/list.spec.ts b/packages/devkit/__tests__/units/commands/list.spec.ts index fdc56fd..d22196a 100644 --- a/packages/devkit/__tests__/units/commands/list.spec.ts +++ b/packages/devkit/__tests__/units/commands/list.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi, type Mock } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { setupListCommand } from "../../../src/commands/list"; import { DevkitError } from "../../../src/utils/errors/base"; import type { CliConfig } from "../../../src/utils/schema/schema"; @@ -40,6 +40,7 @@ const { mockReadAndMergeConfigs, mockPrintTemplates, mockValidateProgrammingLanguage, + mockValidateDisplayMode, mockHandleErrorAndExit, mockProgram, } = vi.hoisted(() => { @@ -48,6 +49,7 @@ const { mockPrintTemplates: vi.fn(), mockValidateProgrammingLanguage: vi.fn(), mockHandleErrorAndExit: vi.fn(), + mockValidateDisplayMode: vi.fn(), mockProgram: { command: vi.fn().mockReturnThis(), alias: vi.fn().mockReturnThis(), @@ -69,6 +71,7 @@ vi.mock("#core/template/printer.js", () => ({ vi.mock("#utils/validations/config.js", () => ({ validateProgrammingLanguage: mockValidateProgrammingLanguage, + validateDisplayMode: mockValidateDisplayMode, })); vi.mock("#utils/errors/handler.js", () => ({ @@ -145,33 +148,38 @@ describe("list command", () => { source: "merged", }); mockValidateProgrammingLanguage.mockReturnValueOnce(true); + mockValidateDisplayMode.mockReturnValueOnce(true); setupListCommand({ program: mockProgram }); - await actionFn("", { all: true }); + await actionFn("", { all: true, mode: "tree" }); expect(mockValidateProgrammingLanguage).not.toHaveBeenCalled(); expect(mockSpinner.start).toHaveBeenCalled(); expect(mockSpinner.stop).toHaveBeenCalledTimes(2); expect(mockSpinner.info).toHaveBeenCalledWith(USING_LOCAL_GLOBAL_KEY); expect(mockPrintTemplates).toHaveBeenCalledWith( - "javascript", - { - "javascript-node": { - description: "Node.js project template", - location: "/path/to/local/templates/javascript-node", - }, - }, - undefined, - ); - expect(mockPrintTemplates).toHaveBeenCalledWith( - "typescript", - { - "typescript-express": { - description: "Express.js project template with TypeScript", - location: "/path/to/global/templates/typescript-express", - }, - }, + [ + [ + "javascript", + { + "javascript-node": { + description: "Node.js project template", + location: "/path/to/local/templates/javascript-node", + }, + }, + ], + [ + "typescript", + { + "typescript-express": { + description: "Express.js project template with TypeScript", + location: "/path/to/global/templates/typescript-express", + }, + }, + ], + ], undefined, + "tree", ); }); @@ -183,21 +191,26 @@ describe("list command", () => { mockValidateProgrammingLanguage.mockReturnValueOnce(true); setupListCommand({ program: mockProgram }); - await actionFn("", { global: true }); + await actionFn("", { global: true, mode: "tree" }); expect(mockValidateProgrammingLanguage).not.toHaveBeenCalled(); expect(mockSpinner.start).toHaveBeenCalled(); expect(mockSpinner.stop).toHaveBeenCalledTimes(2); expect(mockSpinner.info).toHaveBeenCalledWith(USING_GLOBAL_KEY); expect(mockPrintTemplates).toHaveBeenCalledWith( - "typescript", - { - "typescript-express": { - description: "Express.js project template with TypeScript", - location: "/path/to/global/templates/typescript-express", - }, - }, + [ + [ + "typescript", + { + "typescript-express": { + description: "Express.js project template with TypeScript", + location: "/path/to/global/templates/typescript-express", + }, + }, + ], + ], undefined, + "tree", ); }); @@ -207,23 +220,29 @@ describe("list command", () => { source: "local", }); mockValidateProgrammingLanguage.mockReturnValueOnce(true); + mockValidateDisplayMode.mockReturnValueOnce(true); setupListCommand({ program: mockProgram, config: sampleLocalConfig }); - await actionFn("", {}); + await actionFn("", { mode: "table" }); expect(mockValidateProgrammingLanguage).not.toHaveBeenCalled(); expect(mockSpinner.start).toHaveBeenCalled(); expect(mockSpinner.stop).toHaveBeenCalledTimes(2); expect(mockSpinner.info).toHaveBeenCalledWith(USING_LOCAL_KEY); expect(mockPrintTemplates).toHaveBeenCalledWith( - "javascript", - { - "javascript-node": { - description: "Node.js project template", - location: "/path/to/local/templates/javascript-node", - }, - }, + [ + [ + "javascript", + { + "javascript-node": { + description: "Node.js project template", + location: "/path/to/local/templates/javascript-node", + }, + }, + ], + ], undefined, + "table", ); }); @@ -244,6 +263,7 @@ describe("list command", () => { all: false, global: false, filter: "", + mode: "table", }); expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith( @@ -257,14 +277,19 @@ describe("list command", () => { expect(mockPrintTemplates).toHaveBeenCalledOnce(); expect(mockPrintTemplates).toHaveBeenCalledWith( - "javascript", - { - "javascript-node": { - description: "Node.js project template", - location: "/path/to/local/templates/javascript-node", - }, - }, + [ + [ + "javascript", + { + "javascript-node": { + description: "Node.js project template", + location: "/path/to/local/templates/javascript-node", + }, + }, + ], + ], "", + "table", ); }); @@ -284,6 +309,7 @@ describe("list command", () => { all: false, global: false, filter: "javascript", + mode: "tree", }); expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith( @@ -294,14 +320,19 @@ describe("list command", () => { expect(mockSpinner.info).toHaveBeenCalledWith(GLOBAL_FALLBACK_KEY); expect(mockPrintTemplates).toHaveBeenCalledOnce(); expect(mockPrintTemplates).toHaveBeenCalledWith( + [ + [ + "javascript", + { + "javascript-node": { + description: "Node.js project template", + location: "/path/to/local/templates/javascript-node", + }, + }, + ], + ], "javascript", - { - "javascript-node": { - description: "Node.js project template", - location: "/path/to/local/templates/javascript-node", - }, - }, - "javascript", + "tree", ); }); @@ -317,6 +348,7 @@ describe("list command", () => { all: false, global: false, filter: "", + mode: "tree", }); expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith( @@ -331,6 +363,43 @@ describe("list command", () => { expect(mockLogger.log).not.toHaveBeenCalled(); expect(mockPrintTemplates).not.toHaveBeenCalled(); }); + + it("should throw an error when the mode is invalid", async () => { + mockReadAndMergeConfigs.mockResolvedValue({ + config: structuredClone({ + templates: { + ...sampleLocalConfig.templates, + }, + }), + source: "global", + }); + const modeError = new DevkitError("Invalid mode"); + mockValidateProgrammingLanguage.mockReturnValue(true); + mockValidateDisplayMode.mockImplementationOnce(() => { + throw modeError; + }); + + setupListCommand({ program: mockProgram }); + await actionFn("javascript", { + all: false, + global: false, + filter: "javascript", + mode: "folder", + }); + + expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith( + "javascript", + ); + expect(mockSpinner.start).toHaveBeenCalled(); + expect(mockSpinner.stop).toHaveBeenCalledTimes(1); + expect(mockSpinner.info).toHaveBeenCalledWith(GLOBAL_FALLBACK_KEY); + expect(mockPrintTemplates).not.toHaveBeenCalled(); + expect(mockHandleErrorAndExit).toHaveBeenCalledOnce(); + expect(mockHandleErrorAndExit).toHaveBeenCalledWith( + modeError, + mockSpinner, + ); + }); }); describe("filter option", () => { @@ -349,28 +418,37 @@ describe("list command", () => { it("should pass the filter string to printTemplates", async () => { setupListCommand({ program: mockProgram }); - await actionFn("", { all: true, global: false, filter: "vue" }); + await actionFn("", { + all: true, + global: false, + filter: "vue", + mode: "tree", + }); expect(mockValidateProgrammingLanguage).not.toHaveBeenCalled(); expect(mockPrintTemplates).toHaveBeenCalledWith( - "javascript", - { - "javascript-node": { - description: "Node.js project template", - location: "/path/to/local/templates/javascript-node", - }, - }, - "vue", - ); - expect(mockPrintTemplates).toHaveBeenCalledWith( - "typescript", - { - "typescript-express": { - description: "Express.js project template with TypeScript", - location: "/path/to/global/templates/typescript-express", - }, - }, + [ + [ + "javascript", + { + "javascript-node": { + description: "Node.js project template", + location: "/path/to/local/templates/javascript-node", + }, + }, + ], + [ + "typescript", + { + "typescript-express": { + description: "Express.js project template with TypeScript", + location: "/path/to/global/templates/typescript-express", + }, + }, + ], + ], "vue", + "tree", ); }); }); @@ -378,7 +456,7 @@ describe("list command", () => { describe("error handling", () => { it("should throw a DevkitError if both --global and --all flags are used", async () => { setupListCommand({ program: mockProgram }); - await actionFn("", { global: true, all: true }); + await actionFn("", { global: true, all: true, mode: "table" }); expect(mockValidateProgrammingLanguage).not.toHaveBeenCalled(); expect(mockSpinner.start).toHaveBeenCalled(); diff --git a/packages/devkit/__tests__/units/core/template/printer.spec.ts b/packages/devkit/__tests__/units/core/template/printer.spec.ts index fdb7fa7..f4ac960 100644 --- a/packages/devkit/__tests__/units/core/template/printer.spec.ts +++ b/packages/devkit/__tests__/units/core/template/printer.spec.ts @@ -4,12 +4,15 @@ import { printTemplates, } from "../../../../src/core/template/printer.js"; import { mockLogger, mocktFn } from "../../../../vitest.setup.js"; +import type { LanguageConfig } from "../../../integrations/common.js"; const NEW_ALIAS_KEY = "commands.template.add.options.alias"; const NEW_DESCRIPTION_KEY = "commands.template.add.options.description"; const NEW_CACHE_KEY = "commands.template.add.options.cache"; const NEW_PM_KEY = "commands.template.add.options.package_manager"; +mockLogger.table = vi.fn(); + describe("print-utils", () => { beforeEach(() => { vi.clearAllMocks(); @@ -18,7 +21,7 @@ describe("print-utils", () => { const c: any = mockLogger.colors; const t = mocktFn; - const templates = { + const templates: LanguageConfig["templates"] = { "node-ts-api": { description: "A simple Node.js API with TypeScript", alias: "nta", @@ -33,8 +36,12 @@ describe("print-utils", () => { "next-app": { description: "A Next.js application template", alias: "nextjs", + location: "/local/path/to/template", + }, + "simple-template": { + description: "A simple template", + location: "/local/path/to/template", }, - "simple-template": {}, }; const settings = { @@ -43,13 +50,13 @@ describe("print-utils", () => { language: "en", }; - describe("printTemplates", () => { + describe("printTemplates (Mode `Tree`: Default)", () => { it("should print all templates without a filter", () => { - printTemplates("typescript", templates); + printTemplates([["typescript", templates]]); expect(mockLogger.log).toHaveBeenCalledTimes(5); - printTemplates("typescript", templates); + printTemplates([["typescript", templates]]); expect(mockLogger.log).toHaveBeenCalledWith( `\n${c.boldBlue("TYPESCRIPT")}:`, @@ -64,20 +71,20 @@ describe("print-utils", () => { ); expect(mockLogger.log).toHaveBeenCalledWith( - ` - ${c.green("next-app")} ${c.cyanDim(`(${t(NEW_ALIAS_KEY)}: nextjs)`)}${c.dim(`\n ${t(NEW_DESCRIPTION_KEY)}`)}: A Next.js application template\n`, + ` - ${c.green("next-app")} ${c.cyanDim(`(${t(NEW_ALIAS_KEY)}: nextjs)`)}${c.dim(`\n ${t(NEW_DESCRIPTION_KEY)}`)}: A Next.js application template${c.dim("\n Location")}: /local/path/to/template\n`, ); expect(mockLogger.log).toHaveBeenCalledWith( - ` - ${c.green("simple-template")} \n`, + ` - ${c.green("simple-template")} ${c.dim(`\n ${t(NEW_DESCRIPTION_KEY)}`)}: A simple template${c.dim("\n Location")}: /local/path/to/template\n`, ); mockLogger.log.mockRestore(); }); it("should print only filtered templates by name", () => { - printTemplates("typescript", templates, "react"); + printTemplates([["typescript", templates]], "react"); - printTemplates("typescript", templates, "react"); + printTemplates([["typescript", templates]], "react"); expect(mockLogger.log).toHaveBeenCalledWith( `\n${c.boldBlue("TYPESCRIPT")}:`, @@ -91,8 +98,8 @@ describe("print-utils", () => { }); it("should print only filtered templates by alias", () => { - printTemplates("javascript", templates, "nta"); - printTemplates("javascript", templates, "nta"); + printTemplates([["javascript", templates]], "nta"); + printTemplates([["javascript", templates]], "nta"); expect(mockLogger.log).toHaveBeenCalledWith( `\n${c.boldBlue("JAVASCRIPT")}:`, @@ -106,24 +113,138 @@ describe("print-utils", () => { }); it("should not print anything if no templates match the filter", () => { - printTemplates("python", templates, "unrelated"); + printTemplates([["python", templates]], "unrelated"); expect(mockLogger.log).not.toHaveBeenCalled(); - printTemplates("python", templates, "unrelated"); + printTemplates([["python", templates]], "unrelated"); expect(mockLogger.log).not.toHaveBeenCalled(); mockLogger.log.mockRestore(); }); it("should not print anything if templates are empty", () => { - printTemplates("rust", {}); + printTemplates([["rust", {}]]); expect(mockLogger.log).not.toHaveBeenCalled(); - printTemplates("rust", {}); + printTemplates([["rust", {}]]); expect(mockLogger.log).not.toHaveBeenCalled(); mockLogger.log.mockRestore(); }); }); + describe("printTemplates (Mode `Table`)", () => { + it("should call logger.table with all templates across multiple languages without a filter", () => { + printTemplates( + [ + ["typescript", templates], + ["javascript", { "express-api": { description: "Express" } }], + ], + undefined, + "table", + ); + + expect(mockLogger.table).toHaveBeenCalledTimes(1); + + const expectedTableData = [ + [ + c.bold("Language"), + c.bold("Name"), + c.bold("Alias"), + c.bold("Description"), + c.bold("Location"), + ], + [ + c.boldBlue("Typescript"), + c.green("node-ts-api"), + "nta", + "A simple Node.js API with TypeScript", + "https://github.com/devkit/node-ts-api", + ], + [ + c.boldBlue("Typescript"), + c.green("react-component"), + c.dim("N/A"), + "A reusable React component", + "/local/path/to/template", + ], + [ + c.boldBlue("Typescript"), + c.green("next-app"), + "nextjs", + "A Next.js application template", + c.dim("/local/path/to/template"), + ], + [ + c.boldBlue("Typescript"), + c.green("simple-template"), + c.dim("N/A"), + c.dim("A simple template"), + c.dim("/local/path/to/template"), + ], + [ + c.boldBlue("Javascript"), + c.green("express-api"), + c.dim("N/A"), + "Express", + c.dim("N/A"), + ], + ]; + + expect(mockLogger.table).toHaveBeenCalledWith(expectedTableData); + expect(mockLogger.log).not.toHaveBeenCalled(); + }); + + it("should print only filtered templates by name or alias in table mode", () => { + printTemplates([["typescript", templates]], "nextjs", "table"); + + expect(mockLogger.table).toHaveBeenCalledTimes(1); + + const expectedTableData = [ + [ + c.bold("Language"), + c.bold("Name"), + c.bold("Alias"), + c.bold("Description"), + c.bold("Location"), + ], + [ + c.boldBlue("Typescript"), + c.green("next-app"), + "nextjs", + "A Next.js application template", + c.dim("/local/path/to/template"), + ], + ]; + + expect(mockLogger.table).toHaveBeenCalledWith(expectedTableData); + }); + + it("should not call logger.table if no templates match the filter", () => { + printTemplates([["typescript", templates]], "unrelated-app", "table"); + + expect(mockLogger.table).not.toHaveBeenCalled(); + }); + + it("should not call logger.table if templates list is empty", () => { + printTemplates([["rust", {}]], undefined, "table"); + + expect(mockLogger.table).not.toHaveBeenCalled(); + expect(mockLogger.warning).toHaveBeenCalledOnce(); + expect(mockLogger.warning).toHaveBeenCalledWith( + "warnings.template_not_found", + ); + }); + + it("should not call logger.table if templates list is empty with filter applied", () => { + printTemplates([["rust", {}]], "unrelated-app", "table"); + + expect(mockLogger.table).not.toHaveBeenCalled(); + expect(mockLogger.warning).toHaveBeenCalledOnce(); + expect(mockLogger.warning).toHaveBeenCalledWith( + "warnings.template_not_found_with_filter", + ); + }); + }); + describe("printSettings", () => { it("should print all settings correctly", () => { printSettings(settings); diff --git a/packages/devkit/__tests__/units/utils/logger.spec.ts b/packages/devkit/__tests__/units/utils/logger.spec.ts index fbd2228..cbfa246 100644 --- a/packages/devkit/__tests__/units/utils/logger.spec.ts +++ b/packages/devkit/__tests__/units/utils/logger.spec.ts @@ -65,6 +65,10 @@ const { mockChalk, mockOra } = vi.hoisted(() => { dim: timestampDimMock, bold: boldMock, cyan: cyanMock, + boldRed: compositeMocks.boldRed, + boldBlue: compositeMocks.boldBlue, + boldYellow: compositeMocks.boldYellow, + cyanDim: compositeMocks.cyanDim, }; mockChalk.blue = simpleMocks.blue; @@ -137,14 +141,6 @@ describe("logger utility", () => { expect(mockConsoleLog).toHaveBeenCalledWith(`[green] \n✔ ${message}`); }); - it("warning() should call console.log with yellow warning emoji", () => { - const message = "Configuration missing"; - logger.warning(message); - - expect(mockChalk.yellow).toHaveBeenCalledWith(`⚠️ ${message}`); - expect(mockConsoleLog).toHaveBeenCalledWith(`[yellow] ⚠️ ${message}`); - }); - it("error() should call console.error with timestamp, type tag, and redBright message", () => { const message = "File access denied"; const errorType = "CONFIG"; @@ -161,13 +157,61 @@ describe("logger utility", () => { expect(mockConsoleError).toHaveBeenCalledWith(expectedOutput); }); - it("error() should default to 'UNKNOWN' type", () => { - const message = "Generic error"; - logger.error(message); + it("spinner() should call ora with the provided text", () => { + const text = "Loading data..."; + const spinnerInstance = logger.spinner(text); - expect(mockChalk.bold.red).toHaveBeenCalledWith( - `❌[dim] [10:00:00]::[UNKNOWN]`, - ); + expect(mockOra).toHaveBeenCalledWith(text); + expect(spinnerInstance.start).toBeInstanceOf(Function); + }); + + describe("table() utility", () => { + it("should calculate widths and log correctly aligned, colored strings and a separator", () => { + const testData = [ + [logger.colors.bold("Header1"), logger.colors.boldBlue("Header2")], + [ + logger.colors.green("Short"), + logger.colors.cyanDim("A very long colored string"), + ], + [logger.colors.dim("Longer row"), logger.colors.white("Short")], + ]; + + logger.table(testData); + + const expectedLine1 = `[bold] Header1 [bold_blue] Header2 `; + + const expectedTotalWidth = 56; + const expectedLine2 = `[dim] ${"-".repeat(expectedTotalWidth)}`; + + const expectedLine3 = `[green] Short [cyan_dim] A very long colored string `; + + const expectedLine4 = `[dim] Longer row [white] Short `; + + expect(mockConsoleLog).toHaveBeenCalledTimes(4); + expect(mockConsoleLog).toHaveBeenNthCalledWith( + 1, + expectedLine1.trimEnd(), + ); + expect(mockConsoleLog).toHaveBeenNthCalledWith(2, expectedLine2); + expect(mockConsoleLog).toHaveBeenNthCalledWith( + 3, + expectedLine3.trimEnd(), + ); + expect(mockConsoleLog).toHaveBeenNthCalledWith( + 4, + expectedLine4.trimEnd(), + ); + }); + + it("should handle empty or single row data by doing nothing", () => { + logger.table([]); + logger.table([[]]); + logger.table([[], []]); + logger.table([["Header"]]); + logger.table([["H1", "H2"]]); + + expect(mockConsoleLog).not.toHaveBeenCalled(); + }); }); it("dimmed() should call console.log with dim chalk and trim the message", () => { @@ -178,12 +222,13 @@ describe("logger utility", () => { expect(mockConsoleLog).toHaveBeenCalledWith(`[dim] Extra details...`); }); - it("spinner() should call ora with the provided text", () => { - const text = "Loading data..."; - const spinnerInstance = logger.spinner(text); + it("error() should default to 'UNKNOWN' type", () => { + const message = "Generic error"; + logger.error(message); - expect(mockOra).toHaveBeenCalledWith(text); - expect(spinnerInstance.start).toBeInstanceOf(Function); + expect(mockChalk.bold.red).toHaveBeenCalledWith( + `❌[dim] [10:00:00]::[UNKNOWN]`, + ); }); describe("Colors object", () => { diff --git a/packages/devkit/__tests__/units/utils/schema/schema.spec.ts b/packages/devkit/__tests__/units/utils/schema/schema.spec.ts index fbe4deb..10ebe10 100644 --- a/packages/devkit/__tests__/units/utils/schema/schema.spec.ts +++ b/packages/devkit/__tests__/units/utils/schema/schema.spec.ts @@ -21,7 +21,6 @@ describe("Schema Constants and Defaults", () => { expect(JavascriptPackageManagers.Bun).toBe("bun"); expect(JavascriptPackageManagers.Npm).toBe("npm"); expect(JavascriptPackageManagers.Yarn).toBe("yarn"); - expect(JavascriptPackageManagers.Deno).toBe("deno"); expect(JavascriptPackageManagers.Pnpm).toBe("pnpm"); }); @@ -29,12 +28,11 @@ describe("Schema Constants and Defaults", () => { expect(PackageManagers.Bun).toBe("bun"); expect(PackageManagers.Npm).toBe("npm"); expect(PackageManagers.Yarn).toBe("yarn"); - expect(PackageManagers.Deno).toBe("deno"); expect(PackageManagers.Pnpm).toBe("pnpm"); }); it("should correctly define VALID_PACKAGE_MANAGERS", () => { - const expected = ["bun", "npm", "yarn", "deno", "pnpm"]; + const expected = ["bun", "npm", "yarn", "pnpm"]; expect(VALID_PACKAGE_MANAGERS).toEqual(expect.arrayContaining(expected)); expect(VALID_PACKAGE_MANAGERS.length).toBe(expected.length); expect(Object.isSealed(VALID_PACKAGE_MANAGERS)).toBe(true); diff --git a/packages/devkit/__tests__/units/utils/validations/config.spec.ts b/packages/devkit/__tests__/units/utils/validations/config.spec.ts index 9986819..117b098 100644 --- a/packages/devkit/__tests__/units/utils/validations/config.spec.ts +++ b/packages/devkit/__tests__/units/utils/validations/config.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { validateCacheStrategy, + validateDisplayMode, validateLanguage, validatePackageManager, validateProgrammingLanguage, @@ -8,6 +9,7 @@ import { import { DevkitError } from "../../../../src/utils/errors/base.js"; import { mocktFn } from "../../../../vitest.setup.js"; import { + DisplayModes, PackageManagers, ProgrammingLanguage, TextLanguages, @@ -89,3 +91,26 @@ describe("validateProgrammingLanguage", () => { ); }); }); + +describe("validateDisplayMode", () => { + it("should not throw an error for a valid display mode", () => { + const validLang = DisplayModes.Tree.toLowerCase(); + expect(() => validateDisplayMode(validLang)).not.toThrow(); + + const validLang1 = DisplayModes.Table.toLowerCase(); + expect(() => validateDisplayMode(validLang1)).not.toThrow(); + }); + + it("should throw a DevkitError for an invalid programming language", () => { + const invalidDisplayMode = "invalid-display-mode"; + expect(() => validateDisplayMode(invalidDisplayMode)).toThrow(DevkitError); + expect(() => validateDisplayMode(invalidDisplayMode)).toThrow( + mocktFn(NEW_ERROR_KEY, { + key: "mode", + options: Object.values(DisplayModes) + .map((value) => value.toLowerCase()) + .join(", "), + }), + ); + }); +}); diff --git a/packages/devkit/locales/en.json b/packages/devkit/locales/en.json index 826bfa0..28dfe08 100644 --- a/packages/devkit/locales/en.json +++ b/packages/devkit/locales/en.json @@ -46,6 +46,9 @@ }, "filter": { "option": "Filter templates by name or substring." + }, + "mode": { + "option": "Set the display mode: 'tree' or 'table'. (Default: tree)" } }, "options": { @@ -370,6 +373,7 @@ "no_local_config": "No local project configuration found. Using global or default settings.", "global_not_initialized": "Global configuration not initialized. Run 'devkit config init' to create one.", "template_not_found": "No templates found in the configuration file.", + "template_not_found_with_filter": "No templates matched the specified filter.", "templates_not_found": "The following templates were not found: {templates}.", "no_command_provided": "Warning: No command or option provided. Use `dk config --help` to see available commands.", "no_config_found": "⚠️ No configuration file found. Using default configuration." diff --git a/packages/devkit/locales/fr.json b/packages/devkit/locales/fr.json index 643b556..05da4df 100644 --- a/packages/devkit/locales/fr.json +++ b/packages/devkit/locales/fr.json @@ -46,6 +46,9 @@ }, "filter": { "option": "Filtrer les modèles par nom ou sous-chaîne." + }, + "mode": { + "option": "Définir le mode d'affichage : 'tree' (arborescence) ou 'table' (tableau). (Par défaut : tree)" } }, "options": { @@ -370,6 +373,7 @@ "no_local_config": "Aucune configuration de projet locale trouvée. Utilisation des paramètres globaux ou par défaut.", "global_not_initialized": "Configuration globale non initialisée. Exécutez 'devkit config init' pour en créer une.", "template_not_found": "Aucun modèle trouvé dans le fichier de configuration.", + "template_not_found_with_filter": "La liste des modèles est vide après application du filtre.", "templates_not_found": "Les modèles suivants n'ont pas été trouvés : {templates}.", "no_command_provided": "Avertissement : Aucune commande ou option fournie. Utilisez `dk config --help` pour voir les commandes disponibles.", "no_config_found": "⚠️ Aucun fichier de configuration trouvé. Utilisation de la configuration par défaut." diff --git a/packages/devkit/src/commands/config/list.ts b/packages/devkit/src/commands/config/list.ts index 4d74c27..dbf3277 100644 --- a/packages/devkit/src/commands/config/list.ts +++ b/packages/devkit/src/commands/config/list.ts @@ -5,6 +5,7 @@ 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"; +import type { LanguageConfig } from "#/utils/schema/schema"; type ListCommandOptions = { all?: boolean; @@ -100,8 +101,8 @@ export function setupListCommand(configCommand: Command): void { logger.log(logger.colors.yellow(t("warnings.template_not_found"))); } else { Object.entries(config?.templates || {}).forEach( - ([lang, langTemplates]) => { - printTemplates(lang, langTemplates.templates); + ([language, langTemplates]) => { + printTemplates([[language, langTemplates.templates]]); }, ); } diff --git a/packages/devkit/src/commands/list.ts b/packages/devkit/src/commands/list.ts index 767ef1d..19c211c 100644 --- a/packages/devkit/src/commands/list.ts +++ b/packages/devkit/src/commands/list.ts @@ -4,13 +4,21 @@ import { DevkitError } from "#utils/errors/base.js"; import { logger, type TSpinner } from "#utils/logger.js"; import { readAndMergeConfigs } from "#core/config/loader.js"; import { printTemplates } from "#core/template/printer.js"; -import type { SetupCommandOptions } from "#utils/schema/schema.js"; -import { validateProgrammingLanguage } from "#utils/validations/config.js"; +import type { + DisplayModesValues, + LanguageConfig, + SetupCommandOptions, +} from "#utils/schema/schema.js"; +import { + validateDisplayMode, + validateProgrammingLanguage, +} from "#utils/validations/config.js"; type ListCommandOptions = { global?: boolean; all?: boolean; filter?: string; + mode: DisplayModesValues; }; const getStartMessage = ( @@ -63,8 +71,13 @@ export function setupListCommand(options: SetupCommandOptions): void { .option("-g, --global", t("commands.list.options.global")) .option("-a, --all", t("commands.list.options.all")) .option("-f, --filter ", t("commands.list.command.filter.option")) + .option( + "-m, --mode ", + t("commands.list.command.mode.option"), + "tree", + ) .action(async (language, cmdOptions: ListCommandOptions) => { - const { global: isGlobal, all: showAll, filter } = cmdOptions; + const { global: isGlobal, all: showAll, filter, mode } = cmdOptions; const spinner: TSpinner = logger .spinner(t("messages.status.config_loading")) @@ -96,8 +109,9 @@ export function setupListCommand(options: SetupCommandOptions): void { } spinner.info(t(startMessageKey)).start(); + const allTemplates = Object.entries(config?.templates || {}); - if (Object.keys(config?.templates || {}).length === 0) { + if (allTemplates.length === 0) { spinner.succeed( logger.colors.yellow( t("warnings.template_not_found", { @@ -110,12 +124,18 @@ export function setupListCommand(options: SetupCommandOptions): void { logger.log(logger.colors.bold("\n" + t("commands.list.output.header"))); - Object.entries(config?.templates || {}).forEach( - ([lang, langTemplates]) => { - if (language && lang !== language) return; - printTemplates(lang, langTemplates.templates, filter); - }, - ); + const templatesToPrint: [string, LanguageConfig["templates"]][] = []; + + allTemplates.forEach(([lang, langConfig]) => { + if (!language || lang === language) { + templatesToPrint.push([lang, langConfig.templates]); + } + }); + + validateDisplayMode(mode); + + printTemplates(templatesToPrint, filter, mode); + spinner.stop(); } catch (error: unknown) { handleErrorAndExit(error as Error, spinner); diff --git a/packages/devkit/src/core/template/printer.ts b/packages/devkit/src/core/template/printer.ts index 2efdd90..29845d0 100644 --- a/packages/devkit/src/core/template/printer.ts +++ b/packages/devkit/src/core/template/printer.ts @@ -1,27 +1,39 @@ import { t } from "#utils/i18n/translator.js"; -import { type CliConfig, type LanguageConfig } from "#utils/schema/schema.js"; +import { + type CliConfig, + type DisplayModesValues, + type LanguageConfig, + type TemplateConfig, +} from "#utils/schema/schema.js"; import { logger } from "#utils/logger.js"; type TemplateMap = LanguageConfig["templates"]; +type TemplateList = [string, TemplateMap]; -export function printTemplates( - language: string, +const filterTemplateEntries = ( templates: TemplateMap, filter?: string, -): void { +): [string, TemplateConfig][] => { let filteredTemplates = Object.entries(templates); if (filter) { + const lowerFilter = filter.toLowerCase(); filteredTemplates = filteredTemplates.filter( ([templateName, templateConfig]) => { const name = templateName.toLowerCase(); const alias = templateConfig?.alias?.toLowerCase() ?? ""; - return ( - name.includes(filter.toLowerCase()) || - alias.includes(filter.toLowerCase()) - ); + return name.includes(lowerFilter) || alias.includes(lowerFilter); }, ); } + return filteredTemplates; +}; + +export function printTemplatesTree( + language: string, + templates: TemplateMap, + filter?: string, +): void { + const filteredTemplates = filterTemplateEntries(templates, filter); if (filteredTemplates.length === 0) return; @@ -58,6 +70,67 @@ export function printTemplates( }); } +function printTemplatesTable( + templatesList: TemplateList[], + filter?: string, +): void { + const tableData: string[][] = []; + const languageHeader = logger.colors.bold("Language"); + const nameHeader = logger.colors.bold("Name"); + const aliasHeader = logger.colors.bold("Alias"); + const descriptionHeader = logger.colors.bold("Description"); + const locationHeader = logger.colors.bold("Location"); + + tableData.push([ + languageHeader, + nameHeader, + aliasHeader, + descriptionHeader, + locationHeader, + ]); + + templatesList.forEach(([lang, templates]) => { + const filteredTemplates = filterTemplateEntries(templates, filter); + + filteredTemplates.forEach(([templateName, templateConfig]) => { + const row = [ + logger.colors.boldBlue(lang.charAt(0).toUpperCase() + lang.slice(1)), + logger.colors.green(templateName), + templateConfig.alias || logger.colors.dim("N/A"), + templateConfig.description || logger.colors.dim("N/A"), + templateConfig.location || logger.colors.dim("N/A"), + ]; + tableData.push(row); + }); + }); + + if (tableData.length > 1) { + logger.table(tableData); + return; + } + + const messageKey = filter + ? "warnings.template_not_found_with_filter" + : "warnings.template_not_found"; + + logger.warning(t(messageKey)); +} + +export function printTemplates( + templatesList: TemplateList[], + filter?: string, + mode: DisplayModesValues = "tree", +): void { + if (mode === "table") { + printTemplatesTable(templatesList, filter); + return; + } + + templatesList.forEach(([lang, templates]) => { + printTemplatesTree(lang, templates, filter); + }); +} + export function printSettings(settings: CliConfig["settings"]): void { Object.entries(settings).forEach(([key, value]) => { const keyString = logger.colors.yellowBold(` ${key}:`); diff --git a/packages/devkit/src/utils/logger.ts b/packages/devkit/src/utils/logger.ts index 1634445..09ceadc 100644 --- a/packages/devkit/src/utils/logger.ts +++ b/packages/devkit/src/utils/logger.ts @@ -1,6 +1,14 @@ import chalk from "chalk"; import ora, { type Ora } from "ora"; +const stripAnsi = (str: string): string => { + return str.replace( + // oxlint-disable-next-line no-control-regex + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, + "", + ); +}; + function getTimestamp(): string { const date = new Date(); const time = date.toTimeString().split(" ")[0]; @@ -15,6 +23,51 @@ function formatError(message: string, errorType: ErrorType): string { return `${typeTag}>> ${coloredMessage}`; } +function _logTable(data: string[][]): void { + if (!Array.isArray(data) || data.length <= 1 || data[0].length === 0) { + return; + } + + const numCols = data[0].length; + // oxlint-disable-next-line no-new-array + const colWidths = new Array(numCols).fill(0); + const PADDING = 3; + + for (const row of data) { + for (let i = 0; i < numCols; i++) { + const cell = row[i] || ""; + const cellTextLength = stripAnsi(cell).length; + if (cellTextLength > colWidths[i]) { + colWidths[i] = cellTextLength; + } + } + } + + data.forEach((row, rowIndex) => { + let line = ""; + for (let i = 0; i < numCols; i++) { + const cell = row[i] || ""; + const targetWidth = colWidths[i]; + const cellTextLength = stripAnsi(cell).length; + + const paddingSpaces = " ".repeat(targetWidth - cellTextLength); + + const columnSeparator = " "; + + line += cell + paddingSpaces + columnSeparator.repeat(PADDING); + } + + console.log(line.trimEnd()); + + if (rowIndex === 0) { + const totalWidth = + colWidths.reduce((sum, width) => sum + width, 0) + + (numCols - 1) * PADDING; + console.log(chalk.dim("-".repeat(totalWidth))); + } + }); +} + export type ErrorType = | "UNKNOWN" | "WARNING" @@ -56,6 +109,10 @@ export const logger = { console.log(chalk.dim(message.trim())); }, + table(data: string[][]): void { + _logTable(data); + }, + colors: { white: (message: string) => chalk.white(message), blue: (message: string) => chalk.blue(message), diff --git a/packages/devkit/src/utils/schema/schema.ts b/packages/devkit/src/utils/schema/schema.ts index 15eafc6..6ded806 100644 --- a/packages/devkit/src/utils/schema/schema.ts +++ b/packages/devkit/src/utils/schema/schema.ts @@ -8,7 +8,6 @@ export const JavascriptPackageManagers = { Bun: "bun", Npm: "npm", Yarn: "yarn", - Deno: "deno", Pnpm: "pnpm", } as const; @@ -64,7 +63,7 @@ export interface TemplateConfig { } export interface LanguageConfig { - templates: { [key: string]: TemplateConfig }; + templates: { [templateName: string]: TemplateConfig }; } export interface CliConfig { @@ -77,6 +76,11 @@ export interface CliConfig { } export type ConfigurationSource = "local" | "global" | "default" | "merged"; +export const DisplayModes = { + Tree: "tree", + Table: "table", +} as const; +export type DisplayModesValues = ValuesOf; export interface UpdateCommandOptions { global: boolean; diff --git a/packages/devkit/src/utils/validations/config.ts b/packages/devkit/src/utils/validations/config.ts index f5068c8..c00a5fe 100644 --- a/packages/devkit/src/utils/validations/config.ts +++ b/packages/devkit/src/utils/validations/config.ts @@ -1,4 +1,5 @@ import { + DisplayModes, PackageManagers, type PackageManager, type CacheStrategy, @@ -8,6 +9,7 @@ import { ProgrammingLanguage, type SupportedProgrammingLanguageValues, type SupportedPackageManager, + type DisplayModesValues, } from "#utils/schema/schema.js"; import { DevkitError } from "#utils/errors/base.js"; import { t } from "#utils/i18n/translator.js"; @@ -69,3 +71,19 @@ export function validateProgrammingLanguage( ); } } + +export function validateDisplayMode( + value: string, +): asserts value is DisplayModesValues { + const validDisplayMode = Object.values(DisplayModes).map((value) => + value.toLowerCase(), + ); + if (!validDisplayMode.includes(value as DisplayModesValues)) { + throw new DevkitError( + t("errors.validation.invalid_value", { + key: "mode", + options: validDisplayMode.join(", "), + }), + ); + } +} diff --git a/packages/devkit/vitest.setup.ts b/packages/devkit/vitest.setup.ts index f038119..84ef39e 100644 --- a/packages/devkit/vitest.setup.ts +++ b/packages/devkit/vitest.setup.ts @@ -78,6 +78,7 @@ const { log: vi.fn(), warning: vi.fn(), dimmed: vi.fn(), + table: vi.fn(), spinner: vi.fn(() => mockSpinner), colors: { yellow: vi.fn((text: string) => text),