From b4be2b20765a5b87aab8f6c5c35981d5eb2e013a Mon Sep 17 00:00:00 2001 From: Jared Lunde Date: Sat, 7 Mar 2026 23:51:45 -0600 Subject: [PATCH] execute node scripts in the context of the package manager --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/signals/framework.rs | 4 ++-- src/signals/package/deno.rs | 45 ++++++++++++++++++++----------------- src/signals/package/mod.rs | 21 +++++------------ src/signals/package/node.rs | 39 +++++++++++++++++++------------- tests/integration.rs | 24 ++++++++++---------- 7 files changed, 71 insertions(+), 66 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6560a7b..54bed69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -521,7 +521,7 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "paraglide-launch" -version = "0.2.0" +version = "0.2.1" dependencies = [ "aho-corasick", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 3922c35..b219a56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "paraglide-launch" -version = "0.2.0" +version = "0.2.1" edition = "2024" description = "Analyze a project and detect deployable services, languages, frameworks, commands, and env vars" license = "MIT" diff --git a/src/signals/framework.rs b/src/signals/framework.rs index 2459ee9..69af058 100644 --- a/src/signals/framework.rs +++ b/src/signals/framework.rs @@ -494,8 +494,8 @@ mod tests { let svc = &disc.services[0]; assert_eq!(svc.framework(), Some("nextjs")); // Package scripts win for build and dev - assert_eq!(svc.build(), Some("custom-build")); - assert_eq!(svc.dev(), Some("custom-dev")); + assert_eq!(svc.build(), Some("npm run build")); + assert_eq!(svc.dev(), Some("npm run dev")); // Framework fills the gap for start (not in package.json scripts) assert_eq!(svc.start(), Some("next start")); } diff --git a/src/signals/package/deno.rs b/src/signals/package/deno.rs index 94e8714..ec09cc7 100644 --- a/src/signals/package/deno.rs +++ b/src/signals/package/deno.rs @@ -49,27 +49,32 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt // -- Commands -- let tasks = config.as_ref().and_then(|cfg| cfg.get("tasks")); + let has_task = |name: &str| { + tasks + .and_then(|t| t.get(name)) + .and_then(|v| v.as_str()) + .is_some() + }; - let start = tasks - .and_then(|t| t.get("start")) - .and_then(|v| v.as_str()) - .map(String::from) - .or_else(|| { - main_file - .as_ref() - .map(|f| format!("deno run --allow-all {f}")) - }); - - let build = tasks - .and_then(|t| t.get("build")) - .and_then(|v| v.as_str()) - .map(String::from) - .or_else(|| main_file.as_ref().map(|f| format!("deno cache {f}"))); - - let dev = tasks - .and_then(|t| t.get("dev")) - .and_then(|v| v.as_str()) - .map(String::from); + let start = if has_task("start") { + Some("deno task start".into()) + } else { + main_file + .as_ref() + .map(|f| format!("deno run --allow-all {f}")) + }; + + let build = if has_task("build") { + Some("deno task build".into()) + } else { + main_file.as_ref().map(|f| format!("deno cache {f}")) + }; + + let dev = if has_task("dev") { + Some("deno task dev".into()) + } else { + None + }; let commands = Commands { install: None, diff --git a/src/signals/package/mod.rs b/src/signals/package/mod.rs index 3d346f0..d0ca042 100644 --- a/src/signals/package/mod.rs +++ b/src/signals/package/mod.rs @@ -345,8 +345,8 @@ mod tests { assert_eq!(rt.name, "node"); let pm = svc.runtimes[0].package_manager.as_ref().unwrap(); assert_eq!(pm.name, "npm"); - assert_eq!(svc.start(), Some("node index.js")); - assert_eq!(svc.build(), Some("tsc")); + assert_eq!(svc.start(), Some("npm run start")); + assert_eq!(svc.build(), Some("npm run build")); assert_eq!(svc.install(), Some("npm install")); } @@ -1743,10 +1743,7 @@ end assert_eq!(svc.language(), Some(Language::TypeScript)); assert_eq!(svc.runtimes[0].name, "deno"); assert_eq!(svc.runtimes[0].package_manager.as_ref().unwrap().name, "deno"); - assert_eq!( - svc.start(), - Some("deno run --allow-all main.ts") - ); + assert_eq!(svc.start(), Some("deno task start")); } #[test] @@ -1871,15 +1868,9 @@ end let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); let svc = &disc.services[0]; - assert_eq!(svc.start(), Some("deno run -A server.ts")); - assert_eq!( - svc.build(), - Some("deno compile server.ts") - ); - assert_eq!( - svc.dev(), - Some("deno run --watch server.ts") - ); + assert_eq!(svc.start(), Some("deno task start")); + assert_eq!(svc.build(), Some("deno task build")); + assert_eq!(svc.dev(), Some("deno task dev")); } #[test] diff --git a/src/signals/package/node.rs b/src/signals/package/node.rs index 67b2fb8..2a62a16 100644 --- a/src/signals/package/node.rs +++ b/src/signals/package/node.rs @@ -79,19 +79,21 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt // -- Commands -- let scripts = pkg.get("scripts"); - let start = scripts - .and_then(|s| s.get("start")) - .and_then(|v| v.as_str()) - .map(String::from); - let build = scripts - .and_then(|s| s.get("build")) - .and_then(|v| v.as_str()) - .map(String::from); - let dev = scripts - .and_then(|s| s.get("dev")) - .and_then(|v| v.as_str()) - .map(String::from) - .or_else(|| infer_node_dev_command(framework.as_deref())); + let has_script = |name: &str| { + scripts + .and_then(|s| s.get(name)) + .and_then(|v| v.as_str()) + .is_some() + }; + let run_script = |name: &str| format!("{pm_name} run {name}"); + + let start = if has_script("start") { Some(run_script("start")) } else { None }; + let build = if has_script("build") { Some(run_script("build")) } else { None }; + let dev = if has_script("dev") { + Some(run_script("dev")) + } else { + infer_node_dev_command(pm_name, framework.as_deref()) + }; let install = install_command(pm_name); let commands = Commands { @@ -375,7 +377,8 @@ fn detect_static_site( } /// Infer a dev command from the detected framework when scripts.dev is missing. -fn infer_node_dev_command(framework: Option<&str>) -> Option { +/// Uses the package manager's exec command so binaries resolve without PATH setup. +fn infer_node_dev_command(pm: &str, framework: Option<&str>) -> Option { let cmd = match framework? { "next" => "next dev", "nuxt" => "nuxt dev", @@ -388,5 +391,11 @@ fn infer_node_dev_command(framework: Option<&str>) -> Option { "tanstack-start" => "vinxi dev", _ => return None, }; - Some(cmd.into()) + let exec = match pm { + "bun" => "bunx", + "pnpm" => "pnpm exec", + "yarn" | "yarn-classic" => "yarn exec", + _ => "npx", + }; + Some(format!("{exec} {cmd}")) } diff --git a/tests/integration.rs b/tests/integration.rs index 40e2448..9e0d8cd 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -46,9 +46,9 @@ fn bare_node_app() { // Commands from package.json scripts assert_eq!(svc.install(), Some("npm install")); - assert_eq!(svc.build(), Some("tsc")); - assert_eq!(svc.start(), Some("node server.js")); - assert_eq!(svc.dev(), Some("ts-node server.ts")); + assert_eq!(svc.build(), Some("npm run build")); + assert_eq!(svc.start(), Some("npm run start")); + assert_eq!(svc.dev(), Some("npm run dev")); // Env vars from .env and library_calls (dotenv records key presence, not values) let port = svc.env.iter().find(|e| e.key == "PORT").unwrap(); @@ -87,8 +87,8 @@ fn railway_dockerfile_package() { // Railway start wins (highest priority signal) assert_eq!(svc.start(), Some("node dist/index.js")); // Package.json scripts fill build and dev via context - assert_eq!(svc.build(), Some("tsc")); - assert_eq!(svc.dev(), Some("ts-node src/index.ts")); + assert_eq!(svc.build(), Some("npm run build")); + assert_eq!(svc.dev(), Some("npm run dev")); // Dockerfile sets language to JavaScript (FROM node:20), which lands on the service first. // Package context has TypeScript but service.language is already set. assert_eq!(svc.language(), Some(Language::JavaScript)); @@ -187,9 +187,9 @@ services: // worker: private (no ports) assert_eq!(worker.network, Some(Network::Private)); - // Both get build "tsc" from package context - assert_eq!(api.build(), Some("tsc")); - assert_eq!(worker.build(), Some("tsc")); + // Both get build from package context via pm + assert_eq!(api.build(), Some("npm run build")); + assert_eq!(worker.build(), Some("npm run build")); // Plain Dockerfile should be subsumed — no third "app" service assert!(!disc.services.iter().any(|s| s.name == "app")); @@ -256,13 +256,13 @@ fn pnpm_monorepo_turborepo() { // api: fastify is a server library, not a full framework assert!(api.framework().is_none()); - assert_eq!(api.start(), Some("node dist/index.js")); + assert_eq!(api.start(), Some("npm run start")); // PORT from .env assert!(api.env.iter().any(|e| e.key == "PORT")); // web: next framework (from "next" dep in package.json; Package signal uses dep name) assert_eq!(web.framework(), Some("next")); - assert_eq!(web.start(), Some("next start")); + assert_eq!(web.start(), Some("npm run start")); // Monorepo detected let mono = disc.monorepo.as_ref().unwrap(); @@ -407,7 +407,7 @@ fn malformed_files_no_panic() { // Railway signal errors and is skipped, package signal succeeds assert!(!disc.services.is_empty()); let svc = &disc.services[0]; - assert_eq!(svc.start(), Some("node index.js")); + assert_eq!(svc.start(), Some("npm run start")); } // -- Scenario 12: Rails + React (Multi-Language) -- @@ -556,7 +556,7 @@ fn pure_node_single_runtime() { assert_eq!(svc.runtimes.len(), 1, "pure Node should have exactly one runtime"); assert_eq!(svc.runtimes[0].language, Language::JavaScript); - assert_eq!(svc.start(), Some("node index.js")); + assert_eq!(svc.start(), Some("npm run start")); assert_eq!(svc.runtimes[0].install.as_deref(), Some("npm install")); }