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.1"
version = "0.2.2"
edition = "2024"
description = "Analyze a project and detect deployable services, languages, frameworks, commands, and env vars"
license = "MIT"
Expand Down
26 changes: 26 additions & 0 deletions src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,10 @@ fn assemble(outputs: Vec<SignalOutput>, root: &Path) -> Discovery {
for service in &mut services {
if let Some(pkg) = mono.packages.values().find(|p| p.dir == service.dir) {
service.detected_by.push(format!("monorepo:{}", pkg.name));
// Rewrite build/dev commands to use the orchestrator tool
if let Some(ref tool) = mono.tool {
enrich_monorepo_commands(service, tool, &pkg.name);
}
}
}
}
Expand All @@ -210,6 +214,28 @@ fn assemble(outputs: Vec<SignalOutput>, root: &Path) -> Discovery {
}

/// Get the "dir name" for a path — used for derived name detection.
/// Rewrite build/dev commands on a service's runtimes to use the monorepo
/// orchestrator tool (turborepo, nx, lerna) instead of bare npm/pnpm scripts.
fn enrich_monorepo_commands(service: &mut Service, tool: &str, package_name: &str) {
for runtime in &mut service.runtimes {
if runtime.build.is_some() {
runtime.build = Some(orchestrator_command(tool, "build", package_name));
}
if runtime.dev.is_some() {
runtime.dev = Some(orchestrator_command(tool, "dev", package_name));
}
}
}

fn orchestrator_command(tool: &str, task: &str, package_name: &str) -> String {
match tool {
"turborepo" => format!("turbo run {task} --filter={package_name}"),
"nx" => format!("nx run {package_name}:{task}"),
"lerna" => format!("lerna run {task} --scope={package_name}"),
_ => format!("{tool} run {task}"),
}
}

