Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
4 changes: 2 additions & 2 deletions src/signals/framework.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
Expand Down
45 changes: 25 additions & 20 deletions src/signals/package/deno.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 6 additions & 15 deletions src/signals/package/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
39 changes: 24 additions & 15 deletions src/signals/package/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<String> {
/// Uses the package manager's exec command so binaries resolve without PATH setup.
fn infer_node_dev_command(pm: &str, framework: Option<&str>) -> Option<String> {
let cmd = match framework? {
"next" => "next dev",
"nuxt" => "nuxt dev",
Expand All @@ -388,5 +391,11 @@ fn infer_node_dev_command(framework: Option<&str>) -> Option<String> {
"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}"))
}
24 changes: 12 additions & 12 deletions tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) --
Expand Down Expand Up @@ -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"));
}

Expand Down