From 2dcfcae196cafccae2ef452fb5edb6d706c4df60 Mon Sep 17 00:00:00 2001 From: IT-WIBRC Date: Sun, 5 Oct 2025 04:32:58 +0100 Subject: [PATCH] feat(config): add --yes/-y option to dk init for non-interactive overwrite --- .changeset/great-eels-grab.md | 5 + packages/devkit/README.md | 10 +- packages/devkit/TODO.md | 10 +- .../__tests__/integrations/init.spec.ts | 413 ++++++------------ .../units/commands/init/index.spec.ts | 65 ++- .../units/commands/init/logic.spec.ts | 167 +++---- packages/devkit/locales/en.json | 10 +- packages/devkit/locales/fr.json | 8 +- packages/devkit/src/commands/init/index.ts | 20 +- packages/devkit/src/commands/init/logic.ts | 27 +- 10 files changed, 319 insertions(+), 416 deletions(-) create mode 100644 .changeset/great-eels-grab.md diff --git a/.changeset/great-eels-grab.md b/.changeset/great-eels-grab.md new file mode 100644 index 0000000..a861cd1 --- /dev/null +++ b/.changeset/great-eels-grab.md @@ -0,0 +1,5 @@ +--- +"scaffolder-toolkit": minor +--- + +feat(config): Add --yes/-y option to dk init for non-interactive overwrite diff --git a/packages/devkit/README.md b/packages/devkit/README.md index c2e940c..8b153b4 100644 --- a/packages/devkit/README.md +++ b/packages/devkit/README.md @@ -418,7 +418,7 @@ dk new js my-new-project-name -t gh-template The `init` command allows you to initialize a configuration file at different scopes. -**Note:** If a configuration file already exists at the specified location, you will be prompted to confirm if you want to overwrite it. +**Note:** If a configuration file already exists at the specified location, you will be prompted to confirm if you want to overwrite it. To **skip the confirmation prompt and force overwrite**, use the **`--yes`** flag. - To initialize a **local** configuration file in your current project, use the `--local` flag, or run the command without any flags. The file will be named `.devkit.json`. - To initialize a **global** configuration file, use the `--global` flag. The file will be named `.devkitrc`. @@ -429,11 +429,11 @@ The `init` command allows you to initialize a configuration file at different sc # Initialize a local configuration file in the current directory (default) dk init -# Initialize a local configuration file in the current directory (explicit) -dk init --local +# Initialize a local configuration file and automatically overwrite if it exists +dk init --local --yes -# Initialize a global configuration file -dk init --global +# Initialize a global configuration file and force overwrite using the short alias +dk init --global -y ``` ### Add the JSON Schema for Autocompletion diff --git a/packages/devkit/TODO.md b/packages/devkit/TODO.md index 357755e..a57472e 100644 --- a/packages/devkit/TODO.md +++ b/packages/devkit/TODO.md @@ -69,19 +69,15 @@ This document tracks all planned and completed tasks for the Dev Kit project. - [x] Make sure to clean up if the `dk new` command fail - [x] Add a configuration validation step when updating the config file to ensure all required fields are present and correctly formatted. - [x] **Dynamic Help Text**: Programmatically generate help text for options with constrained values (e.g., `--cache-strategy`) to ensure it's always up to date. -- [ ] **Skip Confirmation**: Add a global `-y`/`--yes` and `-n/--no` option to skip confirmation prompts in commands like `dk init`. +- [x] **Package Updates**: Ensure the root `package.json` includes all new packages and that any corrupted packages are replaced. +- [x] **Skip Confirmation**: Add a global `-y`/`--yes` option to skip confirmation prompts in commands like `dk init`. #### Multi-Language Support - [ ] **Multi-Programming Language Support**: Progressively add support for other languages like Python, Ruby, Go, and Rust. - [ ] **Deno Support**: Test and confirm support for the Deno runtime. -#### Documentation & Versioning - -- [ ] **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 +### Debating (RD) - [ ] For unsupported languages, we can let the configuration be added but with a warning that it's not supported yet. When using the `dk new` with an unsupported language, we can copy the template as is without any modifications but ignoring the `.git` folder and not installing dependencies. - [ ] **CLI Self-Update**: Implement a command to allow users to update the CLI itself. `dk upgrade` diff --git a/packages/devkit/__tests__/integrations/init.spec.ts b/packages/devkit/__tests__/integrations/init.spec.ts index d0c86e0..5a9b884 100644 --- a/packages/devkit/__tests__/integrations/init.spec.ts +++ b/packages/devkit/__tests__/integrations/init.spec.ts @@ -27,25 +27,12 @@ let globalConfigDir: string; let localConfig: CliConfig; let globalConfig: CliConfig; -const invalidLocalConfigMalformedSetting: Partial = { - ...defaultCliConfig, - settings: { - ...defaultCliConfig.settings, - defaultPackageManager: "invalid-package-manager-alias" as any, - }, -} as any; - -const invalidLocalConfigMissingRequiredSettings: Partial = { - ...defaultCliConfig, - settings: {} as any, -} as any; - const createLocalConfig = async () => { localConfig = { ...defaultCliConfig, settings: { ...defaultCliConfig.settings, - language: "fr", + language: "en", cacheStrategy: "always-refresh", defaultPackageManager: "npm", }, @@ -69,12 +56,20 @@ const createGlobalConfig = async () => { ); }; +const INPUT_ENTER = "\n"; +const INPUT_DOWN_ARROW = "\x1B[B"; + +const INPUT_YES_SIMULATED = INPUT_ENTER; +const INPUT_NO_SIMULATED = INPUT_DOWN_ARROW + INPUT_ENTER; + describe("dk config", () => { beforeAll(() => { vi.unmock("#utils/shell.js"); }); beforeEach(async () => { + vi.clearAllMocks(); + originalCwd = process.cwd(); tempDir = path.join(os.tmpdir(), `devkit-test-config-${Date.now()}`); globalConfigDir = path.join( @@ -85,310 +80,170 @@ describe("dk config", () => { await fs.ensureDir(tempDir); process.chdir(tempDir); await fs.ensureDir(globalConfigDir); + + vi.spyOn(os, "homedir").mockReturnValue(globalConfigDir); }); afterEach(async () => { + vi.restoreAllMocks(); process.chdir(originalCwd); await fs.remove(tempDir); await fs.remove(globalConfigDir); }); - describe("Core Behavior", () => { - it("should warn the user when no command or option is provided", async () => { - const { all, exitCode } = await execute("bun", [CLI_PATH, "config"], { - all: true, - reject: false, - }); + describe("INIT Functionality (Creating Config)", () => { + const SUCCESS_MESSAGE = "Configuration initialized successfully!"; + const ABORTED_MESSAGE = "Operation aborted. No changes were made."; + const SKIP_YES_MESSAGE_FRAGMENT = + "Skipping confirmation: Overwriting config file at"; - expect(exitCode).toBe(0); - expect(all).toContain( - "Warning: No command or option provided. Use `dk config --help` to see available commands.", - ); - }); + describe("Interactive Init (No --yes)", () => { + it("should create a local config file when none exists (default behavior)", async () => { + const localPath = path.join(tempDir, LOCAL_CONFIG_FILE_NAME); - it("should throw an error if no config file is found for setting", async () => { - const { all, exitCode } = await execute( - "bun", - [CLI_PATH, "conf", "--set", "lang", "en"], - { all: true, reject: false }, - ); + expect(await fs.pathExists(localPath)).toBe(false); - expect(exitCode).toBe(1); - expect(all).toContain( - "::[DEV]>> Devkit encountered an unexpected internal issue: No local configuration file found. Run 'devkit config init --local' to create one.", - ); - }); - }); + const { all, exitCode } = await execute("bun", [CLI_PATH, "init"], { + all: true, + }); - describe("GET Functionality (Reading Config)", () => { - it("should get a single setting from the local config using the full key", async () => { - await createLocalConfig(); - const { all, exitCode } = await execute( - "bun", - [CLI_PATH, "config", "language"], - { all: true }, - ); + expect(exitCode).toBe(0); + expect(all).toContain(SUCCESS_MESSAGE); + expect(await fs.pathExists(localPath)).toBe(true); + }); - expect(exitCode).toBe(0); - expect(all).toContain("language: fr"); - expect(all).toContain( - "Paramètres de configuration récupérés avec succès.", - ); - }); + it("should prompt and successfully overwrite an existing local config file when 'yes' is selected (simulated input)", async () => { + await createLocalConfig(); + const localPath = path.join(tempDir, LOCAL_CONFIG_FILE_NAME); - it("should get a single setting from the local config using a short alias (lang)", async () => { - await createLocalConfig(); - const { all, exitCode } = await execute( - "bun", - [CLI_PATH, "config", "lang"], - { all: true }, - ); + const { all, exitCode } = await execute("bun", [CLI_PATH, "init"], { + all: true, + input: INPUT_YES_SIMULATED, + }); + const finalConfig = await fs.readJson(localPath); + + expect(exitCode).toBe(0); + expect(all).toContain(SUCCESS_MESSAGE); + expect(all).toContain(path.basename(localPath)); + expect(finalConfig.settings.language).not.toBe("old"); + }); - expect(exitCode).toBe(0); - expect(all).toContain("lang: fr"); - expect(all).toContain( - "Paramètres de configuration récupérés avec succès.", - ); - }); + it("should prompt and abort initialization when 'no' is selected for local config overwrite (simulated input)", async () => { + await createLocalConfig(); + const localPath = path.join(tempDir, LOCAL_CONFIG_FILE_NAME); - it("should get multiple settings from the local config using full keys", async () => { - await createLocalConfig(); - const { all, exitCode } = await execute( - "bun", - [CLI_PATH, "config", "language", "cacheStrategy"], - { all: true }, - ); + const { all, exitCode } = await execute("bun", [CLI_PATH, "init"], { + all: true, + input: INPUT_NO_SIMULATED, + }); + const finalConfig = await fs.readJson(localPath); + + expect(exitCode).toBe(0); + expect(all).toContain(ABORTED_MESSAGE); + expect(all).toContain(path.basename(localPath)); + expect(finalConfig.settings.language).toBe("en"); + }); - expect(exitCode).toBe(0); - expect(all).toContain("language: fr"); - expect(all).toContain("cacheStrategy: always-refresh"); - expect(all).toContain( - "Paramètres de configuration récupérés avec succès.", - ); + it("should prompt and successfully overwrite an existing global config file when --global is used and 'yes' is selected (simulated input)", async () => { + await createGlobalConfig(); + const globalPath = path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME); + + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "init", "--global"], + { + all: true, + env: { HOME: globalConfigDir }, + input: INPUT_YES_SIMULATED, + }, + ); + const finalConfig = await fs.readJson(globalPath); + + expect(exitCode).toBe(0); + expect(all).toContain(SUCCESS_MESSAGE); + expect(all).toContain(path.basename(globalPath)); + expect(finalConfig.settings.language).not.toBe("old"); + }); }); - it("should get multiple settings from the local config using short aliases (pm, cache)", async () => { - await createLocalConfig(); - const { all, exitCode } = await execute( - "bun", - [CLI_PATH, "config", "pm", "cache"], - { all: true }, - ); + describe("Non-Interactive Init (With --yes)", () => { + it("should create a new local config file silently with --yes (no prompt, no existing file)", async () => { + const localPath = path.join(tempDir, LOCAL_CONFIG_FILE_NAME); - expect(exitCode).toBe(0); - expect(all).toContain("pm: npm"); - expect(all).toContain("cache: always-refresh"); - expect(all).toContain( - "Paramètres de configuration récupérés avec succès.", - ); - }); + expect(await fs.pathExists(localPath)).toBe(false); - it("should get a setting from the global config with --global flag", async () => { - await createGlobalConfig(); - await createLocalConfig(); - const { all, exitCode } = await execute( - "bun", - [CLI_PATH, "config", "language", "--global"], - { all: true, env: { HOME: globalConfigDir } }, - ); + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "init", "--yes"], + { all: true }, + ); - expect(exitCode).toBe(0); - expect(all).toContain("language: en"); - expect(all).toContain( - "Paramètres de configuration récupérés avec succès.", - ); - }); + expect(exitCode).toBe(0); + expect(all).toContain(SUCCESS_MESSAGE); + expect(await fs.pathExists(localPath)).toBe(true); + expect(all).not.toContain(SKIP_YES_MESSAGE_FRAGMENT); + }); - it("should fail gracefully if a key to get is not found", async () => { - await createLocalConfig(); - const { all, exitCode } = await execute( - "bun", - [CLI_PATH, "config", "non_existent_key"], - { all: true }, - ); + it("should automatically overwrite an existing local config file with --yes (no prompt, success)", async () => { + await createLocalConfig(); + const localPath = path.join(tempDir, LOCAL_CONFIG_FILE_NAME); + + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "init", "-y"], + { all: true }, + ); + const finalConfig = await fs.readJson(localPath); + + expect(exitCode).toBe(0); + expect(all).toContain(SKIP_YES_MESSAGE_FRAGMENT); + expect(all).toContain(SUCCESS_MESSAGE); + expect(finalConfig.settings.language).not.toBe("old"); + }); - expect(exitCode).toBe(0); - expect(all).toContain( - "Clé de configuration 'non_existent_key' introuvable.", - ); + it("should automatically overwrite an existing global config file with --global and --yes", async () => { + await createGlobalConfig(); + const globalPath = path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME); + + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "init", "--global", "-y"], + { all: true, env: { HOME: globalConfigDir } }, + ); + const finalConfig = await fs.readJson(globalPath); + + expect(exitCode).toBe(0); + expect(all).toContain(SKIP_YES_MESSAGE_FRAGMENT); + expect(all).toContain(SUCCESS_MESSAGE); + expect(finalConfig.settings.language).not.toBe("old"); + }); }); }); - describe("SET Functionality (Updating Config)", () => { - it("should set a single setting in the local config using full key", async () => { - await createLocalConfig(); - const { all, exitCode } = await execute( - "bun", - [CLI_PATH, "config", "--set", "language", "en"], - { all: true }, - ); - - const updatedConfig = await fs.readJson( - path.join(tempDir, LOCAL_CONFIG_FILE_NAME), - ); - - expect(exitCode).toBe(0); - expect(all).toContain("Configuration mise à jour avec succès !"); - expect(updatedConfig.settings.language).toBe("en"); - }); - - it("should set a single setting in the local config using a short alias (lang)", async () => { - await createLocalConfig(); - const { all, exitCode } = await execute( - "bun", - [CLI_PATH, "config", "--set", "lang", "en"], - { all: true }, - ); - - const updatedConfig = await fs.readJson( - path.join(tempDir, LOCAL_CONFIG_FILE_NAME), - ); - - expect(exitCode).toBe(0); - expect(all).toContain("Configuration mise à jour avec succès !"); - expect(updatedConfig.settings.language).toBe("en"); - expect(updatedConfig.settings.defaultPackageManager).toBe("npm"); - }); - - it("should set multiple settings in the local config using full keys", async () => { - await createLocalConfig(); - const { all, exitCode } = await execute( - "bun", - [ - CLI_PATH, - "config", - "--set", - "language", - "en", - "cacheStrategy", - "never-refresh", - ], - { all: true }, - ); - - const updatedConfig = await fs.readJson( - path.join(tempDir, LOCAL_CONFIG_FILE_NAME), - ); - - expect(exitCode).toBe(0); - expect(all).toContain("Configuration mise à jour avec succès !"); - expect(updatedConfig.settings.language).toBe("en"); - expect(updatedConfig.settings.cacheStrategy).toBe("never-refresh"); - }); - - it("should set multiple settings in the local config using short aliases (pm, cache)", async () => { - await createLocalConfig(); - const { all, exitCode } = await execute( - "bun", - [CLI_PATH, "config", "-s", "pm", "pnpm", "cache", "never-refresh"], - { all: true }, - ); - - const updatedConfig = await fs.readJson( - path.join(tempDir, LOCAL_CONFIG_FILE_NAME), - ); - - expect(exitCode).toBe(0); - expect(all).toContain("Configuration mise à jour avec succès !"); - expect(updatedConfig.settings.defaultPackageManager).toBe("pnpm"); - expect(updatedConfig.settings.cacheStrategy).toBe("never-refresh"); - }); - - it("should set a single setting in the global config with --global flag", async () => { - await createGlobalConfig(); - const { all, exitCode } = await execute( - "bun", - [CLI_PATH, "config", "--global", "--set", "language", "fr"], - { all: true, env: { HOME: globalConfigDir } }, - ); - - const updatedConfig = await fs.readJson( - path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), - ); - - expect(exitCode).toBe(0); - expect(all).toContain("Configuration updated successfully!"); - expect(updatedConfig.settings.language).toBe("fr"); - }); - - it("should fail if --set has an odd number of arguments", async () => { - await createLocalConfig(); - const { all, exitCode } = await execute( - "bun", - [CLI_PATH, "config", "--set", "language", "en", "invalid"], - { all: true, reject: false }, - ); + describe("Core Behavior", () => { + it("should warn the user when no command or option is provided", async () => { + const { all, exitCode } = await execute("bun", [CLI_PATH, "config"], { + all: true, + reject: false, + }); expect(exitCode).toBe(0); expect(all).toContain( - "Les valeurs pour l'option '--set' doivent être une série de paires clé-valeur (ex: --set clé1 valeur1 clé2 valeur2).", - ); - }); - }); - - describe("Configuration Validation Failures", () => { - const VALIDATION_ERROR_MESSAGE = - "Failed to read configuration: Configuration validation failed"; - const SETTINGS_PM_ERROR_FRAGMENT = - "The value for setting 'defaultPackageManager' is invalid"; - const SETTINGS_MISSING_ERROR_FRAGMENT = - "The value for setting 'defaultPackageManager' is invalid or missing."; - - it("should fail and exit if local config is invalid (malformed settings field) during SET", async () => { - await createLocalConfig(); - - const { all, exitCode } = await execute( - "bun", - [CLI_PATH, "config", "--set", "pm", "invalid-pm"], - { - all: true, - reject: false, - }, + "Warning: No command or option provided. Use `dk config --help` to see available commands.", ); - - expect(exitCode).toBe(1); - expect(all).toContain("Devkit a rencontré un problème interne inattendu"); - expect(all).not.toContain(SETTINGS_PM_ERROR_FRAGMENT); }); - it("should fail and exit if local config is invalid (malformed settings field) during GET", async () => { - await fs.writeJson( - path.join(tempDir, LOCAL_CONFIG_FILE_NAME), - invalidLocalConfigMalformedSetting, - ); - + it("should throw an error if no config file is found for setting", async () => { const { all, exitCode } = await execute( "bun", - [CLI_PATH, "config", "language"], - { - all: true, - reject: false, - }, + [CLI_PATH, "conf", "--set", "lang", "en"], + { all: true, reject: false }, ); expect(exitCode).toBe(1); - expect(all).toContain(VALIDATION_ERROR_MESSAGE); - expect(all).toContain(SETTINGS_PM_ERROR_FRAGMENT); - }); - - it("should fail and exit if local config is invalid (missing required settings fields)", async () => { - await fs.writeJson( - path.join(tempDir, LOCAL_CONFIG_FILE_NAME), - invalidLocalConfigMissingRequiredSettings, - ); - - const { all, exitCode } = await execute( - "bun", - [CLI_PATH, "config", "language"], - { - all: true, - reject: false, - }, + expect(all).toContain( + "::[DEV]>> Devkit encountered an unexpected internal issue: No local configuration file found. Run 'devkit config init --local' to create one.", ); - - expect(exitCode).toBe(1); - expect(all).toContain(VALIDATION_ERROR_MESSAGE); - expect(all).toContain(SETTINGS_MISSING_ERROR_FRAGMENT); }); }); }); diff --git a/packages/devkit/__tests__/units/commands/init/index.spec.ts b/packages/devkit/__tests__/units/commands/init/index.spec.ts index 770bde7..6582caa 100644 --- a/packages/devkit/__tests__/units/commands/init/index.spec.ts +++ b/packages/devkit/__tests__/units/commands/init/index.spec.ts @@ -25,10 +25,17 @@ vi.mock("../../../../src/commands/init/logic.js", () => ({ const CMD_DESCRIPTION_KEY = "commands.config.init.command.description"; const OPT_LOCAL_KEY = "commands.config.init.option.local"; const OPT_GLOBAL_KEY = "commands.config.init.option.global"; +const OPT_YES_KEY = "commands.common.options.yes"; const ERROR_INIT_MUTUAL_EXCLUSION_KEY = "errors.config.init_local_and_global"; -const callAction = (local: boolean, global: boolean) => { - return actionFn({ local, global }); +interface InitActionOptions { + local: boolean; + global: boolean; + yes?: boolean; +} + +const callAction = (local: boolean, global: boolean, yes: boolean = false) => { + return actionFn({ local, global, yes } as InitActionOptions); }; describe("setupInitCommand", () => { @@ -51,7 +58,7 @@ describe("setupInitCommand", () => { setupOptions = { program: mockProgram }; }); - it("should set up the init command with correct arguments and options", () => { + it("should set up the init command with correct arguments and options, including -y/--yes", () => { setupInitCommand(setupOptions); expect(mockProgram.command).toHaveBeenCalledWith("init"); @@ -70,9 +77,49 @@ describe("setupInitCommand", () => { mocktFn(OPT_GLOBAL_KEY), false, ); + + expect(mockProgram.option).toHaveBeenCalledWith( + "-y, --yes", + mocktFn(OPT_YES_KEY), + false, + ); + }); + + describe("action handler - Skip Confirmation Option", () => { + beforeEach(() => { + setupInitCommand(setupOptions); + }); + + it("should call handleLocalInit with skipConfirmation=false when -y is absent", async () => { + await callAction(false, false, false); + + expect(mockHandleLocalInit).toHaveBeenCalledWith(mockSpinner, false); + expect(mockHandleGlobalInit).not.toHaveBeenCalled(); + }); + + it("should call handleLocalInit with skipConfirmation=true when -y is provided", async () => { + await callAction(false, false, true); + + expect(mockHandleLocalInit).toHaveBeenCalledWith(mockSpinner, true); + expect(mockHandleGlobalInit).not.toHaveBeenCalled(); + }); + + it("should call handleGlobalInit with skipConfirmation=false when -y is absent", async () => { + await callAction(false, true, false); + + expect(mockHandleGlobalInit).toHaveBeenCalledWith(mockSpinner, false); + expect(mockHandleLocalInit).not.toHaveBeenCalled(); + }); + + it("should call handleGlobalInit with skipConfirmation=true when -y is provided", async () => { + await callAction(false, true, true); + + expect(mockHandleGlobalInit).toHaveBeenCalledWith(mockSpinner, true); + expect(mockHandleLocalInit).not.toHaveBeenCalled(); + }); }); - describe("action handler", () => { + describe("action handler - Existing Logic Checks", () => { beforeEach(() => { setupInitCommand(setupOptions); }); @@ -80,7 +127,7 @@ describe("setupInitCommand", () => { it("should default to calling handleLocalInit when no options are provided", async () => { await callAction(false, false); - expect(mockHandleLocalInit).toHaveBeenCalledWith(mockSpinner); + expect(mockHandleLocalInit).toHaveBeenCalledWith(mockSpinner, false); expect(mockHandleGlobalInit).not.toHaveBeenCalled(); expect(mockHandleErrorAndExit).not.toHaveBeenCalled(); }); @@ -88,7 +135,7 @@ describe("setupInitCommand", () => { it("should call handleLocalInit when the --local option is set", async () => { await callAction(true, false); - expect(mockHandleLocalInit).toHaveBeenCalledWith(mockSpinner); + expect(mockHandleLocalInit).toHaveBeenCalledWith(mockSpinner, false); expect(mockHandleGlobalInit).not.toHaveBeenCalled(); expect(mockHandleErrorAndExit).not.toHaveBeenCalled(); }); @@ -96,13 +143,13 @@ describe("setupInitCommand", () => { it("should call handleGlobalInit when the --global option is set", async () => { await callAction(false, true); - expect(mockHandleGlobalInit).toHaveBeenCalledWith(mockSpinner); + expect(mockHandleGlobalInit).toHaveBeenCalledWith(mockSpinner, false); expect(mockHandleLocalInit).not.toHaveBeenCalled(); expect(mockHandleErrorAndExit).not.toHaveBeenCalled(); }); - it("should throw ConfigError and call handleErrorAndExit when both --local and --global are set", async () => { - await callAction(true, true); + it("should throw ConfigError and call handleErrorAndExit when both --local and --global are set (with -y being ignored in the error path)", async () => { + await callAction(true, true, true); expect(mockHandleGlobalInit).not.toHaveBeenCalled(); expect(mockHandleLocalInit).not.toHaveBeenCalled(); diff --git a/packages/devkit/__tests__/units/commands/init/logic.spec.ts b/packages/devkit/__tests__/units/commands/init/logic.spec.ts index fbe99d9..fb0bc9c 100644 --- a/packages/devkit/__tests__/units/commands/init/logic.spec.ts +++ b/packages/devkit/__tests__/units/commands/init/logic.spec.ts @@ -54,10 +54,9 @@ vi.mock("#utils/package-manager/index.js", () => ({ getPackageManager: mockGetPackageManager, })); -const CONFIG_INIT_START_KEY = "messages.status.config_init_start"; -const CONFIG_INITIALIZED_KEY = "messages.success.config_initialized"; const CONFIRM_OVERWRITE_KEY = "commands.config.init.confirm_overwrite"; const INIT_ABORTED_KEY = "commands.config.init.aborted"; +const SKIP_YES_CONFIRM_KEY = "commands.config.init.skip_yes_confirm"; const COMMON_YES_KEY = "common.yes"; const COMMON_NO_KEY = "common.no"; @@ -72,9 +71,9 @@ describe("Init Command Logic", () => { describe("promptForStandardOverwrite", () => { const mockPath = "/path/to/config.json"; - it("should return true if the user selects 'yes'", async () => { + it("should return true if the user selects 'yes' (interactive)", async () => { mockSelect.mockResolvedValue(true); - const result = await promptForStandardOverwrite(mockPath); + const result = await promptForStandardOverwrite(mockPath, false); expect(mockSelect).toHaveBeenCalledWith({ message: mockLogger.colors.yellow( @@ -87,98 +86,87 @@ describe("Init Command Logic", () => { default: true, }); expect(result).toBe(true); + expect(mockLogger.info).not.toHaveBeenCalled(); }); - it("should return false if the user selects 'no'", async () => { + it("should return false if the user selects 'no' (interactive)", async () => { mockSelect.mockResolvedValue(false); - const result = await promptForStandardOverwrite(mockPath); + const result = await promptForStandardOverwrite(mockPath, false); expect(result).toBe(false); }); + + it("should return true immediately and log info if skipConfirmation is true", async () => { + const result = await promptForStandardOverwrite(mockPath, true); + + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + mockLogger.colors.yellow( + mocktFn(SKIP_YES_CONFIRM_KEY, { path: mockPath }), + ), + ); + expect(result).toBe(true); + }); }); describe("handleGlobalInit", () => { - const globalFileName = CONFIG_FILE_NAMES[0]; - const defaultGlobalPath = `/home/user/${globalFileName}`; - it("should create config at default global path if no existing file is found", async () => { mockFindGlobalConfigFile.mockResolvedValue(null); mockFs.pathExists.mockResolvedValue(false); - await handleGlobalInit(mockSpinner); - - expect(mockPath.join).toHaveBeenCalledWith("/home/user", globalFileName); + await handleGlobalInit(mockSpinner, false); expect(mockSaveConfig).toHaveBeenCalledTimes(1); - expect(mockSaveConfig).toHaveBeenCalledWith( - expect.objectContaining({ - settings: expect.objectContaining({ defaultPackageManager: "pnpm" }), - }), - defaultGlobalPath, - ); - - expect(mockSpinner.start).toHaveBeenCalledWith( - mockLogger.colors.cyan( - mocktFn(CONFIG_INIT_START_KEY, { path: defaultGlobalPath }), - ), - ); - expect(mockSpinner.succeed).toHaveBeenCalledWith( - mockLogger.colors.green(mocktFn(CONFIG_INITIALIZED_KEY)), - ); - expect(mockSpinner.info).not.toHaveBeenCalled(); + expect(mockSpinner.succeed).toHaveBeenCalled(); }); - it("should use existing global config path if found and overwrite is confirmed", async () => { + it("should use existing global config path if found and overwrite is confirmed (interactive)", async () => { const existingPath = "/etc/custom/config.json"; mockFindGlobalConfigFile.mockResolvedValue(existingPath); mockFs.pathExists.mockResolvedValue(true); mockSelect.mockResolvedValue(true); - await handleGlobalInit(mockSpinner); - - expect(mockSelect).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining(existingPath), - }), - ); + await handleGlobalInit(mockSpinner, false); + expect(mockSelect).toHaveBeenCalled(); expect(mockSaveConfig).toHaveBeenCalledWith( expect.any(Object), existingPath, ); expect(mockSpinner.succeed).toHaveBeenCalled(); - expect(mockSpinner.info).not.toHaveBeenCalled(); }); - it("should abort if existing global config file is found and overwrite is denied", async () => { - const existingPath = "/home/user/.cli-config.json"; + it("should abort if existing global config file is found and overwrite is denied (interactive)", async () => { mockFindGlobalConfigFile.mockResolvedValue(null); mockFs.pathExists.mockResolvedValue(true); mockSelect.mockResolvedValue(false); - await handleGlobalInit(mockSpinner); + await handleGlobalInit(mockSpinner, false); expect(mockSelect).toHaveBeenCalled(); - expect(mockSaveConfig).not.toHaveBeenCalled(); expect(mockSpinner.info).toHaveBeenCalledWith( mockLogger.colors.yellow(mocktFn(INIT_ABORTED_KEY)), ); - expect(mockSpinner.succeed).not.toHaveBeenCalled(); }); - it("should detect and set 'yarn' as the default package manager", async () => { - mockFindGlobalConfigFile.mockResolvedValue(null); - mockFs.pathExists.mockResolvedValue(false); - mockGetPackageManager.mockResolvedValue("yarn"); + it("should overwrite existing config without prompting if skipConfirmation is true", async () => { + const existingPath = "/etc/custom/config.json"; + mockFindGlobalConfigFile.mockResolvedValue(existingPath); + mockFs.pathExists.mockResolvedValue(true); - await handleGlobalInit(mockSpinner); + await handleGlobalInit(mockSpinner, true); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + mockLogger.colors.yellow( + mocktFn(SKIP_YES_CONFIRM_KEY, { path: existingPath }), + ), + ); expect(mockSaveConfig).toHaveBeenCalledWith( - expect.objectContaining({ - settings: expect.objectContaining({ defaultPackageManager: "yarn" }), - }), - defaultGlobalPath, + expect.any(Object), + existingPath, ); + expect(mockSpinner.succeed).toHaveBeenCalled(); }); }); @@ -187,6 +175,7 @@ describe("Init Command Logic", () => { const currentPath = "/project/current"; const monorepoRoot = "/project"; const projectRoot = "/project/sub"; + const existingPath = "/project/.cli-config.json"; const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(currentPath); @@ -197,88 +186,58 @@ describe("Init Command Logic", () => { const expectedPath = `${projectRoot}/${localFileName}`; - await handleLocalInit(mockSpinner); + await handleLocalInit(mockSpinner, false); - expect(mockFindUp).toHaveBeenCalledWith( - expect.objectContaining({ cwd: projectRoot, limit: projectRoot }), - ); expect(mockPath.join).toHaveBeenCalledWith(projectRoot, localFileName); - expect(mockSaveConfig).toHaveBeenCalledWith( expect.any(Object), expectedPath, ); - expect(mockSpinner.succeed).toHaveBeenCalled(); }); - it("should prioritize monorepo root for path construction", async () => { - mockFindUp.mockResolvedValue(null); - mockFindMonorepoRoot.mockResolvedValue(monorepoRoot); - mockFindProjectRoot.mockResolvedValue(projectRoot); - - const expectedPath = `${monorepoRoot}/${localFileName}`; - - await handleLocalInit(mockSpinner); - - expect(mockFindUp).toHaveBeenCalledWith( - expect.objectContaining({ cwd: monorepoRoot, limit: monorepoRoot }), - ); - expect(mockPath.join).toHaveBeenCalledWith(monorepoRoot, localFileName); - expect(mockSaveConfig).toHaveBeenCalledWith( - expect.any(Object), - expectedPath, - ); - }); - - it("should use process.cwd() if no project or monorepo root is found", async () => { - mockFindUp.mockResolvedValue(null); - mockFindMonorepoRoot.mockResolvedValue(null); - mockFindProjectRoot.mockResolvedValue(null); - - const expectedPath = `${currentPath}/${localFileName}`; - - await handleLocalInit(mockSpinner); - - expect(mockFindUp).toHaveBeenCalledWith( - expect.objectContaining({ cwd: currentPath, limit: currentPath }), - ); - expect(mockPath.join).toHaveBeenCalledWith(currentPath, localFileName); - expect(mockSaveConfig).toHaveBeenCalledWith( - expect.any(Object), - expectedPath, - ); - }); - - it("should use existing config path and save if overwrite is confirmed", async () => { - const existingPath = "/project/.cli-config.json"; + it("should use existing config path and save if overwrite is confirmed (interactive)", async () => { mockFindUp.mockResolvedValue(existingPath); mockSelect.mockResolvedValue(true); mockFindMonorepoRoot.mockResolvedValue(monorepoRoot); - await handleLocalInit(mockSpinner); + await handleLocalInit(mockSpinner, false); expect(mockSelect).toHaveBeenCalled(); expect(mockSaveConfig).toHaveBeenCalledWith( expect.any(Object), existingPath, ); - expect(mockSpinner.succeed).toHaveBeenCalled(); }); - it("should use existing config path and abort if overwrite is denied", async () => { - const existingPath = "/project/current/.cli-config.json"; + it("should use existing config path and abort if overwrite is denied (interactive)", async () => { mockFindUp.mockResolvedValue(existingPath); mockSelect.mockResolvedValue(false); - await handleLocalInit(mockSpinner); + await handleLocalInit(mockSpinner, false); expect(mockSelect).toHaveBeenCalled(); - expect(mockSaveConfig).not.toHaveBeenCalled(); expect(mockSpinner.info).toHaveBeenCalledWith( mockLogger.colors.yellow(mocktFn(INIT_ABORTED_KEY)), ); - expect(mockSpinner.succeed).not.toHaveBeenCalled(); + }); + + it("should overwrite existing config without prompting if skipConfirmation is true", async () => { + mockFindUp.mockResolvedValue(existingPath); + + await handleLocalInit(mockSpinner, true); + + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + mockLogger.colors.yellow( + mocktFn(SKIP_YES_CONFIRM_KEY, { path: existingPath }), + ), + ); + expect(mockSaveConfig).toHaveBeenCalledWith( + expect.any(Object), + existingPath, + ); + expect(mockSpinner.succeed).toHaveBeenCalled(); }); afterAll(() => { diff --git a/packages/devkit/locales/en.json b/packages/devkit/locales/en.json index 467512e..fb776ea 100644 --- a/packages/devkit/locales/en.json +++ b/packages/devkit/locales/en.json @@ -20,6 +20,11 @@ } }, "commands": { + "common": { + "options": { + "yes": "Automatically confirm yes to prompts (i.e., overwrite existing files)." + } + }, "new": { "command": { "description": "Create a new project from a template" @@ -148,7 +153,8 @@ "monorepo_location": "A monorepo root was found, but no config file exists there. Where do you want to create the new config file?", "location_current": "Create it in the current package.", "location_root": "Create it in the monorepo root.", - "aborted": "Operation aborted. No changes were made." + "aborted": "Operation aborted. No changes were made.", + "skip_yes_confirm": "Skipping confirmation: Overwriting config file at {path}." }, "list": { "command": { @@ -243,7 +249,7 @@ "template_name_updated": "✔ Template '{oldName}' updated to '{newName}' successfully!", "template_summary_updated": "Successfully updated {count} ({templateName}) template(s) from {language}!", "config_updated": "Configuration updated successfully!", - "config_initialized": "Configuration file created successfully!", + "config_initialized": "Configuration initialized successfully!", "info_collected": "System information collected.", "scaffolding_complete": "\n🚀 Project created successfully! 🎉", "next_steps": "\n| Next steps:\n", diff --git a/packages/devkit/locales/fr.json b/packages/devkit/locales/fr.json index 3393f40..020617f 100644 --- a/packages/devkit/locales/fr.json +++ b/packages/devkit/locales/fr.json @@ -20,6 +20,11 @@ } }, "commands": { + "common": { + "options": { + "yes": "Confirmer automatiquement les invites (c'est-à-dire écraser les fichiers existants)." + } + }, "new": { "command": { "description": "Créer un nouveau projet à partir d'un modèle" @@ -148,7 +153,8 @@ "monorepo_location": "Une racine de monorepo a été trouvée, mais aucun fichier de configuration n'y existe. Où voulez-vous créer le nouveau fichier de configuration ?", "location_current": "Le créer dans le paquet actuel.", "location_root": "Le créer à la racine du monorepo.", - "aborted": "Opération annulée. Aucune modification n'a été effectuée." + "aborted": "Opération annulée. Aucune modification n'a été effectuée.", + "skip_yes_confirm": "Ignorer la confirmation : Remplacement du fichier de configuration situé dans {path}." }, "list": { "command": { diff --git a/packages/devkit/src/commands/init/index.ts b/packages/devkit/src/commands/init/index.ts index ce68ea3..2db235c 100644 --- a/packages/devkit/src/commands/init/index.ts +++ b/packages/devkit/src/commands/init/index.ts @@ -5,6 +5,12 @@ import { logger, TSpinner } from "#utils/logger.js"; import { handleErrorAndExit } from "#utils/errors/handler.js"; import { handleGlobalInit, handleLocalInit } from "./logic.js"; +interface InitCommandOptions { + local: boolean; + global: boolean; + yes?: boolean; +} + export function setupInitCommand(options: SetupCommandOptions): void { const { program } = options; program @@ -13,9 +19,13 @@ export function setupInitCommand(options: SetupCommandOptions): void { .description(t("commands.config.init.command.description")) .option("-l, --local", t("commands.config.init.option.local"), false) .option("-g, --global", t("commands.config.init.option.global"), false) - .action(async (cmdOptions: { local: boolean; global: boolean }) => { - const isLocal: boolean = cmdOptions.local; - const isGlobal: boolean = cmdOptions.global; + .option("-y, --yes", t("commands.common.options.yes"), false) + .action(async (cmdOptions: InitCommandOptions) => { + const { + local: isLocal, + global: isGlobal, + yes: skipConfirmation, + } = cmdOptions; const spinner: TSpinner = logger.spinner(); try { @@ -24,9 +34,9 @@ export function setupInitCommand(options: SetupCommandOptions): void { } if (isGlobal) { - await handleGlobalInit(spinner); + await handleGlobalInit(spinner, skipConfirmation); } else { - await handleLocalInit(spinner); + await handleLocalInit(spinner, skipConfirmation); } } catch (error) { handleErrorAndExit(error, spinner); diff --git a/packages/devkit/src/commands/init/logic.ts b/packages/devkit/src/commands/init/logic.ts index 09c55b1..03d0f9d 100644 --- a/packages/devkit/src/commands/init/logic.ts +++ b/packages/devkit/src/commands/init/logic.ts @@ -17,7 +17,17 @@ import { getPackageManager } from "#utils/package-manager/index.js"; export async function promptForStandardOverwrite( filePath: string, + skipConfirmation: boolean = false, ): Promise { + if (skipConfirmation) { + logger.info( + logger.colors.yellow( + t("commands.config.init.skip_yes_confirm", { path: filePath }), + ), + ); + return true; + } + const response = await select({ message: logger.colors.yellow( t("commands.config.init.confirm_overwrite", { path: filePath }), @@ -44,14 +54,17 @@ async function getUpdatedConfig(): Promise { }; } -export async function handleGlobalInit(spinner: TSpinner): Promise { +export async function handleGlobalInit( + spinner: TSpinner, + skipConfirmation: boolean = false, +): Promise { let finalPath = await findGlobalConfigFile(); if (!finalPath) { finalPath = path.join(os.homedir(), CONFIG_FILE_NAMES[0]); } const shouldOverwrite = (await fs.pathExists(finalPath)) - ? await promptForStandardOverwrite(finalPath) + ? await promptForStandardOverwrite(finalPath, skipConfirmation) : true; if (shouldOverwrite) { @@ -70,7 +83,10 @@ export async function handleGlobalInit(spinner: TSpinner): Promise { } } -export async function handleLocalInit(spinner: TSpinner): Promise { +export async function handleLocalInit( + spinner: TSpinner, + skipConfirmation: boolean = false, +): Promise { const allConfigFiles = [...CONFIG_FILE_NAMES]; const currentPath = process.cwd(); const monorepoRoot = await findMonorepoRoot(); @@ -88,7 +104,10 @@ export async function handleLocalInit(spinner: TSpinner): Promise { if (existingConfigPath) { finalPath = existingConfigPath; - shouldOverwrite = await promptForStandardOverwrite(finalPath); + shouldOverwrite = await promptForStandardOverwrite( + finalPath, + skipConfirmation, + ); } else { finalPath = path.join(rootDir, allConfigFiles[1]); }