diff --git a/Cargo.lock b/Cargo.lock index 59e2179..ec11a06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -131,6 +131,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "errno" version = "0.3.14" @@ -273,6 +279,7 @@ version = "0.1.0" dependencies = [ "clap", "directories", + "dotenvy", "serde_json", "tempfile", ] diff --git a/Cargo.toml b/Cargo.toml index aebaa85..62c6a54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,5 +15,6 @@ exclude = [".github", "npm", "scripts"] [dependencies] clap = { version = "4", features = ["derive"] } directories = "6" +dotenvy = "0.15.7" serde_json = "1" tempfile = "3" diff --git a/README.md b/README.md index 7c21c19..a809b67 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,14 @@ run-code node@20 snippet.ts -- first --verbose The source file is read and its contents are copied into a newly created isolated template project before execution. `run-code` does not execute inside the source file's existing project, discover that project's dependencies, or copy sibling files. Add everything the snippet needs with `--package`; arguments after `--` are passed to the snippet. +### Working directory and environment + +```bash +run-code node@20 --cwd ./fixtures --env-file ./snippet.env snippet.ts +``` + +`--cwd` changes the working directory seen by the final snippet process. Template initialization and dependency installation still happen inside the isolated temporary project. `--env-file` loads dotenv-compatible variables for the final launch and snippet process; it does not modify the current shell or earlier setup and dependency-installation steps, and loaded values are omitted from displayed commands. Both paths are resolved from the directory where `run-code` was invoked. + ### TypeScript ```bash @@ -139,7 +147,7 @@ Several related tools solve parts of this problem, but none matched the combinat ## Security -`run-code` provides environment and dependency isolation; it is not a security sandbox. Snippets and third-party dependencies run with the current user's permissions and may access local files, the network, environment variables, and credentials. +`run-code` provides environment and dependency isolation; it is not a security sandbox. Snippets and third-party dependencies run with the current user's permissions and may access local files, the network, environment variables, and credentials. Variables loaded with `--env-file` are deliberately available to the snippet, so do not pass secrets to untrusted code. Dependency installation may execute npm lifecycle scripts, Python build backends, Cargo `build.rs` scripts, or other ecosystem-specific build code. Run only trusted code and dependencies. Inspect unfamiliar packages before use, pin versions in sensitive environments, and avoid exposing unnecessary secrets. `--clean` removes only the temporary project; it cannot undo system or network side effects, and package-manager download caches remain in place. @@ -157,6 +165,8 @@ run-code skill - `FILE`: Read a source file and copy its contents into a new isolated template project. The file's existing project and sibling files are not used. When omitted, source code is read from stdin. - `ARG`: Pass arguments after `--` to the snippet process. This also works with stdin input. - `-p, --package SPEC`: Add a temporary dependency. Repeat the option to install multiple packages. Specs follow each ecosystem: Python uses `NAME==VERSION`; Node, Rust, Go, and .NET use `NAME@VERSION`. Rust also supports `NAME[@VERSION][FEATURE,...]`, such as `'tokio@1[full]'`. +- `--cwd DIR`: Set the final snippet process's working directory. Template setup and dependency installation remain isolated from this directory. +- `--env-file FILE`: Load dotenv-compatible variables for the final launch and snippet process. Values override inherited variables with the same name and are not printed in displayed commands; runner-owned isolation variables take precedence. - `--commonjs`: Run Node code as CommonJS. The default is ESM with top-level `await` support. - `--clean`: Delete the generated project after execution. Without this option, the project is retained and its path appears in the displayed command. - `--quiet`: Hide project setup, dependency installation, and command display; print only stdout/stderr from the final code process. diff --git a/README_zh.md b/README_zh.md index c9f8ff3..feaa160 100644 --- a/README_zh.md +++ b/README_zh.md @@ -46,6 +46,14 @@ run-code node@20 snippet.ts -- first --verbose 执行前,`run-code` 会读取源文件,并将内容复制到新建的隔离模板项目中。它不会在源文件所属的已有工程里运行,不会读取该工程的依赖,也不会复制同目录的其他文件。代码片段需要的依赖应通过 `--package` 明确添加;`--` 后的参数会传给代码进程。 +### 工作目录与环境变量 + +```bash +run-code node@20 --cwd ./fixtures --env-file ./snippet.env snippet.ts +``` + +`--cwd` 设置最终代码进程看到的工作目录;模板初始化和依赖安装仍然在隔离的临时项目中完成。`--env-file` 按 dotenv 语法为最终启动命令和代码进程加载变量,不修改当前 shell,也不会用于之前的初始化及依赖安装步骤;加载的值不会显示在输出的命令中。两个路径都基于调用 `run-code` 时所在的目录解析。 + ### TypeScript ```bash @@ -139,7 +147,7 @@ Codex 会自动发现这些目录中的 skill;详细约定见 [Codex Skills ## 安全 -`run-code` 提供的是环境和依赖隔离,不是安全沙箱。输入的代码和第三方依赖都以当前用户权限运行,可以访问本机文件、网络、环境变量和凭据。 +`run-code` 提供的是环境和依赖隔离,不是安全沙箱。输入的代码和第三方依赖都以当前用户权限运行,可以访问本机文件、网络、环境变量和凭据。`--env-file` 加载的变量会明确提供给代码片段,不要把密钥传给不可信代码。 依赖安装还可能执行 npm lifecycle scripts、Python build backend、Cargo `build.rs` 或其他生态的构建代码。只运行可信代码和依赖;使用陌生包前先检查官方文档与源码,敏感环境中固定版本,并避免暴露不必要的密钥。`--clean` 只删除临时项目,不会撤销代码已经产生的系统或网络副作用;各包管理器的下载缓存会继续保留。 @@ -157,6 +165,8 @@ run-code skill - `FILE`:读取源文件,并将内容复制进新的隔离模板项目;不会使用文件所在的已有工程或同目录文件。省略时从 stdin 读取代码。 - `ARG`:在 `--` 后提供并传给代码进程;stdin 输入同样可以传参。 - `-p, --package SPEC`:添加临时依赖,可重复使用以安装多个包。依赖格式遵循对应生态;Python 版本使用 `NAME==VERSION`,Node、Rust、Go 和 .NET 使用 `NAME@VERSION`。Rust 还支持 `NAME[@VERSION][FEATURE,...]`,例如 `'tokio@1[full]'`。 +- `--cwd DIR`:设置最终代码进程的工作目录;模板初始化和依赖安装仍与该目录隔离。 +- `--env-file FILE`:按 dotenv 语法为最终启动命令和代码进程加载变量;同名变量会覆盖继承的环境变量,值不会显示在输出命令中,但 runner 自身用于隔离的变量优先。 - `--commonjs`:让 Node 以 CommonJS 方式运行;默认使用支持顶层 `await` 的 ESM。 - `--clean`:运行结束后删除临时项目;不指定时保留项目,默认输出的执行命令中会包含项目路径。 - `--quiet`:隐藏项目初始化、依赖安装及命令本身,只输出最终代码进程的 stdout/stderr。 diff --git a/npm/README.md b/npm/README.md index e771768..96c2bec 100644 --- a/npm/README.md +++ b/npm/README.md @@ -12,4 +12,10 @@ Or copy a source snippet into an isolated template project and pass arguments: npx @timzhong2000/run-code node@20 snippet.ts -- first --verbose ``` +Set the snippet's working directory and load dotenv variables without changing where the isolated template and dependencies are prepared: + +```bash +npx @timzhong2000/run-code node@20 --cwd ./fixtures --env-file ./snippet.env snippet.ts +``` + The package selects the bundled binary for the current operating system and CPU architecture. diff --git a/skills/run-code-snippet/SKILL.md b/skills/run-code-snippet/SKILL.md index e309a1d..ef9117a 100644 --- a/skills/run-code-snippet/SKILL.md +++ b/skills/run-code-snippet/SKILL.md @@ -8,7 +8,7 @@ description: 临时快捷运行一段 Python、TypeScript、JavaScript、Rust、 在 Bash 或 Zsh 中使用 quoted heredoc,将代码原样交给 stdin: ```bash -run-code TOOLCHAIN[@VERSION] [--package SPEC ...] [--commonjs] [--clean] [--quiet] <<'LANG' +run-code TOOLCHAIN[@VERSION] [--package SPEC ...] [--cwd DIR] [--env-file FILE] [--commonjs] [--clean] [--quiet] <<'LANG' CODE LANG ``` @@ -16,11 +16,13 @@ LANG 也可以读取已有代码片段文件,并在 `--` 后传入参数: ```bash -run-code TOOLCHAIN[@VERSION] [--package SPEC ...] [--commonjs] [--clean] [--quiet] FILE [-- ARG ...] +run-code TOOLCHAIN[@VERSION] [--package SPEC ...] [--cwd DIR] [--env-file FILE] [--commonjs] [--clean] [--quiet] FILE [-- ARG ...] ``` 文件模式只读取 `FILE` 的内容,再将其复制进新建的隔离模板项目运行;不会在 `FILE` 所属的已有工程中执行,不会读取该工程的依赖,也不会复制相邻文件。缺少的依赖仍用 `--package` 明确添加。 +代码需要从特定目录读取临时数据时使用 `--cwd DIR`;它只改变最终代码进程的工作目录,模板和依赖安装仍保持隔离。需要环境变量时使用 dotenv 格式的 `--env-file FILE`;变量用于最终启动命令和代码进程,不修改当前 shell,也不会用于之前的初始化及依赖安装步骤,值不会显示在命令输出中。两个路径均相对于调用命令时的目录解析。不要把密钥提供给不可信代码。 + Fish 不支持 heredoc;用 `printf` 将每行代码送入 stdin: ```fish @@ -35,7 +37,7 @@ printf '%s\n' \ ```powershell @' CODE -'@ | run-code TOOLCHAIN[@VERSION] [--package SPEC ...] [--commonjs] [--clean] [--quiet] +'@ | run-code TOOLCHAIN[@VERSION] [--package SPEC ...] [--cwd DIR] [--env-file FILE] [--commonjs] [--clean] [--quiet] ``` 使用 `python`、`node`、`rust`、`go` 或 `dotnet`;`javascript` 和 `typescript` 是 `node` 的别名,`csharp` 和 `cs` 是 `dotnet` 的别名。省略版本时使用内置的最新稳定版本。Python 依赖版本使用 `NAME==VERSION`;Rust 依赖可用 `NAME[@VERSION][FEATURE,...]` 指定 Cargo features;.NET 依赖使用 NuGet 的 `NAME[@VERSION]`。Node 默认以 ESM 统一运行 JavaScript/TypeScript,仅在代码明确依赖 CommonJS 时添加 `--commonjs`。C# 使用 .NET 10+ file-based app。stdin 模式下,Python 和 Node 未指定 `--package` 时直接执行;文件模式始终创建隔离模板项目。需要项目时默认保留;仅在明确只需一次结果时添加 `--clean`,仅需代码本身的输出时添加 `--quiet`。直接调用工具,不自行创建项目或检查运行环境。 @@ -45,6 +47,8 @@ CODE ```bash run-code node@20 snippet.ts -- first --verbose +run-code python@3.14 --cwd ./fixtures --env-file ./snippet.env snippet.py + run-code node@20 --package zod@4 --clean <<'TS' import { z } from "zod"; console.log(z.object({ id: z.number() }).parse({ id: 1 })); diff --git a/src/cli.rs b/src/cli.rs index 8ea1e75..89c93d0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -101,6 +101,14 @@ pub struct Cli { #[arg(last = true, value_name = "ARG")] pub args: Vec, + /// Working directory for the final code process + #[arg(long, value_name = "DIR")] + pub cwd: Option, + + /// Load environment variables for the final code process + #[arg(long, value_name = "FILE")] + pub env_file: Option, + /// Dependency specification; Python supports NAME==VERSION #[arg(short = 'p', long = "package", value_name = "SPEC")] pub packages: Vec, @@ -140,6 +148,8 @@ impl Cli { || self.quiet || self.source.is_some() || !self.args.is_empty() + || self.cwd.is_some() + || self.env_file.is_some() { Some("the skill command does not accept execution arguments".into()) } else { @@ -164,7 +174,7 @@ mod tests { .get_arguments() .filter(|arg| arg.get_id() != "help" && arg.get_id() != "version") .count(); - assert_eq!(visible, 7); + assert_eq!(visible, 9); } #[test] @@ -190,6 +200,21 @@ mod tests { assert_eq!(cli.args, ["first"]); } + #[test] + fn execution_context_options_accept_paths() { + let cli = Cli::try_parse_from([ + "run-code", + "python", + "--cwd", + "data", + "--env-file", + "snippet.env", + ]) + .unwrap(); + assert_eq!(cli.cwd, Some(PathBuf::from("data"))); + assert_eq!(cli.env_file, Some(PathBuf::from("snippet.env"))); + } + #[test] fn skill_is_a_command_without_a_toolchain() { let cli = Cli::try_parse_from(["run-code", "skill"]).unwrap(); diff --git a/src/execution.rs b/src/execution.rs new file mode 100644 index 0000000..09fea41 --- /dev/null +++ b/src/execution.rs @@ -0,0 +1,130 @@ +use crate::cli::Cli; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Default)] +pub struct ExecutionContext { + working_directory: Option, + environment: Vec<(String, String)>, +} + +impl ExecutionContext { + pub fn load(cli: &Cli) -> Result { + let working_directory = cli.cwd.as_deref().map(canonical_directory).transpose()?; + let environment = cli + .env_file + .as_deref() + .map(read_env_file) + .transpose()? + .unwrap_or_default(); + Ok(Self { + working_directory, + environment, + }) + } + + pub fn cwd_or<'a>(&'a self, fallback: &'a Path) -> &'a Path { + self.working_directory.as_deref().unwrap_or(fallback) + } + + pub fn has_custom_cwd(&self) -> bool { + self.working_directory.is_some() + } + + pub fn environment(&self) -> &[(String, String)] { + &self.environment + } +} + +fn canonical_directory(path: &Path) -> Result { + let resolved = path.canonicalize().map_err(|error| { + format!( + "failed to resolve working directory {}: {error}", + path.display() + ) + })?; + if !resolved.is_dir() { + return Err(format!( + "working directory is not a directory: {}", + path.display() + )); + } + Ok(resolved) +} + +fn read_env_file(path: &Path) -> Result, String> { + let entries = dotenvy::from_path_iter(path).map_err(|error| { + format!( + "failed to read environment file {}: {error}", + path.display() + ) + })?; + entries + .map(|entry| { + entry.map_err(|error| { + format!( + "failed to parse environment file {}: {error}", + path.display() + ) + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + use std::fs; + + #[test] + fn loads_quoted_and_escaped_environment_values_without_mutating_process() { + let process_value_before = std::env::var_os("RUN_CODE_CONTEXT_TEST"); + let dir = tempfile::tempdir().unwrap(); + let env_file = dir.path().join("snippet.env"); + fs::write( + &env_file, + "RUN_CODE_CONTEXT_TEST='hello world'\nESCAPED=hello\\ world\n", + ) + .unwrap(); + let cli = Cli::try_parse_from([ + "run-code", + "python", + "--cwd", + dir.path().to_str().unwrap(), + "--env-file", + env_file.to_str().unwrap(), + ]) + .unwrap(); + + let context = ExecutionContext::load(&cli).unwrap(); + assert_eq!( + context.cwd_or(Path::new("fallback")), + dir.path().canonicalize().unwrap() + ); + assert_eq!( + context.environment(), + [ + ("RUN_CODE_CONTEXT_TEST".into(), "hello world".into()), + ("ESCAPED".into(), "hello world".into()) + ] + ); + assert_eq!( + std::env::var_os("RUN_CODE_CONTEXT_TEST"), + process_value_before + ); + } + + #[test] + fn rejects_missing_or_non_directory_working_directories() { + let missing = + Cli::try_parse_from(["run-code", "python", "--cwd", "run-code-missing-directory"]) + .unwrap(); + assert!(ExecutionContext::load(&missing).is_err()); + + let file = tempfile::NamedTempFile::new().unwrap(); + let not_directory = + Cli::try_parse_from(["run-code", "python", "--cwd", file.path().to_str().unwrap()]) + .unwrap(); + assert!(ExecutionContext::load(¬_directory).is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index 6f89d5e..18804c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod cli; +mod execution; mod process; mod runner; mod skill; @@ -7,6 +8,7 @@ mod util; use clap::{Parser, error::ErrorKind}; use cli::{Cli, Command}; +use execution::ExecutionContext; fn main() { let cli = match Cli::try_parse() { @@ -28,6 +30,11 @@ fn main() { return; } + let execution = match ExecutionContext::load(&cli) { + Ok(execution) => execution, + Err(error) => return exit_with_error(&error, 2), + }; + let code = match source::read(cli.source.as_deref()) { Ok(code) => code, Err(error) => return exit_with_error(&error, 1), @@ -41,7 +48,7 @@ fn main() { return exit_with_error(&format!("{input} did not contain source code"), 2); } - match runner::run_snippet(&cli, &code) { + match runner::run_snippet(&cli, &execution, &code) { Ok(exit_code) => exit(exit_code), Err(error) => { eprintln!("run-code: {}", error.message); diff --git a/src/process.rs b/src/process.rs index 0536942..8b82085 100644 --- a/src/process.rs +++ b/src/process.rs @@ -77,12 +77,13 @@ pub fn run_final( args: &[String], cwd: Option<&Path>, env: &[(String, String)], + unprinted_env: &[(String, String)], quiet: bool, ) -> Result { if !quiet { eprintln!("+ {}", display_command(program, args, cwd, env)); } - let status = command(program, args, cwd, env) + let status = command(program, args, cwd, env, unprinted_env) .status() .map_err(|error| RunFailure::start(program, error))?; Ok(ProcessResult { @@ -100,7 +101,7 @@ fn run_setup( ) -> Result { if !quiet { eprintln!("+ {}", display_command(program, args, cwd, env)); - let status = command(program, args, cwd, env) + let status = command(program, args, cwd, env, &[]) .status() .map_err(|error| RunFailure::start(program, error))?; return Ok(ProcessResult { @@ -109,7 +110,7 @@ fn run_setup( }); } - let output = command(program, args, cwd, env) + let output = command(program, args, cwd, env, &[]) .output() .map_err(|error| RunFailure::start(program, error))?; if !output.status.success() { @@ -126,12 +127,18 @@ fn command( args: &[String], cwd: Option<&Path>, env: &[(String, String)], + unprinted_env: &[(String, String)], ) -> Command { let mut command = Command::new(program); command.args(args); if let Some(cwd) = cwd { command.current_dir(cwd); } + for (key, value) in unprinted_env { + command.env(key, value); + } + // Explicit runner variables are applied last so isolation settings such as + // GOWORK=off cannot be replaced by an environment file. for (key, value) in env { command.env(key, value); } @@ -199,11 +206,43 @@ mod tests { #[test] fn missing_program_is_reported_structurally() { - let error = - run_final("run-code-program-that-does-not-exist", &[], None, &[], true).unwrap_err(); + let error = run_final( + "run-code-program-that-does-not-exist", + &[], + None, + &[], + &[], + true, + ) + .unwrap_err(); assert_eq!( error.missing_program.as_deref(), Some("run-code-program-that-does-not-exist") ); } + + #[test] + fn unprinted_environment_is_applied_but_not_displayed() { + let shown = display_command("tool", &[], None, &[]); + assert_eq!(shown, "tool"); + + let command = command( + "tool", + &[], + None, + &[("VISIBLE".into(), "runner".into())], + &[ + ("SECRET".into(), "hidden".into()), + ("VISIBLE".into(), "file".into()), + ], + ); + assert_eq!(command.get_envs().count(), 2); + assert_eq!( + command + .get_envs() + .find(|(key, _)| *key == "VISIBLE") + .and_then(|(_, value)| value), + Some(std::ffi::OsStr::new("runner")) + ); + } } diff --git a/src/runner/dotnet.rs b/src/runner/dotnet.rs index 61f62ac..0287904 100644 --- a/src/runner/dotnet.rs +++ b/src/runner/dotnet.rs @@ -1,7 +1,8 @@ use super::Backend; use crate::cli::ToolchainSpec; +use crate::execution::ExecutionContext; use crate::process::{RunFailure, run_final}; -use crate::util::{strings, write_source}; +use crate::util::{path_text, strings, write_source}; use std::path::Path; pub struct DotnetBackend<'a> { @@ -24,19 +25,33 @@ impl Backend for DotnetBackend<'_> { dir: &Path, code: &str, arguments: &[String], + execution: &ExecutionContext, quiet: bool, ) -> Result { let source = source_with_packages(code, self.packages)?; - write_source(&dir.join("snippet.cs"), &source)?; + let source_path = dir.join("snippet.cs"); + write_source(&source_path, &source)?; let toolchain = format!("dotnet@{}", self.toolchain.version); let mut args = vec!["exec".into(), toolchain]; - args.extend(strings(&["--", "dotnet", "run", "snippet.cs"])); + args.extend(strings(&["--", "dotnet", "run"])); + args.push(if execution.has_custom_cwd() { + path_text(&source_path) + } else { + "snippet.cs".into() + }); if !arguments.is_empty() { args.push("--".into()); args.extend(arguments.iter().cloned()); } - let result = run_final("mise", &args, Some(dir), &[], quiet)?; + let result = run_final( + "mise", + &args, + Some(execution.cwd_or(dir)), + &[], + execution.environment(), + quiet, + )?; Ok(result.exit_code.unwrap_or(1)) } } diff --git a/src/runner/go.rs b/src/runner/go.rs index 8bd1585..7496ddc 100644 --- a/src/runner/go.rs +++ b/src/runner/go.rs @@ -1,8 +1,9 @@ use super::Backend; use crate::cli::ToolchainSpec; +use crate::execution::ExecutionContext; use crate::process::{RunFailure, run_checked, run_checked_hidden, run_final}; -use crate::util::{strings, write_source}; -use std::path::Path; +use crate::util::{path_text, strings, write_source}; +use std::path::{Path, PathBuf}; pub struct GoBackend<'a> { toolchain: &'a ToolchainSpec, @@ -22,6 +23,7 @@ impl Backend for GoBackend<'_> { dir: &Path, code: &str, arguments: &[String], + execution: &ExecutionContext, quiet: bool, ) -> Result { let toolchain = format!("go@{}", self.toolchain.version); @@ -56,10 +58,42 @@ impl Backend for GoBackend<'_> { quiet, )?; } - let mut args = vec!["exec".into(), toolchain.clone()]; - args.extend(strings(&["--", "go", "run", "."])); - args.extend(arguments.iter().cloned()); - let result = run_final("mise", &args, Some(dir), &env, quiet)?; + let result = if execution.has_custom_cwd() { + let binary = snippet_binary(dir); + let mut build = vec!["exec".into(), toolchain.clone()]; + build.extend(strings(&["--", "go", "build", "-o"])); + build.push(path_text(&binary)); + build.push(".".into()); + run_checked_hidden("build Go snippet", "mise", &build, Some(dir), &env)?; + run_final( + &path_text(&binary), + arguments, + Some(execution.cwd_or(dir)), + &[], + execution.environment(), + quiet, + )? + } else { + let mut args = vec!["exec".into(), toolchain.clone()]; + args.extend(strings(&["--", "go", "run", "."])); + args.extend(arguments.iter().cloned()); + run_final( + "mise", + &args, + Some(dir), + &env, + execution.environment(), + quiet, + )? + }; Ok(result.exit_code.unwrap_or(1)) } } + +fn snippet_binary(dir: &Path) -> PathBuf { + dir.join(if cfg!(windows) { + "run-code-snippet.exe" + } else { + "run-code-snippet" + }) +} diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 4b358c7..8cda569 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -5,6 +5,7 @@ mod python; mod rust; use crate::cli::{Cli, ToolchainKind}; +use crate::execution::ExecutionContext; use crate::process::RunFailure; use std::path::{Path, PathBuf}; use tempfile::{Builder, TempDir}; @@ -17,6 +18,7 @@ trait Backend { &self, _code: &str, _arguments: &[String], + _execution: &ExecutionContext, _quiet: bool, ) -> Result { Err(RunFailure::message( @@ -28,6 +30,7 @@ trait Backend { project_dir: &Path, code: &str, arguments: &[String], + execution: &ExecutionContext, quiet: bool, ) -> Result; } @@ -79,7 +82,7 @@ impl From for RunError { } } -pub fn run_snippet(cli: &Cli, code: &str) -> Result { +pub fn run_snippet(cli: &Cli, execution: &ExecutionContext, code: &str) -> Result { let backend = backend_for(cli).map_err(|message| RunError { message, hint: None, @@ -89,7 +92,7 @@ pub fn run_snippet(cli: &Cli, code: &str) -> Result { // runs in, or discovers dependencies from, the source file's project. if should_run_direct(cli, backend.as_ref()) { return backend - .run_direct(code, &cli.args, cli.quiet) + .run_direct(code, &cli.args, execution, cli.quiet) .map_err(Into::into); } @@ -108,7 +111,7 @@ pub fn run_snippet(cli: &Cli, code: &str) -> Result { }; backend - .prepare(&project_dir, code, &cli.args, cli.quiet) + .prepare(&project_dir, code, &cli.args, execution, cli.quiet) .map_err(Into::into) } diff --git a/src/runner/node.rs b/src/runner/node.rs index 86b14fa..2216c9c 100644 --- a/src/runner/node.rs +++ b/src/runner/node.rs @@ -1,7 +1,8 @@ use super::Backend; use crate::cli::ToolchainSpec; +use crate::execution::ExecutionContext; use crate::process::{RunFailure, run_checked, run_checked_hidden, run_final}; -use crate::util::{strings, write_source}; +use crate::util::{path_text, strings, write_source}; use serde_json::json; use std::fs; use std::path::Path; @@ -36,7 +37,13 @@ impl Backend for NodeBackend<'_> { self.packages.is_empty() } - fn run_direct(&self, code: &str, arguments: &[String], quiet: bool) -> Result { + fn run_direct( + &self, + code: &str, + arguments: &[String], + execution: &ExecutionContext, + quiet: bool, + ) -> Result { let suffix = if self.commonjs { ".cts" } else { ".mts" }; let source = Builder::new() .prefix("run-code-") @@ -60,8 +67,15 @@ impl Backend for NodeBackend<'_> { source.path().to_string_lossy().into_owned(), ]; args.extend(arguments.iter().cloned()); - let cwd = std::env::temp_dir(); - let result = run_final("vp", &args, Some(&cwd), &[], quiet)?; + let fallback = std::env::temp_dir(); + let result = run_final( + "vp", + &args, + Some(execution.cwd_or(&fallback)), + &[], + execution.environment(), + quiet, + )?; Ok(result.exit_code.unwrap_or(1)) } @@ -70,6 +84,7 @@ impl Backend for NodeBackend<'_> { dir: &Path, code: &str, arguments: &[String], + execution: &ExecutionContext, quiet: bool, ) -> Result { let file = self.file_name(); @@ -121,12 +136,39 @@ impl Backend for NodeBackend<'_> { quiet, )?; - let final_args = final_command(file, arguments, quiet); - let result = run_final("vp", &final_args, Some(dir), &[], quiet)?; + let final_args = if execution.has_custom_cwd() { + final_command_from(dir, &source, version, arguments) + } else { + final_command(file, arguments, quiet) + }; + let result = run_final( + "vp", + &final_args, + Some(execution.cwd_or(dir)), + &[], + execution.environment(), + quiet, + )?; Ok(result.exit_code.unwrap_or(1)) } } +fn final_command_from( + project_dir: &Path, + source: &Path, + version: &str, + arguments: &[String], +) -> Vec { + let tsx_cli = project_dir.join("node_modules/tsx/dist/cli.mjs"); + let mut command = strings(&["env", "exec", "--node"]); + command.push(version.into()); + command.push("node".into()); + command.push(path_text(&tsx_cli)); + command.push(path_text(source)); + command.extend(arguments.iter().cloned()); + command +} + fn final_command(file: &str, arguments: &[String], quiet: bool) -> Vec { let mut command = if quiet { strings(&["exec", "--", "tsx", file]) @@ -153,4 +195,18 @@ mod tests { ["exec", "--", "tsx", "snippet.ts", "first", "--flag"] ); } + + #[test] + fn custom_cwd_command_uses_absolute_project_tools_and_source() { + let project = Path::new("template"); + let source = project.join("snippet.ts"); + let command = final_command_from(project, &source, "20", &["first".into()]); + assert_eq!(&command[..5], ["env", "exec", "--node", "20", "node"]); + assert_eq!( + command[5], + path_text(&project.join("node_modules/tsx/dist/cli.mjs")) + ); + assert_eq!(command[6], path_text(&source)); + assert_eq!(command[7], "first"); + } } diff --git a/src/runner/python.rs b/src/runner/python.rs index e102140..f25f03c 100644 --- a/src/runner/python.rs +++ b/src/runner/python.rs @@ -1,5 +1,6 @@ use super::Backend; use crate::cli::ToolchainSpec; +use crate::execution::ExecutionContext; use crate::process::{RunFailure, run_checked, run_checked_hidden, run_final}; use crate::util::{path_text, strings, write_source}; use std::path::Path; @@ -22,15 +23,28 @@ impl Backend for PythonBackend<'_> { self.packages.is_empty() } - fn run_direct(&self, code: &str, arguments: &[String], quiet: bool) -> Result { + fn run_direct( + &self, + code: &str, + arguments: &[String], + execution: &ExecutionContext, + quiet: bool, + ) -> Result { let mut args = strings(&["run", "--quiet"]); args.extend(strings(&["--isolated", "--managed-python", "--python"])); args.push(self.toolchain.version.clone()); args.extend(strings(&["python", "-c"])); args.push(code.into()); args.extend(arguments.iter().cloned()); - let cwd = std::env::temp_dir(); - let result = run_final("uv", &args, Some(&cwd), &[], quiet)?; + let fallback = std::env::temp_dir(); + let result = run_final( + "uv", + &args, + Some(execution.cwd_or(&fallback)), + &[], + execution.environment(), + quiet, + )?; Ok(result.exit_code.unwrap_or(1)) } @@ -39,6 +53,7 @@ impl Backend for PythonBackend<'_> { dir: &Path, code: &str, arguments: &[String], + execution: &ExecutionContext, quiet: bool, ) -> Result { let version = &self.toolchain.version; @@ -75,11 +90,23 @@ impl Backend for PythonBackend<'_> { if quiet { args.push("--quiet".into()); } + if execution.has_custom_cwd() { + args.extend(strings(&["--project"])); + args.push(path_text(dir)); + } args.extend(strings(&["--managed-python", "--python"])); args.push(version.clone()); - args.extend(strings(&["python", "main.py"])); + args.push("python".into()); + args.push(path_text(&source)); args.extend(arguments.iter().cloned()); - let result = run_final("uv", &args, Some(dir), &[], quiet)?; + let result = run_final( + "uv", + &args, + Some(execution.cwd_or(dir)), + &[], + execution.environment(), + quiet, + )?; Ok(result.exit_code.unwrap_or(1)) } } diff --git a/src/runner/rust.rs b/src/runner/rust.rs index acf3e3a..841fdbb 100644 --- a/src/runner/rust.rs +++ b/src/runner/rust.rs @@ -1,5 +1,6 @@ use super::Backend; use crate::cli::ToolchainSpec; +use crate::execution::ExecutionContext; use crate::process::{RunFailure, run_checked, run_checked_hidden, run_final}; use crate::util::{path_text, strings, write_source}; use directories::ProjectDirs; @@ -24,6 +25,7 @@ impl Backend for RustBackend<'_> { dir: &Path, code: &str, arguments: &[String], + execution: &ExecutionContext, quiet: bool, ) -> Result { let version = &self.toolchain.version; @@ -67,11 +69,22 @@ impl Backend for RustBackend<'_> { let mut args = strings(&["run", "--install"]); args.push(version.clone()); args.extend(strings(&["cargo", "run", "--quiet"])); + if execution.has_custom_cwd() { + args.extend(strings(&["--manifest-path"])); + args.push(path_text(&dir.join("Cargo.toml"))); + } if !arguments.is_empty() { args.push("--".into()); args.extend(arguments.iter().cloned()); } - let result = run_final("rustup", &args, Some(dir), &env, quiet)?; + let result = run_final( + "rustup", + &args, + Some(execution.cwd_or(dir)), + &env, + execution.environment(), + quiet, + )?; Ok(result.exit_code.unwrap_or(1)) } }