Skip to content

Commit 9fa4590

Browse files
fix(process-directory): Add ability to pass custom working directory to ProcessRunner (#1801)
* fix(process-directory): add option to change working directory for subprocesses * test(process-directory): custom working directory tests for ProcessRunner and SubprocessRunner * fix(process-directory): update api pins
1 parent a41f42c commit 9fa4590

File tree

6 files changed

+214
-11
lines changed

6 files changed

+214
-11
lines changed

packages/builder/api/builder.api

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2704,14 +2704,16 @@ public final class elide/tooling/runner/ProcessRunner {
27042704
}
27052705

27062706
public final class elide/tooling/runner/ProcessRunner$ProcessOptions : java/lang/Record {
2707-
public fun <init> (Lelide/tooling/runner/ProcessRunner$ProcessShell;)V
2707+
public fun <init> (Lelide/tooling/runner/ProcessRunner$ProcessShell;Ljava/nio/file/Path;)V
27082708
public final fun component1 ()Lelide/tooling/runner/ProcessRunner$ProcessShell;
2709-
public final fun copy (Lelide/tooling/runner/ProcessRunner$ProcessShell;)Lelide/tooling/runner/ProcessRunner$ProcessOptions;
2710-
public static synthetic fun copy$default (Lelide/tooling/runner/ProcessRunner$ProcessOptions;Lelide/tooling/runner/ProcessRunner$ProcessShell;ILjava/lang/Object;)Lelide/tooling/runner/ProcessRunner$ProcessOptions;
2709+
public final fun component2 ()Ljava/nio/file/Path;
2710+
public final fun copy (Lelide/tooling/runner/ProcessRunner$ProcessShell;Ljava/nio/file/Path;)Lelide/tooling/runner/ProcessRunner$ProcessOptions;
2711+
public static synthetic fun copy$default (Lelide/tooling/runner/ProcessRunner$ProcessOptions;Lelide/tooling/runner/ProcessRunner$ProcessShell;Ljava/nio/file/Path;ILjava/lang/Object;)Lelide/tooling/runner/ProcessRunner$ProcessOptions;
27112712
public fun equals (Ljava/lang/Object;)Z
27122713
public fun hashCode ()I
27132714
public final fun shell ()Lelide/tooling/runner/ProcessRunner$ProcessShell;
27142715
public fun toString ()Ljava/lang/String;
2716+
public final fun workingDirectory ()Ljava/nio/file/Path;
27152717
}
27162718

27172719
public abstract interface class elide/tooling/runner/ProcessRunner$ProcessShell {

packages/builder/src/main/kotlin/elide/tooling/runner/ProcessRunner.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public object ProcessRunner {
4242
*/
4343
@JvmRecord public data class ProcessOptions(
4444
public val shell: ProcessShell,
45+
public val workingDirectory: Path,
4546
)
4647

4748
/**
@@ -128,6 +129,9 @@ public object ProcessRunner {
128129
}
129130
}
130131
val procBuilder = ProcessBuilder(resolvedArgs).apply {
132+
// handle working directory
133+
directory(task.options.workingDirectory.toFile())
134+
131135
// handle task environment
132136
when (task.env) {
133137
// inject host environment
@@ -185,7 +189,10 @@ public object ProcessRunner {
185189
val mutEnv = env.toMutable()
186190
var executablePath: Path = exec
187191
var effectiveStreams: StdStreams = streams ?: StdStreams.Defaults
188-
var effectiveOptions: ProcessOptions = options ?: ProcessOptions(ProcessShell.None)
192+
var effectiveOptions: ProcessOptions = options ?: ProcessOptions(
193+
shell = ProcessShell.None,
194+
workingDirectory = Path.of(System.getProperty("user.dir")),
195+
)
189196

190197
return object : ProcessTaskBuilder {
191198
override var executable: Path = executablePath
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
* Copyright (c) 2024-2025 Elide Technologies, Inc.
3+
*
4+
* Licensed under the MIT license (the "License"); you may not use this file except in compliance
5+
* with the License. You may obtain a copy of the License at
6+
*
7+
* https://opensource.org/license/mit/
8+
*
9+
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
10+
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
11+
* License for the specific language governing permissions and limitations under the License.
12+
*/
13+
package elide.tooling.runner
14+
15+
import java.nio.file.Path
16+
import kotlin.test.*
17+
import elide.tooling.runner.ProcessRunner.ProcessOptions
18+
import elide.tooling.runner.ProcessRunner.ProcessShell
19+
20+
class ProcessRunnerTest {
21+
@Test fun `ProcessOptions should allow specifying a custom working directory`() {
22+
val customDir = Path.of("/tmp")
23+
val options = ProcessOptions(
24+
shell = ProcessShell.None,
25+
workingDirectory = customDir,
26+
)
27+
28+
assertEquals(customDir, options.workingDirectory)
29+
}
30+
31+
@Test fun `ProcessRunner build should accept a custom working directory`() {
32+
val customDir = Path.of("/tmp")
33+
val exec = Path.of("/bin/ls")
34+
35+
val builder = ProcessRunner.build(exec) {
36+
options = ProcessOptions(
37+
shell = ProcessShell.None,
38+
workingDirectory = customDir,
39+
)
40+
}
41+
42+
assertEquals(customDir, builder.options.workingDirectory)
43+
assertEquals(exec, builder.executable)
44+
}
45+
46+
@Test fun `ProcessRunner build should allow changing the working directory after building`() {
47+
val initialDir = Path.of("/tmp")
48+
val newDir = Path.of("/var")
49+
val exec = Path.of("/bin/echo")
50+
51+
val builder = ProcessRunner.build(exec) {
52+
options = ProcessOptions(
53+
shell = ProcessShell.None,
54+
workingDirectory = initialDir,
55+
)
56+
}
57+
58+
assertEquals(initialDir, builder.options.workingDirectory)
59+
60+
// Change the working directory
61+
builder.options = ProcessOptions(
62+
shell = ProcessShell.None,
63+
workingDirectory = newDir,
64+
)
65+
66+
assertEquals(newDir, builder.options.workingDirectory)
67+
}
68+
69+
@Test fun `ProcessRunner buildFrom should use current working directory by default`() {
70+
val exec = Path.of("/bin/echo")
71+
val args = elide.tooling.Arguments.empty()
72+
val env = elide.tooling.Environment.empty()
73+
74+
val builder = ProcessRunner.buildFrom(exec, args, env)
75+
76+
assertNotNull(builder.options.workingDirectory)
77+
assertEquals(Path.of(System.getProperty("user.dir")), builder.options.workingDirectory)
78+
}
79+
80+
@Test fun `ProcessRunner buildFrom should accept custom working directory`() {
81+
val exec = Path.of("/bin/echo")
82+
val args = elide.tooling.Arguments.empty()
83+
val env = elide.tooling.Environment.empty()
84+
val customDir = Path.of("/tmp")
85+
val customOptions = ProcessOptions(
86+
shell = ProcessShell.None,
87+
workingDirectory = customDir,
88+
)
89+
90+
val builder = ProcessRunner.buildFrom(exec, args, env, options = customOptions)
91+
92+
assertEquals(customDir, builder.options.workingDirectory)
93+
}
94+
}

packages/cli/src/main/kotlin/elide/tool/cli/cmd/repl/ToolShellCommand.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2926,7 +2926,10 @@ internal class ToolShellCommand : ProjectAwareSubcommand<ToolState, CommandConte
29262926
env = Environment.HostEnv
29272927

29282928
// activate shell support
2929-
options = ProcessRunner.ProcessOptions(shell = ProcessRunner.ProcessShell.Active)
2929+
options = ProcessRunner.ProcessOptions(
2930+
shell = ProcessRunner.ProcessShell.Active,
2931+
workingDirectory = Path.of(System.getProperty("user.dir")),
2932+
)
29302933

29312934
// copy in the provided arguments
29322935
runnableArgs.takeIf { it.isNotEmpty() }?.let { arguments ->

packages/cli/src/main/kotlin/elide/tool/exec/SubprocessRunner.kt

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ object SubprocessRunner {
5959
@JvmStatic suspend fun CommandContext.stringToTask(
6060
spec: String,
6161
shell: ProcessRunner.ProcessShell = ProcessRunner.ProcessShell.Active,
62+
workingDirectory: Path = Path.of(System.getProperty("user.dir")),
6263
): CommandLineProcessTaskBuilder {
64+
val cwd = Path.of(System.getProperty("user.dir"))
6365
val toolname = spec.substringBefore(' ')
6466
val argsStr = spec.substringAfter(' ')
6567
val argsArr = argsStr.split(' ')
@@ -69,7 +71,7 @@ object SubprocessRunner {
6971
// use an identical path to elide so that versions always match.
7072
// @TODO in jvm mode, this falls through and calls into native elide
7173
toolname == "elide" && ImageInfo.inImageCode() -> Statics.binPath
72-
toolname.startsWith(".") -> Path.of(System.getProperty("user.dir")).resolve(toolpath)
74+
toolname.startsWith(".") -> cwd.resolve(toolpath)
7375
else -> toolpath
7476
}
7577
suspend fun resolvedTool(): Path {
@@ -79,7 +81,7 @@ object SubprocessRunner {
7981
which(resolvedToolpath) ?: resolvedToolpath
8082
}
8183
}
82-
return subprocess(resolvedTool(), shell = shell) {
84+
return subprocess(resolvedTool(), shell = shell, workingDirectory = workingDirectory) {
8385
// add all cli args
8486
args.addAllStrings(argsTrimmed.toList())
8587
}
@@ -115,13 +117,12 @@ object SubprocessRunner {
115117
@JvmStatic fun CommandContext.subprocess(
116118
exec: Path,
117119
shell: ProcessRunner.ProcessShell = ProcessRunner.ProcessShell.None,
120+
workingDirectory: Path = Path.of(System.getProperty("user.dir")),
118121
block: CommandLineProcessTaskBuilder.() -> Unit,
119122
): CommandLineProcessTaskBuilder {
120123
val out = ProcessRunner.build(exec) {
121-
// by default, user shell access is off; if turned on, activate it from env
122-
if (shell != ProcessRunner.ProcessShell.None) {
123-
options = ProcessRunner.ProcessOptions(shell = shell)
124-
}
124+
// set process options with shell and working directory
125+
options = ProcessRunner.ProcessOptions(shell = shell, workingDirectory = workingDirectory)
125126

126127
// default environment overrides PATH
127128
env = Environment.HostEnv.extend(
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
* Copyright (c) 2024-2025 Elide Technologies, Inc.
3+
*
4+
* Licensed under the MIT license (the "License"); you may not use this file except in compliance
5+
* with the License. You may obtain a copy of the License at
6+
*
7+
* https://opensource.org/license/mit/
8+
*
9+
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
10+
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
11+
* License for the specific language governing permissions and limitations under the License.
12+
*/
13+
package elide.tool.exec
14+
15+
import java.nio.file.Path
16+
import kotlin.test.*
17+
import kotlinx.coroutines.Dispatchers
18+
import kotlinx.coroutines.test.runTest
19+
import elide.tool.cli.CommandContext
20+
import elide.tool.cli.state.CommandOptions
21+
import elide.tool.cli.state.CommandState
22+
import elide.tool.exec.SubprocessRunner.stringToTask
23+
import elide.tool.exec.SubprocessRunner.subprocess
24+
import elide.tooling.runner.ProcessRunner
25+
26+
class SubprocessRunnerTest {
27+
private fun testContext(): CommandContext {
28+
val options = CommandOptions.of(
29+
args = emptyList(),
30+
debug = false,
31+
verbose = false,
32+
quiet = false,
33+
pretty = false,
34+
)
35+
val state = CommandState.of(options)
36+
return CommandContext.default(state, Dispatchers.Default)
37+
}
38+
39+
@Test fun `stringToTask should use current working directory by default`() = runTest {
40+
val expectedCwd = Path.of(System.getProperty("user.dir"))
41+
val context = testContext()
42+
43+
with(context) {
44+
val builder = stringToTask("echo test")
45+
assertEquals(expectedCwd, builder.options.workingDirectory)
46+
}
47+
}
48+
49+
@Test fun `stringToTask should accept custom working directory`() = runTest {
50+
val customDir = Path.of("/tmp")
51+
val context = testContext()
52+
53+
with(context) {
54+
val builder = stringToTask("echo test", workingDirectory = customDir)
55+
assertEquals(customDir, builder.options.workingDirectory)
56+
}
57+
}
58+
59+
@Test fun `should use current working directory by default when creating subprocess`() {
60+
val expectedCwd = Path.of(System.getProperty("user.dir"))
61+
val context = testContext()
62+
val exec = Path.of("/bin/echo")
63+
64+
with(context) {
65+
val builder = subprocess(exec) {}
66+
assertEquals(expectedCwd, builder.options.workingDirectory)
67+
}
68+
}
69+
70+
@Test fun `should be able to pass custom working directory when creating subprocess`() {
71+
val customDir = Path.of("/var")
72+
val context = testContext()
73+
val exec = Path.of("/bin/ls")
74+
75+
with(context) {
76+
val builder = subprocess(exec, workingDirectory = customDir) {}
77+
assertEquals(customDir, builder.options.workingDirectory)
78+
}
79+
}
80+
81+
@Test fun `should be able to pass custom working directory through to ProcessRunner options`() {
82+
val customDir = Path.of("/usr")
83+
val context = testContext()
84+
val exec = Path.of("/bin/pwd")
85+
86+
with(context) {
87+
val builder = subprocess(exec, workingDirectory = customDir) {
88+
args.addAllStrings(listOf("arg1"))
89+
}
90+
91+
assertEquals(customDir, builder.options.workingDirectory)
92+
assertEquals(exec, builder.executable)
93+
assertTrue(builder.args.asArgumentList().contains("arg1"))
94+
}
95+
}
96+
}

0 commit comments

Comments
 (0)