fn dir_name(dir: &str, _root: &Path) -> String {
if dir == "." || dir.is_empty() {
// Root: use "app" — matches Dockerfile/Package signal naming conventions
Expand Down
2 changes: 1 addition & 1 deletion src/signals/framework.rs
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,7 @@ mod tests {
// Package's php module sets start (registered before framework)
assert_eq!(
svc.start(),
Some("php bin/console server:start 0.0.0.0:8000")
Some("php -S 0.0.0.0:8000 -t public")
);
}

Expand Down
1 change: 1 addition & 0 deletions src/signals/package/deno.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt
language_config: None,
output_dir: None,
commands,
healthcheck: None,
env: Vec::new(),
system_deps: Vec::new(),
})
Expand Down
7 changes: 4 additions & 3 deletions src/signals/package/elixir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use regex::Regex;
use super::DirFiles;
use crate::fs::FileSystem;
use crate::signals::package::common::{
dir_string, parse_mise_toml, parse_tool_versions, read_text,
dir_string, parse_mise_toml, parse_tool_versions, read_text, strip_version_prefix,
};
use crate::types::{
Commands, DirContext, ElixirConfig, EnvVar, Language, LanguageConfig, PackageManagerInfo,
Expand Down Expand Up @@ -134,6 +134,7 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt
language_config: Some(language_config),
output_dir: None,
commands,
healthcheck: None,
env,
system_deps,
})
Expand All @@ -149,7 +150,7 @@ fn detect_elixir_version(
// 0. .elixir-version
if files.elixir_version_file {
if let Some(raw) = read_text(fs, &dir.join(".elixir-version")) {
let v = raw.trim().to_string();
let v = strip_version_prefix(&raw);
if !v.is_empty() {
return (Some(v), Some(".elixir-version".into()));
}
Expand Down Expand Up @@ -193,7 +194,7 @@ fn detect_erlang_version(
// 0. .erlang-version
if files.erlang_version_file {
if let Some(raw) = read_text(fs, &dir.join(".erlang-version")) {
let v = raw.trim().to_string();
let v = strip_version_prefix(&raw);
if !v.is_empty() {
return (Some(v), Some(".erlang-version".into()));
}
Expand Down
28 changes: 21 additions & 7 deletions src/signals/package/go.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use super::DirFiles;
use crate::fs::FileSystem;
use crate::signals::package::common::{
detect_framework, dir_string, parse_mise_toml, parse_tool_versions, read_text,
strip_version_prefix,
};
use crate::types::{
Commands, DirContext, GoConfig, Language, LanguageConfig, PackageManagerInfo, RuntimeInfo,
Expand All @@ -19,7 +20,7 @@ static GO_VERSION_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^go[ \t]+([0-9]+\.[0-9]+(?:\.[0-9]+)?)").unwrap());

pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Option<DirContext> {
if !files.go_mod {
if !files.go_mod && !files.go_work {
return None;
}

Expand Down Expand Up @@ -65,7 +66,7 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt
.any(|d| cgo_deps.iter().any(|c| d.contains(c)));

// -- System deps --
let mut system_deps = vec!["ca-certificates".to_string()];
let mut system_deps = vec!["ca-certificates".to_string(), "tzdata".to_string()];
if cgo {
for dep in &["gcc", "g++", "libc6-dev"] {
system_deps.push(dep.to_string());
Expand Down Expand Up @@ -113,19 +114,30 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt
language_config: Some(language_config),
output_dir: None,
commands,
healthcheck: None,
env: Vec::new(),
system_deps,
})
}

/// Extract Go version from go.mod, .tool-versions, or .mise.toml.
/// Extract Go version from .go-version, go.mod, .tool-versions, or .mise.toml.
fn detect_go_version(
dir: &Path,
files: &DirFiles,
go_mod: &str,
fs: &dyn FileSystem,
) -> (Option<String>, Option<String>) {
// 1. go.mod "go X.Y.Z"
// 1. .go-version
if files.go_version_file {
if let Some(raw) = read_text(fs, &dir.join(".go-version")) {
let v = strip_version_prefix(&raw);
if !v.is_empty() {
return (Some(v), Some(".go-version".into()));
}
}
}

// 2. go.mod "go X.Y.Z"
for line in go_mod.lines() {
if let Some(caps) = GO_VERSION_RE.captures(line.trim()) {
if let Some(v) = caps.get(1) {
Expand All @@ -134,16 +146,18 @@ fn detect_go_version(
}
}

// 2. .tool-versions
// 3. .tool-versions (supports both "golang" and "go" keys)
if files.tool_versions {
if let Some(content) = read_text(fs, &dir.join(".tool-versions")) {
if let Some(v) = parse_tool_versions(&content, "golang") {
if let Some(v) = parse_tool_versions(&content, "golang")
.or_else(|| parse_tool_versions(&content, "go"))
{
return (Some(v), Some(".tool-versions".into()));
}
}
}

// 3. .mise.toml
// 4. .mise.toml
if files.mise_toml {
if let Some(content) = read_text(fs, &dir.join(".mise.toml")) {
if let Some(v) = parse_mise_toml(&content, "go") {
Expand Down
108 changes: 85 additions & 23 deletions src/signals/package/java.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ static GRADLE_JAVA_RE: LazyLock<Regex> = LazyLock::new(|| {
.unwrap()
});

/// Matches JavaLanguageVersion.of(21) in build.gradle.kts toolchain blocks.
static GRADLE_TOOLCHAIN_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"JavaLanguageVersion\.of\((\d+)\)"#).unwrap()
});

/// Matches <release>21</release> in pom.xml (maven-compiler-plugin 3.6+).
static MAVEN_RELEASE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"<release>([0-9]+)</release>").unwrap());

pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Option<DirContext> {
let has_gradle = files.build_gradle || files.build_gradle_kts;
if !files.pom_xml && !has_gradle {
Expand All @@ -50,7 +59,8 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt
};

// -- Runtime version --
let (version, source) = detect_java_version(dir, files, build_content.as_deref(), is_maven, fs);
let (version, source) =
detect_java_version(dir, files, build_content.as_deref(), is_maven, fs);

let runtime = RuntimeInfo {
name: "java".into(),
Expand All @@ -69,20 +79,22 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt

let framework_patterns: &[(&str, &str)] = &[
("spring-boot", "spring-boot"),
("org.springframework.boot", "spring-boot"),
("quarkus", "quarkus"),
("micronaut", "micronaut"),
("grails", "grails"),
("vertx", "vertx"),
];
// Use the whole content as a single "dep" for matching
let content_as_dep = [content];
let framework = detect_framework(&content_as_dep, framework_patterns).map(String::from);

// -- Commands (Java always gets defaults) --
// -- Commands --
let build_tool_wrapper = files.gradlew || files.mvnw;

let (build_cmd, start_cmd) = if is_maven {
let prefix = if files.mvnw { "./mvnw" } else { "mvn" };
let port_cfg = detect_maven_port_config(content);
let port_cfg = detect_port_config(content, framework.as_deref(), is_maven);
(
format!("{prefix} -B -DskipTests clean package -Pproduction"),
format!("java {port_cfg}$JAVA_OPTS -jar target/*jar")
Expand All @@ -91,12 +103,16 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt
)
} else {
let prefix = if files.gradlew { "./gradlew" } else { "gradle" };
let port_cfg = detect_gradle_port_config(content);
let port_cfg = detect_port_config(content, framework.as_deref(), is_maven);
(
format!("{prefix} clean build -x check -x test -Pproduction"),
format!("java {port_cfg}$JAVA_OPTS -jar build/libs/*jar")
.trim()
.to_string(),
// Filter out -plain jars — Gradle produces both app.jar and app-plain.jar;
// the -plain jar has no dependencies bundled and won't run.
format!(
"java {port_cfg}$JAVA_OPTS -jar $(find build/libs -name '*jar' ! -name '*-plain*' | head -1)"
)
.trim()
.to_string(),
)
};

Expand All @@ -111,9 +127,9 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt
} else {
let prefix = if files.gradlew { "./gradlew" } else { "gradle" };
match framework.as_deref() {
Some("spring-boot") => Some(format!("{prefix} bootRun")),
Some("spring-boot") | Some("grails") => Some(format!("{prefix} bootRun")),
Some("quarkus") => Some(format!("{prefix} quarkusDev")),
Some("micronaut") => Some(format!("{prefix} run")),
Some("micronaut") | Some("vertx") => Some(format!("{prefix} run")),
_ => Some(format!("{prefix} run")),
}
};
Expand All @@ -125,8 +141,29 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt
dev,
};

// -- Health check --
let healthcheck = match framework.as_deref() {
Some("spring-boot") | Some("grails") if content.contains("actuator") => {
Some("/actuator/health".into())
}
Some("quarkus") if content.contains("smallrye-health") => Some("/q/health".into()),
Some("micronaut") if content.contains("micronaut-management") => {
Some("/health".into())
}
_ => None,
};

// -- System deps --
let system_deps = vec!["ca-certificates".into()];

// -- Language config --
let language_config = LanguageConfig::Java(JavaConfig { build_tool_wrapper });
let has_flyway = content.contains("flyway");
let has_liquibase = content.contains("liquibase");
let language_config = LanguageConfig::Java(JavaConfig {
build_tool_wrapper,
has_flyway,
has_liquibase,
});

Some(DirContext {
dir: dir_string(dir),
Expand All @@ -137,8 +174,9 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt
language_config: Some(language_config),
output_dir: None,
commands,
healthcheck,
env: Vec::new(),
system_deps: Vec::new(),
system_deps,
})
}

Expand Down Expand Up @@ -175,6 +213,12 @@ fn detect_java_version(
return (Some(v.as_str().to_string()), Some("pom.xml".into()));
}
}
// pom.xml: <release>21</release> (maven-compiler-plugin 3.6+)
if let Some(caps) = MAVEN_RELEASE_RE.captures(content) {
if let Some(v) = caps.get(1) {
return (Some(v.as_str().to_string()), Some("pom.xml".into()));
}
}
} else {
// build.gradle: sourceCompatibility = 21
if let Some(caps) = GRADLE_JAVA_RE.captures(content) {
Expand All @@ -187,6 +231,17 @@ fn detect_java_version(
return (Some(v.as_str().to_string()), Some(source_file.into()));
}
}
// build.gradle.kts: JavaLanguageVersion.of(21) (toolchain API)
if let Some(caps) = GRADLE_TOOLCHAIN_RE.captures(content) {
if let Some(v) = caps.get(1) {
let source_file = if files.build_gradle_kts {
"build.gradle.kts"
} else {
"build.gradle"
};
return (Some(v.as_str().to_string()), Some(source_file.into()));
}
}
}
}

Expand All @@ -211,20 +266,27 @@ fn detect_java_version(
(None, None)
}

/// Detect Spring Boot or Wildfly Swarm port config for Maven projects.
fn detect_maven_port_config(content: &str) -> &'static str {
if content.contains("org.wildfly.swarm") {
"-Dswarm.http.port=$PORT "
} else if content.contains("org.springframework.boot") && content.contains("spring-boot") {
"-Dserver.port=$PORT "
} else {
""
/// Detect port configuration flag based on framework and build file content.
fn detect_port_config(content: &str, framework: Option<&str>, is_maven: bool) -> &'static str {
// Grails runs on Spring Boot — same port config
match framework {
Some("spring-boot") | Some("grails") => return "-Dserver.port=$PORT ",
Some("quarkus") => return "-Dquarkus.http.port=$PORT ",
Some("micronaut") => return "-Dmicronaut.server.port=$PORT ",
Some("vertx") => return "",
_ => {}
}
}

/// Detect Spring Boot port config for Gradle projects.
fn detect_gradle_port_config(content: &str) -> &'static str {
if content.contains("org.springframework.boot:spring-boot")
// Fallback: detect from build file content
if is_maven {
if content.contains("org.wildfly.swarm") {
"-Dswarm.http.port=$PORT "
} else if content.contains("org.springframework.boot") && content.contains("spring-boot") {
"-Dserver.port=$PORT "
} else {
""
}
} else if content.contains("org.springframework.boot:spring-boot")
|| content.contains("spring-boot-gradle-plugin")
|| content.contains("org.springframework.boot")
{
Expand Down
Loading
Loading