diff --git a/.changeset/cute-lizards-knock.md b/.changeset/cute-lizards-knock.md new file mode 100644 index 00000000000..a2a6c74349e --- /dev/null +++ b/.changeset/cute-lizards-knock.md @@ -0,0 +1,5 @@ +--- +"@effect/cli": patch +--- + +handle executable paths with spaces in CLI arguments diff --git a/packages/cli/src/internal/cliApp.ts b/packages/cli/src/internal/cliApp.ts index 2de64c447a9..ec487af9db8 100644 --- a/packages/cli/src/internal/cliApp.ts +++ b/packages/cli/src/internal/cliApp.ts @@ -102,7 +102,8 @@ export const run = dual< InternalCommand.getHelp(e.command, config) ), execute, - config + config, + args ) ) : Option.none() @@ -115,7 +116,7 @@ export const run = dual< }) } case "BuiltIn": { - return handleBuiltInOption(self, executable, filteredArgs, directive.option, execute, config).pipe( + return handleBuiltInOption(self, executable, filteredArgs, directive.option, execute, config, args).pipe( Effect.catchSome((e) => InternalValidationError.isValidationError(e) ? Option.some(Effect.zipRight(printDocs(e.error), Effect.fail(e))) @@ -151,7 +152,8 @@ const handleBuiltInOption = ( args: ReadonlyArray, builtIn: BuiltInOptions.BuiltInOptions, execute: (a: A) => Effect.Effect, - config: CliConfig.CliConfig + config: CliConfig.CliConfig, + originalArgs: ReadonlyArray ): Effect.Effect< void, E | ValidationError.ValidationError, @@ -159,14 +161,17 @@ const handleBuiltInOption = ( > => { switch (builtIn._tag) { case "SetLogLevel": { - const nextArgs = executable.split(/\s+/) - // Filter out the log level option before re-executing the command + // Use first 2 elements from originalArgs (runtime + script) to preserve paths with spaces + // Filter out --log-level from args before re-executing + const baseArgs = Arr.take(originalArgs, 2) + const filteredArgs: Array = [] for (let i = 0; i < args.length; i++) { if (isLogLevelArg(args[i]) || isLogLevelArg(args[i - 1])) { continue } - nextArgs.push(args[i]) + filteredArgs.push(args[i]) } + const nextArgs = Arr.appendAll(baseArgs, filteredArgs) return run(self, nextArgs, execute).pipe( Logger.withMinimumLogLevel(builtIn.level) ) @@ -269,10 +274,11 @@ const handleBuiltInOption = ( active: "yes", inactive: "no" }).pipe(Effect.flatMap((shouldRunCommand) => { - const finalArgs = pipe( - Arr.drop(args, 1), - Arr.prependAll(executable.split(/\s+/)) - ) + // Use first 2 elements from originalArgs (runtime + script) to preserve paths with spaces + // This mimics executable.split() behavior but without breaking Windows paths + const baseArgs = Arr.take(originalArgs, 2) + const wizardArgs = Arr.drop(args, 1) + const finalArgs = Arr.appendAll(baseArgs, wizardArgs) return shouldRunCommand ? Console.log().pipe(Effect.zipRight(run(self, finalArgs, execute))) : Effect.void diff --git a/packages/cli/test/CliApp.test.ts b/packages/cli/test/CliApp.test.ts index e0b2317ef65..de5dc108ee6 100644 --- a/packages/cli/test/CliApp.test.ts +++ b/packages/cli/test/CliApp.test.ts @@ -1,3 +1,4 @@ +import * as Args from "@effect/cli/Args" import type * as CliApp from "@effect/cli/CliApp" import * as CliConfig from "@effect/cli/CliConfig" import * as Command from "@effect/cli/Command" @@ -113,5 +114,21 @@ describe("CliApp", () => { yield* cli(["node", "logging.js", "--log-level=debug"]) expect(logLevel).toEqual(LogLevel.Debug) }).pipe(runEffect)) + + it("should handle paths with spaces when using --log-level", () => + Effect.gen(function*() { + let executedValue: string | undefined = undefined + const cmd = Command.make("test", { value: Args.text() }, ({ value }) => + Effect.sync(() => { + executedValue = value + })) + const cli = Command.run(cmd, { + name: "Test", + version: "1.0.0" + }) + // Simulate Windows path with spaces (e.g., "C:\Program Files\nodejs\node.exe") + yield* cli(["C:\\Program Files\\node.exe", "C:\\My Scripts\\test.js", "--log-level", "info", "hello"]) + expect(executedValue).toEqual("hello") + }).pipe(runEffect)) }) })