Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cute-lizards-knock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect/cli": patch
---

handle executable paths with spaces in CLI arguments
29 changes: 19 additions & 10 deletions packages/cli/src/internal/cliApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ export const run = dual<
InternalCommand.getHelp(e.command, config)
),
execute,
config
config,
args
)
)
: Option.none()
Expand All @@ -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)))
Expand Down Expand Up @@ -151,22 +152,26 @@ const handleBuiltInOption = <R, E, A>(
args: ReadonlyArray<string>,
builtIn: BuiltInOptions.BuiltInOptions,
execute: (a: A) => Effect.Effect<void, E, R>,
config: CliConfig.CliConfig
config: CliConfig.CliConfig,
originalArgs: ReadonlyArray<string>
): Effect.Effect<
void,
E | ValidationError.ValidationError,
R | CliApp.CliApp.Environment | Terminal.Terminal
> => {
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 = getExecutableArgs(originalArgs)
const filteredArgs: Array<string> = []
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)
)
Expand Down Expand Up @@ -269,10 +274,11 @@ const handleBuiltInOption = <R, E, A>(
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 = getExecutableArgs(originalArgs)
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
Expand Down Expand Up @@ -339,6 +345,9 @@ const getWizardPrefix = (
return Arr.appendAll(rootCommand.split(/\s+/), args)
}

const getExecutableArgs = (args: ReadonlyArray<string>): ReadonlyArray<string> =>
Arr.take(args, 2) as ReadonlyArray<string>

const renderWizardArgs = (args: ReadonlyArray<string>) => {
const params = pipe(
Arr.filter(args, (param) => param.length > 0),
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/test/CliApp.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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))
})
})
Loading