From 88af3a5a24bc6a0cb4691f08a808d5260bab983e Mon Sep 17 00:00:00 2001 From: "Clelia (Astra) Bertelli" Date: Mon, 27 Jul 2026 12:37:02 +0200 Subject: [PATCH 1/2] feat: add support for cwd in jake --- src/load.rs | 65 +++++++++++++++++++++++++++++++++++---------- src/main.rs | 47 +++++++++++++++++++++++++++++--- src/models.rs | 29 +++++++++++++++++--- src/package_json.rs | 29 +++++++++++++++----- 4 files changed, 143 insertions(+), 27 deletions(-) diff --git a/src/load.rs b/src/load.rs index d8b69dc..03130c6 100644 --- a/src/load.rs +++ b/src/load.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; use std::env; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::str::FromStr; use crate::code_blocks::ScriptLanguage; @@ -34,30 +34,40 @@ fn resolve_jakefile_path() -> Result { )) } -pub fn parse_jakefile(file_path: Option<&str>) -> Result { +pub fn parse_jakefile(file_path: Option<&str>) -> Result<(Table, Option)> { let owned_path; - let path = match file_path { + let (path, cwd) = match file_path { None => { let p = resolve_jakefile_path()?; owned_path = p; - Path::new(&owned_path) + (Path::new(&owned_path), None) } Some(p) => { owned_path = p.to_string(); - Path::new(&owned_path) + ( + Path::new(&owned_path), + Some(PathBuf::from(&owned_path).parent().map_or( + env::current_dir()?.canonicalize()?, + |p| { + p.to_path_buf() + .canonicalize() + .expect("Should be able to canonicalize path") + }, + )), + ) } }; if path.exists() { let content = std::fs::read_to_string(path)?; let table = content.parse::
()?; - Ok(table) + Ok((table, cwd.map(|c| c.to_string_lossy().to_string()))) } else { Err(anyhow!("jakefile.toml does not exist")) } } pub fn list_jakefile_tasks(file_path: Option<&str>) -> Result> { - let parsed = parse_jakefile(file_path)?; + let (parsed, _) = parse_jakefile(file_path)?; let mut commands: Vec = vec![]; for key in parsed.keys() { @@ -195,20 +205,20 @@ pub fn execute_command( } else { flags.split_whitespace().collect() }; - let available_tasks = parse_jakefile(jakefile_path)?; + let (available_tasks, cwd) = parse_jakefile(jakefile_path)?; let mut execution_order: Vec = vec![]; let mut state_map: HashMap = HashMap::new(); resolve_dependencies(&available_tasks, task, &mut execution_order, &mut state_map)?; for command in &execution_order[..execution_order.len() - 1] { - executor.execute(command.as_str(), load_env)?; + executor.execute(command.as_str(), load_env, cwd.clone())?; } // the actual command to execute is the last one in the execution order let cmd = &execution_order[execution_order.len() - 1]; if cmd_options.is_empty() { - executor.execute(cmd, load_env)?; + executor.execute(cmd, load_env, cwd)?; } else { let full_command = cmd.to_owned() + " " + cmd_options.join(" ").as_str(); - executor.execute(&full_command, load_env)?; + executor.execute(&full_command, load_env, cwd)?; } Ok(()) } @@ -219,7 +229,7 @@ pub fn execute_default_command( executor: &dyn Executor, load_env: bool, ) -> Result<()> { - let available_tasks = parse_jakefile(jakefile_path)?; + let (available_tasks, _) = parse_jakefile(jakefile_path)?; if available_tasks.contains_key("default") { execute_command(jakefile_path, "default", flags, executor, load_env)?; } else { @@ -251,7 +261,12 @@ mod tests { } impl Executor for MockCommandExecutor { - fn execute(&self, full_command: &str, _load_env: bool) -> anyhow::Result<()> { + fn execute( + &self, + full_command: &str, + _load_env: bool, + _cwd: Option, + ) -> anyhow::Result<()> { std::fs::write("test.mock", full_command)?; Ok(()) } @@ -266,7 +281,8 @@ mod tests { println!("An error occurred: {}", e.to_string()); assert!(false); // fail here } - Ok(t) => { + Ok((t, c)) => { + assert!(c.is_some_and(|s| s.contains("testfiles"))); assert!(t.contains_key("say-hello")); assert!(t.contains_key("say-hello-back")); assert!(t.contains_key("say-bye")); @@ -725,4 +741,25 @@ mod tests { .starts_with("Command exited with a non-zero exit code:") })); } + + #[test] + #[serial] + fn test_resolves_cwd_from_path() { + let (_, cwd) = + parse_jakefile(Some("testfiles/jakefile.toml")).expect("Should be able to resolve cwd"); + let expected_cwd = PathBuf::from("./testfiles") + .canonicalize() + .expect("Should be able to canonicalize"); + assert!(cwd.is_some_and(|p| { + println!("{:?} {:?}", p, expected_cwd); + PathBuf::from(p) == expected_cwd + })); + } + + #[test] + #[serial] + fn test_cwd_resolves_to_none() { + let (_, cwd) = parse_jakefile(None).expect("Should be able to resolve cwd"); + assert!(cwd.is_none()); + } } diff --git a/src/main.rs b/src/main.rs index e67450b..37a4b85 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use crate::{ initialize::write_jakefile, load::{execute_command, execute_default_command, is_posix_os, list_jakefile_tasks}, @@ -46,6 +48,12 @@ struct Args { /// Print commands that would be run without executing them #[arg(long, default_value_t = false)] dry_run: bool, + + /// Directory under which jakefile.toml can be found + /// and that should be used as current working directory + /// for the command execution + #[arg(long, default_value = None)] + cwd: Option, } fn main() -> anyhow::Result<()> { @@ -72,7 +80,13 @@ fn main() -> anyhow::Result<()> { }; if args.js { if let Some(script_name) = args.task { - execute_script(None, script_name, args.env, executor.as_ref())?; + let package_json_path = args.cwd.map(|p| { + PathBuf::from(p) + .join("package.json") + .to_string_lossy() + .to_string() + }); + execute_script(package_json_path, script_name, args.env, executor.as_ref())?; } else { return Err(anyhow!( "No script name provided, please provide one or, if you wish to execute the default command from jakefile.toml, do not pass the `--js` flag." @@ -81,8 +95,35 @@ fn main() -> anyhow::Result<()> { return Ok(()); } match args.task { - Some(t) => execute_command(None, &t, &args.options, executor.as_ref(), args.env)?, - None => execute_default_command(None, &args.options, executor.as_ref(), args.env)?, + Some(t) => { + let jakefile_path = args.cwd.map(|p| { + PathBuf::from(p) + .join("jakefile.toml") + .to_string_lossy() + .to_string() + }); + execute_command( + jakefile_path.as_deref(), + &t, + &args.options, + executor.as_ref(), + args.env, + )? + } + None => { + let jakefile_path = args.cwd.map(|p| { + PathBuf::from(p) + .join("jakefile.toml") + .to_string_lossy() + .to_string() + }); + execute_default_command( + jakefile_path.as_deref(), + &args.options, + executor.as_ref(), + args.env, + )? + } } Ok(()) } diff --git a/src/models.rs b/src/models.rs index dff2415..551468f 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,9 +1,14 @@ -use std::collections::HashSet; +use std::{collections::HashSet, env, path::PathBuf}; use crate::env_vars::dotenv_to_hashmap; pub trait Executor { - fn execute(&self, full_command: &str, load_env: bool) -> anyhow::Result<()>; + fn execute( + &self, + full_command: &str, + load_env: bool, + cwd: Option, + ) -> anyhow::Result<()>; } pub struct CommandExecutor; @@ -24,14 +29,28 @@ impl DryRunExecutor { } impl Executor for DryRunExecutor { - fn execute(&self, full_command: &str, _load_env: bool) -> anyhow::Result<()> { + fn execute( + &self, + full_command: &str, + _load_env: bool, + _cwd: Option, + ) -> anyhow::Result<()> { println!("{}", full_command); Ok(()) } } impl Executor for CommandExecutor { - fn execute(&self, full_command: &str, load_env: bool) -> anyhow::Result<()> { + fn execute( + &self, + full_command: &str, + load_env: bool, + cwd: Option, + ) -> anyhow::Result<()> { + let current_dir = match cwd { + Some(c) => PathBuf::from(c.replace("\\", "/")).canonicalize()?, + None => env::current_dir()?, + }; let mut cmd = if !load_env { std::process::Command::new("sh") .arg("-c") @@ -39,6 +58,7 @@ impl Executor for CommandExecutor { .stdin(std::process::Stdio::inherit()) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) + .current_dir(current_dir) .spawn()? } else { let env_vars = dotenv_to_hashmap()?; @@ -49,6 +69,7 @@ impl Executor for CommandExecutor { .stdin(std::process::Stdio::inherit()) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) + .current_dir(current_dir) .spawn()? }; let status = cmd.wait()?; diff --git a/src/package_json.rs b/src/package_json.rs index 39ebe37..05d3f84 100644 --- a/src/package_json.rs +++ b/src/package_json.rs @@ -1,7 +1,7 @@ use std::{ collections::{HashMap, HashSet}, env, fs, - path::Path, + path::{Path, PathBuf}, }; use anyhow::{Result, anyhow}; @@ -131,15 +131,23 @@ pub fn execute_script( executor: &dyn Executor, ) -> Result<()> { let owned_path; - let path = match package_json_path { + let (path, cwd) = match package_json_path { None => { let p = resolve_package_json_path()?; owned_path = p; - Path::new(&owned_path) + (Path::new(&owned_path), None) } Some(p) => { owned_path = p; - Path::new(&owned_path) + let cur_dir = env::current_dir()?; + ( + Path::new(&owned_path), + Some( + PathBuf::from(&owned_path) + .parent() + .map_or(cur_dir, |p| p.to_path_buf()), + ), + ) } }; let map = load_package_json(path)?; @@ -147,7 +155,11 @@ pub fn execute_script( let command = get_script_command(scripts, script_name)?; let resolved_command = resolve_command_executables(&command); println!("\x1b[38;5;247m{}\x1b[0m\n", command); - executor.execute(&resolved_command, load_env)?; + executor.execute( + &resolved_command, + load_env, + cwd.map(|c| c.to_string_lossy().to_string()), + )?; Ok(()) } @@ -168,7 +180,12 @@ mod tests { } impl Executor for MockCommandExecutor { - fn execute(&self, full_command: &str, _load_env: bool) -> anyhow::Result<()> { + fn execute( + &self, + full_command: &str, + _load_env: bool, + _cwd: Option, + ) -> anyhow::Result<()> { std::fs::write("package.mock", full_command)?; Ok(()) } From cb77b0de952f5dc70794db39f86516c5421d8404 Mon Sep 17 00:00:00 2001 From: "Clelia (Astra) Bertelli" Date: Mon, 27 Jul 2026 14:52:35 +0200 Subject: [PATCH 2/2] docs: readme, docs and version-bump --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 11 +++++++++++ package.json | 2 +- pre-install.js | 4 ++-- reference/contents/features.md | 2 ++ reference/contents/reference.md | 10 ++++++++++ src/main.rs | 2 +- 8 files changed, 29 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b1a3de1..7e973a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -199,7 +199,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jake" -version = "0.9.0" +version = "0.10.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 7e1266b..136ccfd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jake" -version = "0.9.0" +version = "0.10.0" edition = "2024" license-file = "LICENSE" readme = "README.md" diff --git a/README.md b/README.md index 8a243f1..6479d3d 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ It is based on TOML tasks definition (stored in a file called `jakefile.toml`) a - You can load `.env` file (in the same working directory or anywhere up in the directory tree), by passing the `--env` flag - Execute scripts from a `package.json` file with the `--js` flag. - Execute inline scripts in Python, JavaScript, TypeScript, Ruby, PHP, Lua, and Perl +- Run tasks from any directory with `--cwd `, making `jake` a workspace-wide task runner - Dry-run mode: `jake --dry-run ` prints commands without executing them. ## Installation @@ -199,6 +200,16 @@ console.log('hey there, great to run TS!') Supported language identifiers: `py`/`python`, `js`/`javascript`, `ts`/`typescript`, `rb`/`ruby`, `php`, `lua`, `perl`. +### Running tasks from a custom working directory + +You can use `--cwd` to run tasks from a specific directory without changing your shell's current directory. This is especially useful in monorepos or multi-package workspaces: + +```bash +jake build --cwd ./packages/backend +``` + +When `--cwd` is provided, `jake` will look for `jakefile.toml` (or `package.json` if using `--js`) in that directory and use it as the working directory for command execution. + ### package.json Scripts If you are in a JS/TS application using a `package.json` for its scripts, as in this example: diff --git a/package.json b/package.json index 1af01a0..318e9e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cle-does-things/jake", - "version": "0.9.0", + "version": "0.10.0", "description": "A Make-like task executor for Unix operating systems", "main": "start.js", "directories": { diff --git a/pre-install.js b/pre-install.js index 12372e4..8cfcda7 100644 --- a/pre-install.js +++ b/pre-install.js @@ -31,8 +31,8 @@ const features = process.env.npm_config_features ? `--features ${process.env.npm_config_features.replace(",", " ")}` : ""; -console.log(`Installing and compiling jake 0.9.0 ${features} ...`); -exec(`cargo install jake --vers 0.9.0 ${features}`, (error, stdout, stderr) => { +console.log(`Installing and compiling jake 0.10.0 ${features} ...`); +exec(`cargo install jake --vers 0.10.0 ${features}`, (error, stdout, stderr) => { console.log(stdout); if (error || stderr) { console.log(error || stderr); diff --git a/reference/contents/features.md b/reference/contents/features.md index dd3cc34..79e62f8 100644 --- a/reference/contents/features.md +++ b/reference/contents/features.md @@ -25,6 +25,7 @@ additional options from the command line. file can be executed by passing the `--js` flag - **Inline scripts**: write scripts in Python, JavaScript, TypeScript, Ruby, PHP, Lua, and Perl directly in `jakefile.toml` using the `script` key +- **Custom working directory**: with `--cwd`, you can point `jake` to any directory to use as the working directory for command execution, enabling workspace-wide task running - **Dry-run**: `--dry-run` prints the commands that would be run without executing them ### Comparison @@ -48,6 +49,7 @@ The table below compares `jake` against [`just`](https://github.com/casey/just) | Initialize a boilerplate jake/just/makefile | ✅ | ❌ | ❌ | | Execute inline scripts in `jakefile.toml` | ✅ | ❌ | ❌ | | Execute scripts in `package.json` | ✅ | ❌ | ❌ | +| Custom working directory (`--cwd`) | ✅ | ❌ | ❌ | | Dry-run (print commands only) | ✅ | ✅ | ✅ | ⚠️ `make` supports passing variables from the command line but not named options in the same diff --git a/reference/contents/reference.md b/reference/contents/reference.md index 9fc14b1..c6d09aa 100644 --- a/reference/contents/reference.md +++ b/reference/contents/reference.md @@ -216,6 +216,16 @@ drwxr-xr-x@ 6 user staff 192 Feb 13 10:22 target The value passed to `--options` is appended to the task's command at execution time, so `jake list --options "-la"` effectively runs `ls -la`. +**Run tasks from a custom working directory** + +Use `--cwd` to point `jake` to a specific directory where `jakefile.toml` (or `package.json`) resides. This is useful in monorepos or workspace setups where you want to run tasks from a different directory without changing your shell's working directory: + +```bash +jake build --cwd ./packages/backend +``` + +The specified directory is used as the current working directory for command execution, and `jake` will look for `jakefile.toml` (or `package.json` when using `--js`) inside it. + **Load a `.env` file and execute a task** If a task requires an environment variable, e.g.: diff --git a/src/main.rs b/src/main.rs index 37a4b85..47c5be5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,7 @@ mod package_json; /// Make-like task executor for Unix-based operating systems #[derive(Parser, Debug)] -#[command(version = "0.9.0")] +#[command(version = "0.10.0")] #[command(name = "jake")] #[command(about, long_about = None)] struct Args {