From 8f4a57d8a917e479eb7052604b8ce4af7ead2135 Mon Sep 17 00:00:00 2001 From: Miguel Miranda Date: Wed, 4 Nov 2026 16:43:13 +0000 Subject: [PATCH 1/3] feat: add a runtime Towerfile field and a bun runtime `[app] runtime` declares what starts the script: python (uv), bash or bun. It is copied into the MANIFEST only when set, so existing bundles are unchanged. Without it the runtime still follows the script: .sh runs with bash, anything else with Python. The manifest keeps the value as a string, so a runtime this version doesn't know still parses and fails the run with UnsupportedRuntime instead. Bun apps run `bun install` (frozen when there's a lockfile) when they have a package.json, on the setup channel, then `bun run` on the script. Like uv and bash, bun runs in its own process group and with a cleared environment in protected mode, and installs into the runtime's cache dir when it has one. Bun scripts must be JavaScript or TypeScript, and import_paths aren't supported with bun yet. The packager now leaves node_modules out of bundles, and the MCP tower_file_update tool and the agent skill cover the new key. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test-rust.yml | 7 + crates/tower-cmd/src/mcp.rs | 8 +- crates/tower-cmd/src/skill.rs | 2 + crates/tower-package/src/core.rs | 108 ++++++++++- crates/tower-package/src/lib.rs | 4 +- crates/tower-package/src/native.rs | 7 + crates/tower-package/src/towerfile.rs | 172 +++++++++++++++++- crates/tower-package/tests/package_test.rs | 46 ++++- crates/tower-runtime/src/errors.rs | 6 + crates/tower-runtime/src/local.rs | 125 ++++++++++++- .../tests/example-apps/08-bun-hello/Towerfile | 4 + .../tests/example-apps/08-bun-hello/bun.lock | 15 ++ .../tests/example-apps/08-bun-hello/main.ts | 5 + .../example-apps/08-bun-hello/package.json | 7 + .../09-bun-stale-lockfile/Towerfile | 4 + .../09-bun-stale-lockfile/bun.lock | 15 ++ .../09-bun-stale-lockfile/main.ts | 5 + .../09-bun-stale-lockfile/package.json | 7 + crates/tower-runtime/tests/local_test.rs | 168 +++++++++++++++++ .../tower-runtime/tests/missing_bun_test.rs | 45 +++++ plugin/skills/tower/SKILL.md | 2 + 21 files changed, 750 insertions(+), 12 deletions(-) create mode 100644 crates/tower-runtime/tests/example-apps/08-bun-hello/Towerfile create mode 100644 crates/tower-runtime/tests/example-apps/08-bun-hello/bun.lock create mode 100644 crates/tower-runtime/tests/example-apps/08-bun-hello/main.ts create mode 100644 crates/tower-runtime/tests/example-apps/08-bun-hello/package.json create mode 100644 crates/tower-runtime/tests/example-apps/09-bun-stale-lockfile/Towerfile create mode 100644 crates/tower-runtime/tests/example-apps/09-bun-stale-lockfile/bun.lock create mode 100644 crates/tower-runtime/tests/example-apps/09-bun-stale-lockfile/main.ts create mode 100644 crates/tower-runtime/tests/example-apps/09-bun-stale-lockfile/package.json create mode 100644 crates/tower-runtime/tests/missing_bun_test.rs diff --git a/.github/workflows/test-rust.yml b/.github/workflows/test-rust.yml index d2a2c359..803371b0 100644 --- a/.github/workflows/test-rust.yml +++ b/.github/workflows/test-rust.yml @@ -35,6 +35,13 @@ jobs: with: save-if: ${{ github.ref_name == 'main' }} + # The bun runtime tests run bun from PATH. They're unix-only, like the runtime's bun support. + - name: Set up bun + if: runner.os == 'Linux' + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.4 + - name: Compile tests run: > cargo test --all-features --no-run diff --git a/crates/tower-cmd/src/mcp.rs b/crates/tower-cmd/src/mcp.rs index c7994819..e922731a 100644 --- a/crates/tower-cmd/src/mcp.rs +++ b/crates/tower-cmd/src/mcp.rs @@ -83,6 +83,9 @@ struct UpdateTowerfileRequest { /// Compute size for each run: one of basic.xsmall, basic.small, /// basic.medium, basic.large. Omit to let Tower apply its default. run_size: Option, + /// What starts the script: python, bash or bun. Omit to follow the + /// script's extension (.sh runs with bash, anything else with Python). + runtime: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -1023,7 +1026,7 @@ impl TowerService { } #[tool( - description = "Update Towerfile config (app name, script, description, source, run_size). Use this instead of editing TOML. Optional: working_directory." + description = "Update Towerfile config (app name, script, description, source, run_size, runtime). Use this instead of editing TOML. Optional: working_directory." )] async fn tower_file_update( &self, @@ -1045,6 +1048,9 @@ impl TowerService { if let Some(run_size) = request.run_size { tf.app.run_size = Some(run_size); } + if let Some(runtime) = request.runtime { + tf.app.runtime = Some(runtime); + } Ok("Towerfile updated".into()) }) } diff --git a/crates/tower-cmd/src/skill.rs b/crates/tower-cmd/src/skill.rs index 2f28e505..34554133 100644 --- a/crates/tower-cmd/src/skill.rs +++ b/crates/tower-cmd/src/skill.rs @@ -192,6 +192,8 @@ Always use `tower_file_update` or `tower_file_add/edit/remove_parameter` to modi To give an app more compute, set `run_size` via `tower_file_update`: one of `basic.xsmall`, `basic.small`, `basic.medium` (the default), `basic.large`. It is versioned with the code, and self-hosted runners ignore it. +To run JavaScript or TypeScript, set `runtime` to `bun` via `tower_file_update` and point `script` at a `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs` or `.cjs` file. Dependencies come from `package.json` (and `bun.lock`). Without `runtime`, `.sh` scripts run with bash and everything else with Python. + ### 2. Local development (preferred) ``` diff --git a/crates/tower-package/src/core.rs b/crates/tower-package/src/core.rs index f65233c0..970e5db1 100644 --- a/crates/tower-package/src/core.rs +++ b/crates/tower-package/src/core.rs @@ -77,6 +77,12 @@ pub struct Manifest { #[serde(default, skip_serializing_if = "Option::is_none")] pub run_size: Option, + /// The runtime the Towerfile declared, e.g. "bun". `None` means it didn't declare one and + /// the runtime follows the script; see `resolve_runtime`. A string rather than `Runtime`, + /// so a manifest naming a runtime this version doesn't know still parses. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime: Option, + #[serde(default)] pub import_paths: Vec, @@ -94,6 +100,47 @@ impl Manifest { pub fn from_json(data: &str) -> Result { Ok(serde_json::from_str(data)?) } + + /// The runtime that starts this app: the declared one, or else the one its script implies. + /// `None` when the manifest declares a runtime this version doesn't know. + pub fn resolve_runtime(&self) -> Option { + match &self.runtime { + Some(name) => Runtime::from_name(name), + None => Some(Runtime::infer(&self.invoke)), + } + } +} + +/// How an app's script is started. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Runtime { + /// `uv run`, after installing dependencies from pyproject.toml or requirements.txt. + Python, + /// `bash`. + Bash, + /// `bun run`, after `bun install` when there's a package.json. + Bun, +} + +impl Runtime { + /// Parses a runtime name as written in a Towerfile or MANIFEST; see `RUNTIMES`. + pub fn from_name(name: &str) -> Option { + match name { + "python" => Some(Runtime::Python), + "bash" => Some(Runtime::Bash), + "bun" => Some(Runtime::Bun), + _ => None, + } + } + + /// The runtime for a script that doesn't declare one: bash for `.sh`, Python otherwise. + pub fn infer(script: &str) -> Self { + if script.ends_with(".sh") { + Runtime::Bash + } else { + Runtime::Python + } + } } #[derive(Debug, Clone)] @@ -111,7 +158,8 @@ pub struct PackageInputs { // module_files have archive_name already rooted under "modules/". pub module_files: Vec, - // towerfile_bytes is the sole source of invoke, parameters, run_size, and import_paths. + // towerfile_bytes is the sole source of invoke, parameters, run_size, runtime, and + // import_paths. pub towerfile_bytes: Vec, } @@ -158,6 +206,7 @@ pub fn build_package(inputs: PackageInputs) -> Result { parameters: towerfile.parameters, schedule: None, run_size: towerfile.app.run_size, + runtime: towerfile.app.runtime, import_paths, app_dir_name: "app".to_string(), modules_dir_name: "modules".to_string(), @@ -320,4 +369,61 @@ import_paths = ["../shared", "libs/inner", "./weird/"] }); assert!(matches!(result, Err(Error::InvalidTowerfile { .. }))); } + + fn manifest_for(invoke: &str, runtime: Option<&str>) -> Manifest { + Manifest { + version: Some(CURRENT_PACKAGE_VERSION), + invoke: invoke.to_string(), + parameters: vec![], + schedule: None, + run_size: None, + runtime: runtime.map(str::to_string), + import_paths: vec![], + app_dir_name: "app".to_string(), + modules_dir_name: "modules".to_string(), + checksum: String::new(), + } + } + + #[test] + fn test_resolve_runtime_infers_from_the_script_when_undeclared() { + assert_eq!( + manifest_for("./task.py", None).resolve_runtime(), + Some(Runtime::Python) + ); + assert_eq!( + manifest_for("./task.sh", None).resolve_runtime(), + Some(Runtime::Bash) + ); + // Bun is never inferred: a .ts script without a runtime still goes to Python, as before. + assert_eq!( + manifest_for("./task.ts", None).resolve_runtime(), + Some(Runtime::Python) + ); + } + + #[test] + fn test_resolve_runtime_prefers_the_declared_runtime() { + assert_eq!( + manifest_for("./task.ts", Some("bun")).resolve_runtime(), + Some(Runtime::Bun) + ); + assert_eq!( + manifest_for("./bin/run", Some("bash")).resolve_runtime(), + Some(Runtime::Bash) + ); + assert_eq!( + manifest_for("./task.ts", Some("deno")).resolve_runtime(), + None + ); + } + + #[test] + fn test_manifest_with_unknown_runtime_still_parses() { + let manifest = Manifest::from_json( + r#"{"version":3,"invoke":"main.ts","schedule":null,"runtime":"deno"}"#, + ) + .unwrap(); + assert_eq!(manifest.runtime.as_deref(), Some("deno")); + } } diff --git a/crates/tower-package/src/lib.rs b/crates/tower-package/src/lib.rs index 35586799..7cb31ad5 100644 --- a/crates/tower-package/src/lib.rs +++ b/crates/tower-package/src/lib.rs @@ -3,9 +3,9 @@ mod towerfile; pub use core::{ build_package, compute_sha256_bytes, compute_sha256_package, normalize_path, BuiltPackage, - Entry, Manifest, PackageInputs, CURRENT_PACKAGE_VERSION, + Entry, Manifest, PackageInputs, Runtime, CURRENT_PACKAGE_VERSION, }; -pub use towerfile::{App, Parameter, Towerfile, RUN_SIZES}; +pub use towerfile::{App, Parameter, Towerfile, RUNTIMES, RUN_SIZES}; #[cfg(feature = "native")] mod error; diff --git a/crates/tower-package/src/native.rs b/crates/tower-package/src/native.rs index fe601d0e..2bf5ad18 100644 --- a/crates/tower-package/src/native.rs +++ b/crates/tower-package/src/native.rs @@ -85,6 +85,7 @@ impl Package { parameters: vec![], schedule: None, run_size: None, + runtime: None, import_paths: vec![], app_dir_name: "app".to_string(), modules_dir_name: "modules".to_string(), @@ -462,6 +463,11 @@ impl FileResolver { return true; } + // Or in installed JavaScript dependencies, which bun installs at run time. + if is_in_dir(p, "node_modules") { + return true; + } + false } @@ -586,6 +592,7 @@ mod test { parameters: vec![], schedule: None, run_size: None, + runtime: None, import_paths: vec![], app_dir_name: "app".to_string(), modules_dir_name: "modules".to_string(), diff --git a/crates/tower-package/src/towerfile.rs b/crates/tower-package/src/towerfile.rs index a89e598c..743e56e8 100644 --- a/crates/tower-package/src/towerfile.rs +++ b/crates/tower-package/src/towerfile.rs @@ -1,4 +1,4 @@ -use crate::core::Error; +use crate::core::{Error, Runtime}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -8,6 +8,13 @@ use std::path::PathBuf; /// deliberately has no default of its own. pub const RUN_SIZES: [&str; 4] = ["basic.xsmall", "basic.small", "basic.medium", "basic.large"]; +/// Runtimes accepted by `[app] runtime`. When the Towerfile omits it, `.sh` +/// scripts run with bash and everything else with Python. +pub const RUNTIMES: [&str; 3] = ["python", "bash", "bun"]; + +/// Script extensions the bun runtime accepts. +const BUN_SCRIPT_EXTENSIONS: [&str; 6] = ["ts", "tsx", "js", "jsx", "mjs", "cjs"]; + #[derive(Clone, Deserialize, Serialize, Debug)] pub struct Parameter { #[serde(default)] @@ -51,6 +58,12 @@ pub struct App { /// serializing), leaving the server to apply its default. #[serde(default, skip_serializing_if = "Option::is_none")] pub run_size: Option, + + /// What starts the script, e.g. "bun". `None` means the Towerfile didn't + /// set one (and the key is omitted when serializing), so the runtime + /// follows the script's extension. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime: Option, } #[derive(Deserialize, Serialize, Debug)] @@ -79,6 +92,7 @@ impl Towerfile { description: None, import_paths: vec![], run_size: None, + runtime: None, }, } } @@ -140,6 +154,47 @@ impl Towerfile { } } + if let Some(runtime) = &self.app.runtime { + match Runtime::from_name(runtime) { + None => { + return Err(Error::InvalidTowerfile { + message: format!( + "runtime {:?} is not a valid runtime (expected one of: {})", + runtime, + RUNTIMES.join(", ") + ), + }); + } + Some(Runtime::Bun) => self.validate_bun_app()?, + Some(_) => {} + } + } + + Ok(()) + } + + fn validate_bun_app(&self) -> Result<(), Error> { + let script = self.app.script.as_str(); + let is_js = script + .rsplit_once('.') + .is_some_and(|(_, ext)| BUN_SCRIPT_EXTENSIONS.contains(&ext)); + if !is_js { + return Err(Error::InvalidTowerfile { + message: format!( + "runtime \"bun\" needs a JavaScript or TypeScript script (.{}), but script is {:?}", + BUN_SCRIPT_EXTENSIONS.join(", ."), + script + ), + }); + } + + // import_paths are wired into PYTHONPATH only. + if !self.app.import_paths.is_empty() { + return Err(Error::InvalidTowerfile { + message: "import_paths are not supported with runtime \"bun\"".to_string(), + }); + } + Ok(()) } @@ -595,6 +650,121 @@ mod test { drop(tempfile); } + fn towerfile_with_runtime(script: &str, runtime: &str) -> String { + format!( + r#" + [app] + name = "test" + script = "{}" + runtime = "{}" + "#, + script, runtime + ) + } + + #[test] + fn test_runtime_absent_when_not_set() { + let toml = r#" + [app] + name = "test" + script = "./script.py" + "#; + + let towerfile = crate::Towerfile::from_toml(toml).unwrap(); + assert_eq!(towerfile.app.runtime, None); + + // An unset runtime must not be written into the user's Towerfile, so it + // keeps following the script's extension. + let serialized = toml::to_string_pretty(&towerfile).unwrap(); + assert!(!serialized.contains("runtime")); + } + + #[test] + fn test_runtime_accepts_every_known_runtime() { + for (runtime, script) in [ + ("python", "./task.py"), + ("bash", "./task.sh"), + ("bun", "./task.ts"), + ] { + let towerfile = + crate::Towerfile::from_toml(&towerfile_with_runtime(script, runtime)).unwrap(); + assert_eq!(towerfile.app.runtime.as_deref(), Some(runtime)); + } + // Every advertised name is one the runtime can start. + for name in crate::RUNTIMES { + assert!( + crate::Runtime::from_name(name).is_some(), + "{:?} doesn't parse", + name + ); + } + } + + #[test] + fn test_runtime_rejects_unknown_runtime() { + for bad in ["node", "Bun", "uv", ""] { + let err = + crate::Towerfile::from_toml(&towerfile_with_runtime("./task.ts", bad)).unwrap_err(); + let message = err.to_string(); + assert!(message.contains("python, bash, bun"), "was: {}", message); + } + } + + #[test] + fn test_bun_runtime_requires_a_javascript_or_typescript_script() { + for script in [ + "./task.ts", + "./task.tsx", + "./src/task.mjs", + "./task.cjs", + "task.js", + ] { + assert!( + crate::Towerfile::from_toml(&towerfile_with_runtime(script, "bun")).is_ok(), + "expected {:?} to be accepted", + script + ); + } + for script in ["./task.py", "./task.sh", "./task", "./task.ts.bak"] { + let err = + crate::Towerfile::from_toml(&towerfile_with_runtime(script, "bun")).unwrap_err(); + assert!(err.to_string().contains(".ts"), "was: {}", err); + } + } + + #[test] + fn test_bun_runtime_rejects_import_paths() { + let toml = r#" + [app] + name = "test" + script = "./task.ts" + runtime = "bun" + import_paths = ["../shared"] + "#; + + let err = crate::Towerfile::from_toml(toml).unwrap_err(); + assert!(err.to_string().contains("import_paths"), "was: {}", err); + } + + #[test] + fn test_bash_runtime_runs_scripts_without_an_extension() { + let towerfile = + crate::Towerfile::from_toml(&towerfile_with_runtime("./bin/run", "bash")).unwrap(); + assert_eq!(towerfile.app.runtime.as_deref(), Some("bash")); + } + + #[test] + fn test_runtime_survives_read_modify_write() { + let mut towerfile = + crate::Towerfile::from_toml(&towerfile_with_runtime("./task.ts", "bun")).unwrap(); + towerfile.app.description = Some("edited".to_string()); + + let serialized = toml::to_string_pretty(&towerfile).unwrap(); + let reparsed = crate::Towerfile::from_toml(&serialized).unwrap(); + + assert_eq!(reparsed.app.runtime.as_deref(), Some("bun")); + } + #[test] fn test_roundtrip_serialization() { let original_toml = r#"[app] diff --git a/crates/tower-package/tests/package_test.rs b/crates/tower-package/tests/package_test.rs index 0ef8ef74..cf1df09b 100644 --- a/crates/tower-package/tests/package_test.rs +++ b/crates/tower-package/tests/package_test.rs @@ -11,7 +11,7 @@ use tokio::{ use tokio_stream::*; use tokio_tar::Archive; -use tower_package::{Manifest, Package, PackageSpec, Towerfile}; +use tower_package::{Manifest, Package, PackageSpec, Runtime, Towerfile}; use tower_telemetry::debug; const TRIVIAL_TOWERFILE: &str = "[app]\nname = \"test\"\nscript = \"main.py\"\n"; @@ -62,6 +62,8 @@ async fn it_creates_package() { let json: serde_json::Value = serde_json::from_str(manifest).unwrap(); assert!(json.get("run_size").is_none()); assert_eq!(Manifest::from_json(manifest).unwrap().run_size, None); + // Without a declared runtime the manifest stays as it was, and the runtime follows the script. + assert!(json.get("runtime").is_none()); } #[tokio::test] @@ -368,6 +370,12 @@ async fn it_excludes_various_content_that_should_not_be_there() { ) .await; create_test_file(tmp_dir.to_path_buf(), ".git/some-file", "").await; + create_test_file( + tmp_dir.to_path_buf(), + "node_modules/ms/index.js", + "module.exports = {}", + ) + .await; let spec = PackageSpec { base_dir: tmp_dir.to_path_buf(), @@ -394,6 +402,12 @@ async fn it_excludes_various_content_that_should_not_be_there() { "files {:?} contained a .pyc", files ); + assert!(files.contains_key("app/main.py"), "files {:?}", files); + assert!( + !files.contains_key("app/node_modules/ms/index.js"), + "files {:?} contained node_modules", + files + ); } #[tokio::test] @@ -645,3 +659,33 @@ async fn it_preserves_run_size_in_the_manifest_and_towerfile() { Some("basic.large") ); } + +#[tokio::test] +async fn it_copies_a_declared_runtime_into_the_manifest() { + // The deploy handler reads runtime from MANIFEST to pick the runner image. + let tmp_dir = TmpDir::new("example") + .await + .expect("Failed to create temp dir"); + + let towerfile = "[app]\nname = \"test\"\nscript = \"main.ts\"\nruntime = \"bun\"\n"; + create_test_file(tmp_dir.to_path_buf(), "Towerfile", towerfile).await; + create_test_file(tmp_dir.to_path_buf(), "main.ts", "console.log('hi')").await; + + let spec = PackageSpec { + base_dir: tmp_dir.to_path_buf(), + towerfile_path: tmp_dir.to_path_buf().join("Towerfile").to_path_buf(), + file_globs: vec!["*.ts".to_string()], + import_paths: vec![], + }; + + let package = Package::build(spec).await.expect("Failed to build package"); + let files = read_package_files(package).await; + + let manifest = files.get("MANIFEST").expect("package was missing MANIFEST"); + let json: serde_json::Value = serde_json::from_str(manifest).unwrap(); + assert_eq!(json["runtime"], "bun"); + assert_eq!( + Manifest::from_json(manifest).unwrap().resolve_runtime(), + Some(Runtime::Bun) + ); +} diff --git a/crates/tower-runtime/src/errors.rs b/crates/tower-runtime/src/errors.rs index 0b4a96fd..45deb146 100644 --- a/crates/tower-runtime/src/errors.rs +++ b/crates/tower-runtime/src/errors.rs @@ -53,6 +53,12 @@ pub enum Error { #[snafu(display("bash could not be found"))] MissingBash, + #[snafu(display("bun could not be found"))] + MissingBun, + + #[snafu(display("unsupported runtime {runtime:?}"))] + UnsupportedRuntime { runtime: String }, + #[snafu(display("failed to create a virtual environment"))] VirtualEnvCreationFailed, diff --git a/crates/tower-runtime/src/local.rs b/crates/tower-runtime/src/local.rs index 98e85a92..4523c3db 100644 --- a/crates/tower-runtime/src/local.rs +++ b/crates/tower-runtime/src/local.rs @@ -29,7 +29,7 @@ use nix::{ use tokio_util::sync::CancellationToken; -use tower_package::Package; +use tower_package::{Package, Runtime}; use tower_telemetry::debug; use tower_uv::Uv; @@ -132,6 +132,14 @@ async fn find_bash() -> Result { } } +async fn find_bun() -> Result { + if let Some(path) = find_executable_in_path("bun").await { + Ok(path) + } else { + Err(Error::MissingBun) + } +} + /// Run the app to completion, returning the child's exit code on success or an /// `Error` for any platform failure. Cancellation is reported as `Ok(-1)` to /// preserve the historical `Status::Crashed { code: -1 }` semantic. @@ -154,6 +162,11 @@ async fn inner_execute_local_app( debug!(ctx: &ctx, " - working directory: {:?}", &working_dir); let manifest = &package.manifest; + let runtime = manifest + .resolve_runtime() + .ok_or_else(|| Error::UnsupportedRuntime { + runtime: manifest.runtime.clone().unwrap_or_default(), + })?; let secrets = opts.secrets; let params = opts.parameters; let other_env_vars = opts.env_vars; @@ -182,7 +195,7 @@ async fn inner_execute_local_app( // plus only what we pass in. let protected_mode = opts.cache_dir.is_none(); - if is_bash_package(&package) { + if runtime == Runtime::Bash { let script = Package::resolve_invoke_path(manifest, &package_path); let mut child = execute_bash_program( &ctx, @@ -206,6 +219,26 @@ async fn inner_execute_local_app( return Err(Error::Cancelled); } Ok(code) + } else if runtime == Runtime::Bun { + let script = Package::resolve_invoke_path(manifest, &package_path); + let env_vars = make_env_vars( + &ctx, + &working_dir, + &secrets, + ¶ms, + &other_env_vars, + &import_paths, + ); + execute_bun_app( + &ctx, + &cancel_token, + &opts.output_sender, + working_dir, + script, + opts.cache_dir, + env_vars, + ) + .await } else { let uv = Uv::new(opts.cache_dir, protected_mode).await?; let env_vars = make_env_vars( @@ -486,6 +519,90 @@ async fn execute_bash_program( Ok(cmd.spawn()?) } +/// Runs a bun app: `bun install` when the app has a package.json, then `bun run` on its script. +/// Returns the exit code of whichever step ends the run. +async fn execute_bun_app( + ctx: &tower_telemetry::Context, + cancel_token: &CancellationToken, + output_sender: &OutputSender, + cwd: PathBuf, + script: PathBuf, + cache_dir: Option, + env_vars: HashMap, +) -> Result { + let bun_path = find_bun().await?; + debug!(ctx: &ctx, "using bun at {:?}", bun_path); + + let protected_mode = cache_dir.is_none(); + let mut env_vars = tower_uv::normalize_env_vars(&env_vars); + if let Some(dir) = &cache_dir { + env_vars.insert( + "BUN_INSTALL_CACHE_DIR".to_string(), + dir.join("bun").to_string_lossy().to_string(), + ); + } + + if cwd.join("package.json").exists() { + let mut cmd = bun_command(&bun_path, &cwd, &env_vars, protected_mode); + cmd.arg("install").arg("--no-progress"); + // With a lockfile, install exactly what it pins rather than re-resolving. + if cwd.join("bun.lock").exists() || cwd.join("bun.lockb").exists() { + cmd.arg("--frozen-lockfile"); + } + + let code = run_setup_child(ctx, cancel_token, output_sender, cmd.spawn()?).await; + if cancel_token.is_cancelled() { + return Err(Error::Cancelled); + } + if code != 0 { + // A failed install is a crash with bun's exit code, like a failed `uv sync`. + return Ok(code); + } + } + + if cancel_token.is_cancelled() { + return Err(Error::Cancelled); + } + + debug!(ctx: &ctx, " - bun script {:?}", script); + + let mut cmd = bun_command(&bun_path, &cwd, &env_vars, protected_mode); + let mut child = cmd.arg("run").arg(&script).spawn()?; + forward_output(&mut child, Channel::Program, output_sender); + + let code = wait_for_process(ctx.clone(), cancel_token, child).await; + if cancel_token.is_cancelled() { + return Err(Error::Cancelled); + } + Ok(code) +} + +fn bun_command( + bun_path: &PathBuf, + cwd: &PathBuf, + env_vars: &HashMap, + protected_mode: bool, +) -> Command { + let mut cmd = Command::new(bun_path); + cmd.current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + #[cfg(unix)] + { + cmd.process_group(0); + } + + if protected_mode { + cmd.env_clear(); + } + cmd.envs(env_vars); + + cmd +} + fn make_env_var_key(src: &str) -> String { // TODO: We have this special case defined for dltHub, and I'm not sure that we want to... if src.starts_with("dlt.") { @@ -660,7 +777,3 @@ async fn drain_output( }); } } - -fn is_bash_package(package: &Package) -> bool { - return package.manifest.invoke.ends_with(".sh"); -} diff --git a/crates/tower-runtime/tests/example-apps/08-bun-hello/Towerfile b/crates/tower-runtime/tests/example-apps/08-bun-hello/Towerfile new file mode 100644 index 00000000..98b5a64d --- /dev/null +++ b/crates/tower-runtime/tests/example-apps/08-bun-hello/Towerfile @@ -0,0 +1,4 @@ +[app] +name = "08-bun-hello" +script = "./main.ts" +runtime = "bun" diff --git a/crates/tower-runtime/tests/example-apps/08-bun-hello/bun.lock b/crates/tower-runtime/tests/example-apps/08-bun-hello/bun.lock new file mode 100644 index 00000000..d6e4faf8 --- /dev/null +++ b/crates/tower-runtime/tests/example-apps/08-bun-hello/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "08-bun-hello", + "dependencies": { + "ms": "2.1.3", + }, + }, + }, + "packages": { + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + } +} diff --git a/crates/tower-runtime/tests/example-apps/08-bun-hello/main.ts b/crates/tower-runtime/tests/example-apps/08-bun-hello/main.ts new file mode 100644 index 00000000..4e2e253b --- /dev/null +++ b/crates/tower-runtime/tests/example-apps/08-bun-hello/main.ts @@ -0,0 +1,5 @@ +import ms from "ms"; + +console.log(`One minute is ${ms(60_000)}`); +console.log(`The greeting is: ${process.env.GREETING ?? "unset"}`); +console.log(`The parent variable is: ${process.env.BUN_TEST_PARENT_VARIABLE ?? "unset"}`); diff --git a/crates/tower-runtime/tests/example-apps/08-bun-hello/package.json b/crates/tower-runtime/tests/example-apps/08-bun-hello/package.json new file mode 100644 index 00000000..e47447d8 --- /dev/null +++ b/crates/tower-runtime/tests/example-apps/08-bun-hello/package.json @@ -0,0 +1,7 @@ +{ + "name": "08-bun-hello", + "private": true, + "dependencies": { + "ms": "2.1.3" + } +} diff --git a/crates/tower-runtime/tests/example-apps/09-bun-stale-lockfile/Towerfile b/crates/tower-runtime/tests/example-apps/09-bun-stale-lockfile/Towerfile new file mode 100644 index 00000000..146c79b4 --- /dev/null +++ b/crates/tower-runtime/tests/example-apps/09-bun-stale-lockfile/Towerfile @@ -0,0 +1,4 @@ +[app] +name = "09-bun-stale-lockfile" +script = "./main.ts" +runtime = "bun" diff --git a/crates/tower-runtime/tests/example-apps/09-bun-stale-lockfile/bun.lock b/crates/tower-runtime/tests/example-apps/09-bun-stale-lockfile/bun.lock new file mode 100644 index 00000000..d6e4faf8 --- /dev/null +++ b/crates/tower-runtime/tests/example-apps/09-bun-stale-lockfile/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "08-bun-hello", + "dependencies": { + "ms": "2.1.3", + }, + }, + }, + "packages": { + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + } +} diff --git a/crates/tower-runtime/tests/example-apps/09-bun-stale-lockfile/main.ts b/crates/tower-runtime/tests/example-apps/09-bun-stale-lockfile/main.ts new file mode 100644 index 00000000..4e2e253b --- /dev/null +++ b/crates/tower-runtime/tests/example-apps/09-bun-stale-lockfile/main.ts @@ -0,0 +1,5 @@ +import ms from "ms"; + +console.log(`One minute is ${ms(60_000)}`); +console.log(`The greeting is: ${process.env.GREETING ?? "unset"}`); +console.log(`The parent variable is: ${process.env.BUN_TEST_PARENT_VARIABLE ?? "unset"}`); diff --git a/crates/tower-runtime/tests/example-apps/09-bun-stale-lockfile/package.json b/crates/tower-runtime/tests/example-apps/09-bun-stale-lockfile/package.json new file mode 100644 index 00000000..fe2055ef --- /dev/null +++ b/crates/tower-runtime/tests/example-apps/09-bun-stale-lockfile/package.json @@ -0,0 +1,7 @@ +{ + "name": "08-bun-hello", + "private": true, + "dependencies": { + "ms": "2.1.2" + } +} diff --git a/crates/tower-runtime/tests/local_test.rs b/crates/tower-runtime/tests/local_test.rs index 9e19f919..29cede7a 100644 --- a/crates/tower-runtime/tests/local_test.rs +++ b/crates/tower-runtime/tests/local_test.rs @@ -560,3 +560,171 @@ async fn test_terminating_bash_script_stops_its_children() { } assert!(!alive, "the script's child should have been stopped"); } + +/// The bun runtime runs bun from PATH. CI installs it; elsewhere the bun tests skip without it. +#[cfg(unix)] +fn bun_available() -> bool { + let found = std::env::var_os("PATH") + .map(|paths| std::env::split_paths(&paths).any(|dir| dir.join("bun").is_file())) + .unwrap_or(false); + if !found { + assert!( + std::env::var_os("CI").is_none(), + "bun must be on PATH in CI" + ); + eprintln!("skipping: bun is not on PATH"); + } + found +} + +#[cfg(unix)] +#[tokio::test] +async fn test_running_bun_app() { + if !bun_available() { + return; + } + debug!("Running 08-bun-hello"); + + // Protected mode must keep this out of the app's environment. + std::env::set_var("BUN_TEST_PARENT_VARIABLE", "should not reach the app"); + + let dir = get_example_app_dir("08-bun-hello"); + let package = build_package_from_dir(&dir).await; + let (sender, mut receiver) = unbounded_channel(); + + let mut parameters = HashMap::new(); + parameters.insert("GREETING".to_string(), "hello from a parameter".to_string()); + + let opts = StartOptions { + ctx: tower_telemetry::Context::new("runner-id".to_string()), + package, + output_sender: sender, + cwd: None, + environment: "local".to_string(), + secrets: HashMap::new(), + parameters, + env_vars: HashMap::new(), + // No cache dir means protected mode. + cache_dir: None, + }; + + let app = LocalApp::start(opts).await.expect("Failed to start app"); + + let mut setup = Vec::new(); + let mut program = Vec::new(); + while let Some(output) = receiver.recv().await { + match output.channel { + tower_runtime::Channel::Setup => setup.push(output.line), + tower_runtime::Channel::Program => program.push(output.line), + } + } + + assert!( + !setup.is_empty(), + "bun install should report on the setup channel" + ); + // The dependency resolved, so `bun install` ran before the script. + assert!( + program.contains(&"One minute is 1m".to_string()), + "expected the app's output; got {:?}", + program + ); + assert!(program.contains(&"The greeting is: hello from a parameter".to_string())); + assert!( + program.contains(&"The parent variable is: unset".to_string()), + "protected mode should not pass the host environment through" + ); + + let status = app.status().await.expect("Failed to get app status"); + assert!( + status == Status::Exited, + "App should have exited cleanly, got {:?}", + status + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_running_bun_app_installs_into_the_cache_dir() { + if !bun_available() { + return; + } + + let dir = get_example_app_dir("08-bun-hello"); + let package = build_package_from_dir(&dir).await; + let (sender, mut receiver) = unbounded_channel(); + let cache_dir = tmpdir::TmpDir::new("tower-bun-cache") + .await + .expect("Failed to create cache dir"); + + let opts = StartOptions { + ctx: tower_telemetry::Context::new("runner-id".to_string()), + package, + output_sender: sender, + cwd: None, + environment: "local".to_string(), + secrets: HashMap::new(), + parameters: HashMap::new(), + env_vars: HashMap::new(), + cache_dir: Some(cache_dir.to_path_buf()), + }; + + let app = LocalApp::start(opts).await.expect("Failed to start app"); + while receiver.recv().await.is_some() {} + + let status = app.status().await.expect("Failed to get app status"); + assert!( + status == Status::Exited, + "App should have exited cleanly, got {:?}", + status + ); + assert!( + cache_dir.to_path_buf().join("bun").is_dir(), + "bun install should use the runtime's cache dir" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_abort_on_bun_install_failure() { + if !bun_available() { + return; + } + // package.json no longer matches bun.lock, so a frozen install fails. + let dir = get_example_app_dir("09-bun-stale-lockfile"); + let package = build_package_from_dir(&dir).await; + let (sender, mut receiver) = unbounded_channel(); + + let opts = StartOptions { + ctx: tower_telemetry::Context::new("runner-id".to_string()), + package, + output_sender: sender, + cwd: None, + environment: "local".to_string(), + secrets: HashMap::new(), + parameters: HashMap::new(), + env_vars: HashMap::new(), + cache_dir: Some(config::default_cache_dir()), + }; + + let app = LocalApp::start(opts).await.expect("Failed to start app"); + + let mut program = Vec::new(); + while let Some(output) = receiver.recv().await { + if matches!(output.channel, tower_runtime::Channel::Program) { + program.push(output.line); + } + } + + assert!( + program.is_empty(), + "the script should not run: {:?}", + program + ); + let status = app.status().await.expect("Failed to get app status"); + assert!( + matches!(status, Status::Crashed { code } if code != 0), + "a failed install should crash the app, got {:?}", + status + ); +} diff --git a/crates/tower-runtime/tests/missing_bun_test.rs b/crates/tower-runtime/tests/missing_bun_test.rs new file mode 100644 index 00000000..6009d041 --- /dev/null +++ b/crates/tower-runtime/tests/missing_bun_test.rs @@ -0,0 +1,45 @@ +// This test clears PATH for the whole process, so it lives in its own test binary. +#![cfg(unix)] + +use std::collections::HashMap; +use std::path::PathBuf; + +use tokio::sync::mpsc::unbounded_channel; + +use config::Towerfile; +use tower_package::{Package, PackageSpec}; +use tower_runtime::{errors::Error, local::LocalApp, App, AppFailure, StartOptions, Status}; + +#[tokio::test] +async fn test_bun_app_fails_without_bun() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/example-apps/08-bun-hello"); + let towerfile = Towerfile::from_path(dir.join("Towerfile")).expect("Failed to load Towerfile"); + let mut package = Package::build(PackageSpec::from_towerfile(&towerfile)) + .await + .expect("Failed to build package"); + package.unpack().await.expect("Failed to unpack package"); + + std::env::set_var("PATH", ""); + + let (sender, mut receiver) = unbounded_channel(); + let opts = StartOptions { + ctx: tower_telemetry::Context::new("runner-id".to_string()), + package, + output_sender: sender, + cwd: None, + environment: "local".to_string(), + secrets: HashMap::new(), + parameters: HashMap::new(), + env_vars: HashMap::new(), + cache_dir: None, + }; + + let app = LocalApp::start(opts).await.expect("Failed to start app"); + while receiver.recv().await.is_some() {} + + let status = app.status().await.expect("Failed to get app status"); + assert_eq!( + status, + Status::Failed(AppFailure::Runtime(Error::MissingBun)) + ); +} diff --git a/plugin/skills/tower/SKILL.md b/plugin/skills/tower/SKILL.md index 476f4b4e..cee59772 100644 --- a/plugin/skills/tower/SKILL.md +++ b/plugin/skills/tower/SKILL.md @@ -92,6 +92,8 @@ Always use `tower_file_update` or `tower_file_add/edit/remove_parameter` to modi To give an app more compute, set `run_size` via `tower_file_update`: one of `basic.xsmall`, `basic.small`, `basic.medium` (the default), `basic.large`. It is versioned with the code, and self-hosted runners ignore it. +To run JavaScript or TypeScript, set `runtime` to `bun` via `tower_file_update` and point `script` at a `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs` or `.cjs` file. Dependencies come from `package.json` (and `bun.lock`). Without `runtime`, `.sh` scripts run with bash and everything else with Python. + ### 2. Local development (preferred) ``` From f3c01f33f839634a74f3e897a3c2c2015eafd4e4 Mon Sep 17 00:00:00 2001 From: Miguel Miranda Date: Tue, 10 Nov 2026 14:03:02 +0100 Subject: [PATCH 2/3] feat: support import_paths with the bun runtime The runtime puts a bun app's import paths on NODE_PATH, as it puts a Python app's on PYTHONPATH, so `import_paths = ["../shared"]` makes `../shared/helpers.ts` importable as "helpers". Shared code is packaged under modules/, outside the app directory, so it never walks up into the app's node_modules. Bun would then quietly auto-install the latest version of whatever the shared code imports. The app's node_modules goes on NODE_PATH after the import paths, so shared code gets the versions the app pinned. Co-Authored-By: Claude Opus 5.5 --- crates/tower-package/src/towerfile.rs | 13 ++--- crates/tower-runtime/src/local.rs | 48 +++++++++++++----- .../10-bun-import-paths/Towerfile | 7 +++ .../example-apps/10-bun-import-paths/bun.lock | 15 ++++++ .../10-bun-import-paths/lib/greet.ts | 6 +++ .../example-apps/10-bun-import-paths/main.ts | 3 ++ .../10-bun-import-paths/package.json | 7 +++ crates/tower-runtime/tests/local_test.rs | 49 +++++++++++++++++++ 8 files changed, 126 insertions(+), 22 deletions(-) create mode 100644 crates/tower-runtime/tests/example-apps/10-bun-import-paths/Towerfile create mode 100644 crates/tower-runtime/tests/example-apps/10-bun-import-paths/bun.lock create mode 100644 crates/tower-runtime/tests/example-apps/10-bun-import-paths/lib/greet.ts create mode 100644 crates/tower-runtime/tests/example-apps/10-bun-import-paths/main.ts create mode 100644 crates/tower-runtime/tests/example-apps/10-bun-import-paths/package.json diff --git a/crates/tower-package/src/towerfile.rs b/crates/tower-package/src/towerfile.rs index 743e56e8..d3b590ee 100644 --- a/crates/tower-package/src/towerfile.rs +++ b/crates/tower-package/src/towerfile.rs @@ -188,13 +188,6 @@ impl Towerfile { }); } - // import_paths are wired into PYTHONPATH only. - if !self.app.import_paths.is_empty() { - return Err(Error::InvalidTowerfile { - message: "import_paths are not supported with runtime \"bun\"".to_string(), - }); - } - Ok(()) } @@ -733,7 +726,7 @@ mod test { } #[test] - fn test_bun_runtime_rejects_import_paths() { + fn test_bun_runtime_accepts_import_paths() { let toml = r#" [app] name = "test" @@ -742,8 +735,8 @@ mod test { import_paths = ["../shared"] "#; - let err = crate::Towerfile::from_toml(toml).unwrap_err(); - assert!(err.to_string().contains("import_paths"), "was: {}", err); + let towerfile = crate::Towerfile::from_toml(toml).unwrap(); + assert_eq!(towerfile.app.import_paths, vec![PathBuf::from("../shared")]); } #[test] diff --git a/crates/tower-runtime/src/local.rs b/crates/tower-runtime/src/local.rs index 4523c3db..1cb3b3ca 100644 --- a/crates/tower-runtime/src/local.rs +++ b/crates/tower-runtime/src/local.rs @@ -179,7 +179,7 @@ async fn inner_execute_local_app( .collect(); if !import_paths.is_empty() { - debug!(ctx: &ctx, "adding import paths to PYTHONPATH: {:?}", import_paths); + debug!(ctx: &ctx, "adding import paths to the module search path: {:?}", import_paths); } // We insert these checks for cancellation along the way to see if the process was @@ -221,7 +221,7 @@ async fn inner_execute_local_app( Ok(code) } else if runtime == Runtime::Bun { let script = Package::resolve_invoke_path(manifest, &package_path); - let env_vars = make_env_vars( + let mut env_vars = make_env_vars( &ctx, &working_dir, &secrets, @@ -229,6 +229,18 @@ async fn inner_execute_local_app( &other_env_vars, &import_paths, ); + if !import_paths.is_empty() { + // Shared code lives under modules/, outside the app directory, so it reaches the + // app's packages through NODE_PATH as well. + extend_search_path( + &mut env_vars, + "NODE_PATH", + import_paths + .iter() + .cloned() + .chain([working_dir.join("node_modules")]), + ); + } execute_bun_app( &ctx, &cancel_token, @@ -642,25 +654,37 @@ fn make_env_vars( let added_keys = res.keys().map(|s| &**s).collect::>().join(", "); debug!(ctx: &ctx, "added keys {}", &added_keys); - let mut path_entries: Vec = res - .remove("PYTHONPATH") + extend_search_path( + &mut res, + "PYTHONPATH", + import_paths.iter().cloned().chain([cwd.clone()]), + ); + + res.insert("PYTHONUNBUFFERED".to_string(), "x".to_string()); + + res +} + +/// Appends `extra` to the path list in `env_vars[key]`, after any entries already there. +fn extend_search_path( + env_vars: &mut HashMap, + key: &str, + extra: impl IntoIterator, +) { + let mut entries: Vec = env_vars + .remove(key) .as_deref() .map(env::split_paths) .into_iter() .flatten() .collect(); - path_entries.extend(import_paths.iter().cloned()); - path_entries.push(cwd.clone()); + entries.extend(extra); - let pythonpath = env::join_paths(&path_entries) + let joined = env::join_paths(&entries) .unwrap() .to_string_lossy() .to_string(); - res.insert("PYTHONPATH".to_string(), pythonpath); - - res.insert("PYTHONUNBUFFERED".to_string(), "x".to_string()); - - res + env_vars.insert(key.to_string(), joined); } #[cfg(unix)] diff --git a/crates/tower-runtime/tests/example-apps/10-bun-import-paths/Towerfile b/crates/tower-runtime/tests/example-apps/10-bun-import-paths/Towerfile new file mode 100644 index 00000000..b034bfb3 --- /dev/null +++ b/crates/tower-runtime/tests/example-apps/10-bun-import-paths/Towerfile @@ -0,0 +1,7 @@ +[app] +name = "10-bun-import-paths" +script = "./main.ts" +runtime = "bun" +import_paths = [ + "./lib", +] diff --git a/crates/tower-runtime/tests/example-apps/10-bun-import-paths/bun.lock b/crates/tower-runtime/tests/example-apps/10-bun-import-paths/bun.lock new file mode 100644 index 00000000..361fa1d0 --- /dev/null +++ b/crates/tower-runtime/tests/example-apps/10-bun-import-paths/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "10-bun-import-paths", + "dependencies": { + "ms": "2.1.2", + }, + }, + }, + "packages": { + "ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], + } +} diff --git a/crates/tower-runtime/tests/example-apps/10-bun-import-paths/lib/greet.ts b/crates/tower-runtime/tests/example-apps/10-bun-import-paths/lib/greet.ts new file mode 100644 index 00000000..26eefb13 --- /dev/null +++ b/crates/tower-runtime/tests/example-apps/10-bun-import-paths/lib/greet.ts @@ -0,0 +1,6 @@ +import ms from "ms"; +import { version } from "ms/package.json"; + +export function greet() { + console.log(`hello from lib, one minute is ${ms(60_000)} (ms ${version})`); +} diff --git a/crates/tower-runtime/tests/example-apps/10-bun-import-paths/main.ts b/crates/tower-runtime/tests/example-apps/10-bun-import-paths/main.ts new file mode 100644 index 00000000..9c752910 --- /dev/null +++ b/crates/tower-runtime/tests/example-apps/10-bun-import-paths/main.ts @@ -0,0 +1,3 @@ +import { greet } from "greet"; + +greet(); diff --git a/crates/tower-runtime/tests/example-apps/10-bun-import-paths/package.json b/crates/tower-runtime/tests/example-apps/10-bun-import-paths/package.json new file mode 100644 index 00000000..10074e10 --- /dev/null +++ b/crates/tower-runtime/tests/example-apps/10-bun-import-paths/package.json @@ -0,0 +1,7 @@ +{ + "name": "10-bun-import-paths", + "private": true, + "dependencies": { + "ms": "2.1.2" + } +} diff --git a/crates/tower-runtime/tests/local_test.rs b/crates/tower-runtime/tests/local_test.rs index 29cede7a..a0625ab2 100644 --- a/crates/tower-runtime/tests/local_test.rs +++ b/crates/tower-runtime/tests/local_test.rs @@ -643,6 +643,55 @@ async fn test_running_bun_app() { ); } +#[cfg(unix)] +#[tokio::test] +async fn test_running_bun_app_with_import_paths() { + if !bun_available() { + return; + } + debug!("Running 10-bun-import-paths"); + + let dir = get_example_app_dir("10-bun-import-paths"); + let package = build_package_from_dir(&dir).await; + let (sender, mut receiver) = unbounded_channel(); + + let opts = StartOptions { + ctx: tower_telemetry::Context::new("runner-id".to_string()), + package, + output_sender: sender, + cwd: None, + environment: "local".to_string(), + secrets: HashMap::new(), + parameters: HashMap::new(), + env_vars: HashMap::new(), + cache_dir: None, + }; + + let app = LocalApp::start(opts).await.expect("Failed to start app"); + + let mut program = Vec::new(); + while let Some(output) = receiver.recv().await { + if let tower_runtime::Channel::Program = output.channel { + program.push(output.line); + } + } + + // main.ts imports the shared module by name, and the shared module gets the app's pinned + // `ms`. Without the app's node_modules on NODE_PATH, bun would auto-install the latest. + assert!( + program.contains(&"hello from lib, one minute is 1m (ms 2.1.2)".to_string()), + "expected output from the shared module; got {:?}", + program + ); + + let status = app.status().await.expect("Failed to get app status"); + assert!( + status == Status::Exited, + "App should have exited cleanly, got {:?}", + status + ); +} + #[cfg(unix)] #[tokio::test] async fn test_running_bun_app_installs_into_the_cache_dir() { From ab44d5b77138fde7093a9771fa6e4b8801120aa3 Mon Sep 17 00:00:00 2001 From: Miguel Miranda Date: Tue, 10 Nov 2026 14:14:05 +0100 Subject: [PATCH 3/3] feat: generate a Towerfile for bun apps from package.json tower_file_generate still reads pyproject.toml when there is one. Without it, it reads package.json and writes a bun Towerfile: the name without its npm scope, and the script from `module` (what `bun init` writes), `main` or the `start` script, then common entry points. A `main` that names a build output that doesn't exist yet is skipped. The workflow help and the skill gain a `bun init -y` step. Co-Authored-By: Claude Opus 5.5 --- crates/tower-cmd/src/error.rs | 9 + crates/tower-cmd/src/mcp.rs | 26 +- crates/tower-cmd/src/skill.rs | 2 + crates/tower-cmd/src/towerfile_gen.rs | 296 ++++++++++++++++++ crates/tower-package/src/lib.rs | 2 +- crates/tower-package/src/towerfile.rs | 12 +- plugin/skills/tower/SKILL.md | 2 + .../features/mcp_app_management.feature | 6 + tests/integration/features/steps/mcp_steps.py | 31 ++ 9 files changed, 369 insertions(+), 17 deletions(-) diff --git a/crates/tower-cmd/src/error.rs b/crates/tower-cmd/src/error.rs index 55461df5..7740f278 100644 --- a/crates/tower-cmd/src/error.rs +++ b/crates/tower-cmd/src/error.rs @@ -63,6 +63,15 @@ pub enum Error { #[snafu(display("No [project] section found in pyproject.toml"))] MissingProjectSection, + #[snafu(display("package.json not found at {}", path))] + PackageJsonNotFound { path: String }, + + #[snafu(display("Failed to parse package.json: {}", source))] + InvalidPackageJson { source: serde_json::Error }, + + #[snafu(display("No pyproject.toml or package.json found in {}", dir))] + ProjectFileNotFound { dir: String }, + // File I/O errors #[snafu(display("File I/O error: {}", source))] IoError { source: std::io::Error }, diff --git a/crates/tower-cmd/src/mcp.rs b/crates/tower-cmd/src/mcp.rs index e922731a..defa8a40 100644 --- a/crates/tower-cmd/src/mcp.rs +++ b/crates/tower-cmd/src/mcp.rs @@ -1145,21 +1145,18 @@ impl TowerService { } #[tool( - description = "Generate Towerfile from pyproject.toml (first step). Don't add build systems to pyproject.toml. Optional: working_directory." + description = "Generate Towerfile from pyproject.toml, or from package.json for a bun app (first step). Don't add build systems to pyproject.toml. Optional: working_directory." )] async fn tower_file_generate( &self, Parameters(request): Parameters, ) -> Result { let working_dir = Self::resolve_working_directory(&request.common); - let pyproject_path = working_dir.join("pyproject.toml"); - let content = match TowerfileGenerator::from_pyproject( - Some(pyproject_path.to_str().unwrap()), - request.script_path.as_deref(), - ) { - Ok(content) => content, - Err(e) => return Self::error_result("Failed to generate Towerfile", e), - }; + let content = + match TowerfileGenerator::from_dir(&working_dir, request.script_path.as_deref()) { + Ok(content) => content, + Err(e) => return Self::error_result("Failed to generate Towerfile", e), + }; let towerfile_path = working_dir.join("Towerfile"); match std::fs::write(&towerfile_path, &content) { @@ -1197,6 +1194,11 @@ All tools accept optional working_directory parameter to specify which project t DO NOT add [build-system], [tool.hatchling], [tool.setuptools] or similar Skip this step if project with pyproject.toml already exists + JAVASCRIPT OR TYPESCRIPT PROJECT (if creating new): + Use 'bun init -y' in project directory to create package.json and index.ts + Skip this step if project with package.json already exists + tower_file_generate then writes a Towerfile with runtime = "bun" + 1. TOWERFILE (required for all Tower operations): tower_file_generate → tower_file_update → tower_file_add/edit/remove_parameter → tower_file_validate CRITICAL: Always use tower_file_update or tower_file_add/edit/remove_parameter to modify @@ -1224,8 +1226,8 @@ All tools accept optional working_directory parameter to specify which project t tower_secrets_create, tower_secrets_list - store credentials, API keys, database passwords TYPICAL WORKFLOW: -1. Check if pyproject.toml exists (or create with uv init) -2. tower_file_generate - creates Towerfile from pyproject.toml +1. Check if pyproject.toml or package.json exists (or create with uv init / bun init -y) +2. tower_file_generate - creates Towerfile from pyproject.toml or package.json 3. tower_run_local - test locally (can access tower secrets) 4. tower_apps_create - create app in Tower cloud 5. tower_deploy - deploy code to Tower @@ -1337,7 +1339,7 @@ Rules: - MCP tools are the authoritative Tower interface (not wrappers) - Use tower_file_update/add/edit/remove_parameter to modify Towerfiles (never edit TOML directly) - DO NOT add hatchling/setuptools to pyproject.toml - Tower handles deployment -- Tower apps need: pyproject.toml (deps only), Python code, Towerfile +- Tower apps need: pyproject.toml (deps only), Python code, Towerfile; or, for bun apps, package.json, JavaScript or TypeScript code, Towerfile - Pass run parameters as a JSON object {\"key\": \"value\"}, not as CLI flags - There is no --param or --params flag diff --git a/crates/tower-cmd/src/skill.rs b/crates/tower-cmd/src/skill.rs index 34554133..a59cd886 100644 --- a/crates/tower-cmd/src/skill.rs +++ b/crates/tower-cmd/src/skill.rs @@ -182,6 +182,8 @@ uv init Creates `pyproject.toml`, `main.py`, `README.md`. Keep `pyproject.toml` minimal — `[project]` metadata and dependencies only. No `[build-system]`, `[tool.hatchling]`, or similar. Skip if a `pyproject.toml` already exists. +For a JavaScript or TypeScript app, run `bun init -y` instead, which creates `package.json` and `index.ts`. Skip it if a `package.json` already exists. Without a `pyproject.toml`, `tower_file_generate` reads `package.json` and sets `runtime = "bun"`. + ### 1. Towerfile ``` diff --git a/crates/tower-cmd/src/towerfile_gen.rs b/crates/tower-cmd/src/towerfile_gen.rs index 58525a45..58c972b4 100644 --- a/crates/tower-cmd/src/towerfile_gen.rs +++ b/crates/tower-cmd/src/towerfile_gen.rs @@ -1,10 +1,129 @@ use crate::Error; use std::fs; use std::path::Path; +use tower_package::is_bun_script; pub struct TowerfileGenerator; impl TowerfileGenerator { + /// Generates a Towerfile from the project file in `dir`: pyproject.toml when there is one, + /// otherwise package.json for a bun app. + pub fn from_dir(dir: &Path, script_path: Option<&str>) -> Result { + let pyproject_path = dir.join("pyproject.toml"); + if pyproject_path.exists() { + return Self::from_pyproject(pyproject_path.to_str(), script_path); + } + + let package_json_path = dir.join("package.json"); + if package_json_path.exists() { + return Self::from_package_json(package_json_path.to_str(), script_path); + } + + Err(Error::ProjectFileNotFound { + dir: dir.display().to_string(), + }) + } + + pub fn from_package_json( + package_json_path: Option<&str>, + script_path: Option<&str>, + ) -> Result { + let package_json_path = package_json_path.unwrap_or("package.json"); + let package_dir = Path::new(package_json_path) + .parent() + .unwrap_or(Path::new(".")); + + if !Path::new(package_json_path).exists() { + return Err(Error::PackageJsonNotFound { + path: package_json_path.to_string(), + }); + } + + let content = fs::read_to_string(package_json_path)?; + let package: serde_json::Value = serde_json::from_str(&content) + .map_err(|source| Error::InvalidPackageJson { source })?; + + // An npm scope (`@acme/report`) isn't part of the app's name. + let app_name = package + .get("name") + .and_then(|n| n.as_str()) + .map(|n| { + n.strip_prefix('@') + .and_then(|scoped| scoped.split_once('/')) + .map_or(n, |(_, name)| name) + }) + .filter(|n| !n.is_empty()) + .unwrap_or("my-app"); + + let description = package + .get("description") + .and_then(|d| d.as_str()) + .unwrap_or(""); + + let script = script_path + .map(String::from) + .or_else(|| Self::find_bun_script(package_dir, &package)) + .unwrap_or_else(|| "./index.ts".to_string()); + + let towerfile = toml::toml! { + [app] + name = app_name + script = script + source = [] + description = description + runtime = "bun" + }; + + Ok(toml::to_string(&towerfile)?) + } + + /// The first runnable file named by package.json (`module`, `main`, then the `start` + /// script), then a conventional entry point, then any JS/TS file in `dir`. + fn find_bun_script(dir: &Path, package: &serde_json::Value) -> Option { + let start_script = package + .get("scripts") + .and_then(|scripts| scripts.get("start")) + .and_then(|start| start.as_str()) + .unwrap_or(""); + + let declared = ["module", "main"] + .into_iter() + .filter_map(|field| package.get(field).and_then(|v| v.as_str())) + .chain(start_script.split_whitespace()); + + let conventional = [ + "index.ts", + "main.ts", + "src/index.ts", + "src/main.ts", + "index.js", + "main.js", + ]; + + declared + .chain(conventional) + .map(|candidate| candidate.trim_start_matches("./")) + // `main` often names a build output that doesn't exist yet. + .find(|candidate| is_bun_script(candidate) && dir.join(candidate).is_file()) + .map(|candidate| format!("./{}", candidate)) + .or_else(|| Self::find_any_bun_file(dir)) + } + + fn find_any_bun_file(dir: &Path) -> Option { + let mut files: Vec = fs::read_dir(dir) + .ok()? + .flatten() + .filter(|entry| entry.path().is_file()) + .filter_map(|entry| entry.file_name().to_str().map(String::from)) + // Type declarations and tool configs are never the app. + .filter(|name| { + is_bun_script(name) && !name.ends_with(".d.ts") && !name.contains(".config.") + }) + .collect(); + files.sort(); + files.into_iter().next().map(|name| format!("./{}", name)) + } + pub fn from_pyproject( pyproject_path: Option<&str>, script_path: Option<&str>, @@ -303,4 +422,181 @@ if __name__ == "__main__": let result = TowerfileGenerator::find_main_script(temp_dir.path()); assert_eq!(result, Some("./main.py".to_string())); } + + /// Generates from `package_json` in a fresh dir holding `files`, and parses the result back + /// so it must also pass Towerfile validation. + fn generate_from_package_json( + package_json: &str, + files: &[&str], + script_path: Option<&str>, + ) -> tower_package::Towerfile { + let temp_dir = create_test_env(); + let package_json_path = temp_dir.path().join("package.json"); + fs::write(&package_json_path, package_json).unwrap(); + for file in files { + let path = temp_dir.path().join(file); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, "console.log('hello');").unwrap(); + } + + let content = TowerfileGenerator::from_package_json( + Some(package_json_path.to_str().unwrap()), + script_path, + ) + .unwrap(); + tower_package::Towerfile::from_toml(&content).unwrap() + } + + #[test] + fn test_from_package_json_bun_init() { + // What `bun init -y` writes. + let towerfile = generate_from_package_json( + r#"{ + "name": "my-report", + "module": "index.ts", + "type": "module", + "private": true, + "scripts": { + "typecheck": "bun check" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^7" + } +}"#, + &["index.ts"], + None, + ); + + assert_eq!(towerfile.app.name, "my-report"); + assert_eq!(towerfile.app.script, "./index.ts"); + assert_eq!(towerfile.app.runtime.as_deref(), Some("bun")); + } + + #[test] + fn test_from_package_json_drops_the_npm_scope() { + let towerfile = generate_from_package_json( + r#"{"name": "@acme/report", "description": "Weekly report"}"#, + &["index.ts"], + None, + ); + + assert_eq!(towerfile.app.name, "report"); + assert_eq!(towerfile.app.description.as_deref(), Some("Weekly report")); + } + + #[test] + fn test_from_package_json_skips_a_main_that_does_not_exist() { + let towerfile = generate_from_package_json( + r#"{"name": "app", "main": "dist/index.js"}"#, + &["src/index.ts"], + None, + ); + + assert_eq!(towerfile.app.script, "./src/index.ts"); + } + + #[test] + fn test_from_package_json_reads_the_start_script() { + let towerfile = generate_from_package_json( + r#"{"name": "app", "scripts": {"start": "bun run --watch ./src/job.ts"}}"#, + &["src/job.ts", "index.ts"], + None, + ); + + assert_eq!(towerfile.app.script, "./src/job.ts"); + } + + #[test] + fn test_from_package_json_falls_back_to_any_script_file() { + let towerfile = generate_from_package_json( + r#"{"name": "app"}"#, + &["env.d.ts", "vite.config.ts", "job.ts"], + None, + ); + + assert_eq!(towerfile.app.script, "./job.ts"); + } + + #[test] + fn test_from_package_json_with_custom_script() { + let towerfile = generate_from_package_json( + r#"{"name": "app", "module": "index.ts"}"#, + &["index.ts"], + Some("./run.ts"), + ); + + assert_eq!(towerfile.app.script, "./run.ts"); + } + + #[test] + fn test_from_package_json_errors() { + let missing = + TowerfileGenerator::from_package_json(Some("/nonexistent/path/package.json"), None); + assert!(missing + .unwrap_err() + .to_string() + .contains("package.json not found")); + + let temp_dir = create_test_env(); + let package_json_path = temp_dir.path().join("package.json"); + fs::write(&package_json_path, "{ not json").unwrap(); + let invalid = + TowerfileGenerator::from_package_json(Some(package_json_path.to_str().unwrap()), None); + assert!(invalid + .unwrap_err() + .to_string() + .contains("Failed to parse package.json")); + } + + #[test] + fn test_from_dir_prefers_pyproject() { + let temp_dir = create_test_env(); + fs::write( + temp_dir.path().join("pyproject.toml"), + "[project]\nname = \"python-app\"\n", + ) + .unwrap(); + fs::write(temp_dir.path().join("main.py"), "print('hello')").unwrap(); + fs::write( + temp_dir.path().join("package.json"), + r#"{"name": "js-tools"}"#, + ) + .unwrap(); + + let content = TowerfileGenerator::from_dir(temp_dir.path(), None).unwrap(); + let towerfile = tower_package::Towerfile::from_toml(&content).unwrap(); + + assert_eq!(towerfile.app.name, "python-app"); + assert_eq!(towerfile.app.runtime, None); + } + + #[test] + fn test_from_dir_uses_package_json_without_pyproject() { + let temp_dir = create_test_env(); + fs::write( + temp_dir.path().join("package.json"), + r#"{"name": "js-app", "module": "index.ts"}"#, + ) + .unwrap(); + fs::write(temp_dir.path().join("index.ts"), "console.log('hello');").unwrap(); + + let content = TowerfileGenerator::from_dir(temp_dir.path(), None).unwrap(); + let towerfile = tower_package::Towerfile::from_toml(&content).unwrap(); + + assert_eq!(towerfile.app.name, "js-app"); + assert_eq!(towerfile.app.runtime.as_deref(), Some("bun")); + } + + #[test] + fn test_from_dir_without_a_project_file() { + let temp_dir = create_test_env(); + + let err = TowerfileGenerator::from_dir(temp_dir.path(), None).unwrap_err(); + assert!(err + .to_string() + .contains("No pyproject.toml or package.json found")); + } } diff --git a/crates/tower-package/src/lib.rs b/crates/tower-package/src/lib.rs index 7cb31ad5..49a8eb52 100644 --- a/crates/tower-package/src/lib.rs +++ b/crates/tower-package/src/lib.rs @@ -5,7 +5,7 @@ pub use core::{ build_package, compute_sha256_bytes, compute_sha256_package, normalize_path, BuiltPackage, Entry, Manifest, PackageInputs, Runtime, CURRENT_PACKAGE_VERSION, }; -pub use towerfile::{App, Parameter, Towerfile, RUNTIMES, RUN_SIZES}; +pub use towerfile::{is_bun_script, App, Parameter, Towerfile, RUNTIMES, RUN_SIZES}; #[cfg(feature = "native")] mod error; diff --git a/crates/tower-package/src/towerfile.rs b/crates/tower-package/src/towerfile.rs index d3b590ee..ed7ea192 100644 --- a/crates/tower-package/src/towerfile.rs +++ b/crates/tower-package/src/towerfile.rs @@ -15,6 +15,13 @@ pub const RUNTIMES: [&str; 3] = ["python", "bash", "bun"]; /// Script extensions the bun runtime accepts. const BUN_SCRIPT_EXTENSIONS: [&str; 6] = ["ts", "tsx", "js", "jsx", "mjs", "cjs"]; +/// Whether the bun runtime can run `script`, judged by its extension. +pub fn is_bun_script(script: &str) -> bool { + script + .rsplit_once('.') + .is_some_and(|(_, ext)| BUN_SCRIPT_EXTENSIONS.contains(&ext)) +} + #[derive(Clone, Deserialize, Serialize, Debug)] pub struct Parameter { #[serde(default)] @@ -175,10 +182,7 @@ impl Towerfile { fn validate_bun_app(&self) -> Result<(), Error> { let script = self.app.script.as_str(); - let is_js = script - .rsplit_once('.') - .is_some_and(|(_, ext)| BUN_SCRIPT_EXTENSIONS.contains(&ext)); - if !is_js { + if !is_bun_script(script) { return Err(Error::InvalidTowerfile { message: format!( "runtime \"bun\" needs a JavaScript or TypeScript script (.{}), but script is {:?}", diff --git a/plugin/skills/tower/SKILL.md b/plugin/skills/tower/SKILL.md index cee59772..54a2257a 100644 --- a/plugin/skills/tower/SKILL.md +++ b/plugin/skills/tower/SKILL.md @@ -82,6 +82,8 @@ uv init Creates `pyproject.toml`, `main.py`, `README.md`. Keep `pyproject.toml` minimal — `[project]` metadata and dependencies only. No `[build-system]`, `[tool.hatchling]`, or similar. Skip if a `pyproject.toml` already exists. +For a JavaScript or TypeScript app, run `bun init -y` instead, which creates `package.json` and `index.ts`. Skip it if a `package.json` already exists. Without a `pyproject.toml`, `tower_file_generate` reads `package.json` and sets `runtime = "bun"`. + ### 1. Towerfile ``` diff --git a/tests/integration/features/mcp_app_management.feature b/tests/integration/features/mcp_app_management.feature index 03762225..2d528be1 100644 --- a/tests/integration/features/mcp_app_management.feature +++ b/tests/integration/features/mcp_app_management.feature @@ -76,6 +76,12 @@ Feature: MCP App Management Then I should receive a valid TOML Towerfile And the Towerfile should contain the project name and description + Scenario: Generate Towerfile from package.json + Given I have a package.json file with project metadata + When I call tower_file_generate via MCP + Then I should receive a valid TOML Towerfile + And the Towerfile should run index.ts with bun + Scenario: List schedules when none exist When I call tower_schedules_list via MCP Then I should receive a response with empty schedules data diff --git a/tests/integration/features/steps/mcp_steps.py b/tests/integration/features/steps/mcp_steps.py index 1f097ba2..7696c282 100644 --- a/tests/integration/features/steps/mcp_steps.py +++ b/tests/integration/features/steps/mcp_steps.py @@ -235,6 +235,21 @@ def step_create_pyproject_toml(context): f.write('print("Hello from test project")\n') +@given("I have a package.json file with project metadata") +def step_create_package_json(context): + package_json_content = """{ + "name": "@acme/test-bun-project", + "description": "A test bun project for Towerfile generation", + "module": "index.ts", + "type": "module" +} +""" + with open("package.json", "w") as f: + f.write(package_json_content) + with open("index.ts", "w") as f: + f.write('console.log("Hello from test bun project");\n') + + @step("I call {tool_name} via MCP") @async_run_until_complete async def step_call_mcp_tool(context, tool_name): @@ -394,6 +409,22 @@ def step_check_towerfile_metadata(context): ), f"Towerfile should contain project name and description, got: {context.mcp_response.get('content', [])}" +@step("the Towerfile should run index.ts with bun") +def step_check_bun_towerfile(context): + """Verify the Towerfile generated from package.json runs its module with bun.""" + assert hasattr(context, "mcp_response"), "No MCP response was recorded" + + found_bun_towerfile = has_text_content( + context.mcp_response, + lambda text: 'name = "test-bun-project"' in text + and 'script = "./index.ts"' in text + and 'runtime = "bun"' in text, + ) + assert ( + found_bun_towerfile + ), f"Towerfile should run index.ts with bun, got: {context.mcp_response.get('content', [])}" + + @step("the MCP server should remain responsive") @async_run_until_complete async def step_check_server_responsive(context):