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 Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ test-integration:
# Usage: make test-mdl MPR=path/to/app.mpr
MPR ?= app.mpr
test-mdl: build
$(BUILD_DIR)/$(BINARY_NAME) test mdl-examples/doctype-tests/microflow-spec.test.mdl -p $(MPR)
./scripts/run-mdl-tests.sh "$(abspath $(MPR))" "$(abspath $(BUILD_DIR)/$(BINARY_NAME))"

# Lint all code (Go + TypeScript)
lint: lint-go lint-ts
Expand Down
45 changes: 38 additions & 7 deletions cmd/mxcli/docker/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func Build(opts BuildOptions) error {

// Step 2: Resolve MxBuild
fmt.Fprintln(w, "Resolving MxBuild...")
mxbuildPath, err := resolveMxBuild(opts.MxBuildPath)
mxbuildPath, err := resolveMxBuild(opts.MxBuildPath, pv.ProductVersion)
if err != nil {
// Auto-download fallback
fmt.Fprintln(w, " MxBuild not found locally, downloading from CDN...")
Expand Down Expand Up @@ -96,7 +96,7 @@ func Build(opts BuildOptions) error {
// Step 4: Pre-build check
if !opts.SkipCheck {
fmt.Fprintln(w, "Checking project for errors...")
mxPath, err := ResolveMx(opts.MxBuildPath)
mxPath, err := ResolveMxForVersion(opts.MxBuildPath, pv.ProductVersion)
if err != nil {
fmt.Fprintf(w, " Skipping check: %v\n", err)
} else {
Expand Down Expand Up @@ -383,8 +383,10 @@ func Run(opts RunOptions) error {
}

// findPADDir searches for a PAD output in the output directory tree.
// It looks for a Dockerfile first, then for docker_compose/Default.yaml as a fallback
// (MxBuild 11.6.3+ generates docker_compose instead of a Dockerfile).
// It recognizes:
// - classic PAD output with a Dockerfile
// - PAD output with docker_compose/Default.yaml
// - newer PAD output already extracted at the build root (app/bin/etc/lib)
func findPADDir(outputDir string) (string, error) {
// Check output dir itself
if isPADDir(outputDir) {
Expand All @@ -405,19 +407,48 @@ func findPADDir(outputDir string) (string, error) {
}
}

return "", fmt.Errorf("no PAD output found in %s (looked for Dockerfile or docker_compose/Default.yaml)", outputDir)
return "", fmt.Errorf("no PAD output found in %s (looked for Dockerfile, docker_compose/Default.yaml, or extracted app/bin/etc/lib layout)", outputDir)
}

// isPADDir checks if a directory contains PAD output.
// Recognizes both Dockerfile-based PAD and docker_compose-based PAD.
// Recognizes Dockerfile-based PAD, docker_compose-based PAD, and newer
// already-extracted PAD layout at the build root.
func isPADDir(dir string) bool {
if _, err := os.Stat(filepath.Join(dir, "Dockerfile")); err == nil {
return true
}
if _, err := os.Stat(filepath.Join(dir, "docker_compose", "Default.yaml")); err == nil {
return true
}
return false
return hasExtractedPADLayout(dir)
}

func hasExtractedPADLayout(dir string) bool {
requiredDirs := []string{
filepath.Join(dir, "app"),
filepath.Join(dir, "bin"),
filepath.Join(dir, "etc"),
filepath.Join(dir, "lib"),
}
for _, path := range requiredDirs {
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
return false
}
}

requiredFiles := []string{
filepath.Join(dir, "bin", "start"),
filepath.Join(dir, "lib", "runtime", "launcher", "runtimelauncher.jar"),
}
for _, path := range requiredFiles {
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return false
}
}

return true
}

// flattenPADDir moves contents from a PAD subdirectory to the output root directory.
Expand Down
28 changes: 28 additions & 0 deletions cmd/mxcli/docker/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,34 @@ func TestFindPADDir_DockerCompose_InSubdir(t *testing.T) {
}
}

func TestFindPADDir_ExtractedLayoutInRoot(t *testing.T) {
dir := t.TempDir()
for _, subdir := range []string{
filepath.Join(dir, "app"),
filepath.Join(dir, "etc"),
filepath.Join(dir, "bin"),
filepath.Join(dir, "lib", "runtime", "launcher"),
} {
if err := os.MkdirAll(subdir, 0755); err != nil {
t.Fatalf("mkdir %s: %v", subdir, err)
}
}
if err := os.WriteFile(filepath.Join(dir, "bin", "start"), []byte("#!/bin/sh\n"), 0755); err != nil {
t.Fatalf("write start script: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "lib", "runtime", "launcher", "runtimelauncher.jar"), []byte("fake"), 0644); err != nil {
t.Fatalf("write launcher: %v", err)
}

padDir, err := findPADDir(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if padDir != dir {
t.Errorf("expected %s, got %s", dir, padDir)
}
}

func TestFindPADDir_PrefersDockerfile(t *testing.T) {
dir := t.TempDir()
// Both Dockerfile and docker_compose exist — Dockerfile wins
Expand Down
74 changes: 60 additions & 14 deletions cmd/mxcli/docker/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"path/filepath"
"runtime"
"strings"

"github.com/mendixlabs/mxcli/sdk/mpr"
)

// CheckOptions configures the mx check command.
Expand Down Expand Up @@ -44,7 +46,15 @@ func Check(opts CheckOptions) error {
}

// Resolve mx binary
mxPath, err := ResolveMx(opts.MxBuildPath)
projectVersion := ""
if opts.ProjectPath != "" {
if reader, err := mpr.Open(opts.ProjectPath); err == nil {
projectVersion = reader.ProjectVersion().ProductVersion
reader.Close()
}
}

mxPath, err := ResolveMxForVersion(opts.MxBuildPath, projectVersion)
if err != nil {
return err
}
Expand Down Expand Up @@ -88,12 +98,25 @@ func mxBinaryName() string {
return "mx"
}

func mxBinaryNames() []string {
if runtime.GOOS == "windows" {
return []string{"mx.exe", "mx"}
}
return []string{"mx"}
}

// ResolveMx finds the mx executable.
// Priority: derive from mxbuild path > PATH lookup.
func ResolveMx(mxbuildPath string) (string, error) {
return ResolveMxForVersion(mxbuildPath, "")
}

// ResolveMxForVersion finds the mx executable, preferring the project's exact
// Mendix version when multiple local installations or cached downloads exist.
func ResolveMxForVersion(mxbuildPath, preferredVersion string) (string, error) {
if mxbuildPath != "" {
// Resolve mxbuild first to handle directory paths
resolvedMxBuild, err := resolveMxBuild(mxbuildPath)
resolvedMxBuild, err := resolveMxBuild(mxbuildPath, preferredVersion)
if err == nil {
// Look for mx in the same directory as mxbuild
mxDir := filepath.Dir(resolvedMxBuild)
Expand Down Expand Up @@ -123,24 +146,47 @@ func ResolveMx(mxbuildPath string) (string, error) {
return p, nil
}

if preferredVersion != "" {
if studioProDir := ResolveStudioProDir(preferredVersion); studioProDir != "" {
for _, name := range mxBinaryNames() {
candidate := filepath.Join(studioProDir, "modeler", name)
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate, nil
}
}
}
}

// Try OS-specific known locations (Studio Pro on Windows) before cached downloads.
for _, pattern := range mendixSearchPaths(mxBinaryName()) {
matches, _ := filepath.Glob(pattern)
if len(matches) > 0 {
return matches[len(matches)-1], nil
if matches := globVersionedMatches(mendixSearchPaths(mxBinaryName())); len(matches) > 0 {
if exact := exactVersionedPath(matches, preferredVersion); exact != "" {
return exact, nil
}
if newest := NewestVersionedPath(matches); newest != "" {
return newest, nil
}
}

// Try cached mxbuild installations (~/.mxcli/mxbuild/*/modeler/mx).
// NOTE: lexicographic sort is imperfect for versions (e.g. "9.x" > "10.x"),
// but this is a fallback-of-last-resort — in practice users typically have
// only one mxbuild version installed.
if home, err := os.UserHomeDir(); err == nil {
matches, _ := filepath.Glob(filepath.Join(home, ".mxcli", "mxbuild", "*", "modeler", mxBinaryName()))
if len(matches) > 0 {
return matches[len(matches)-1], nil
if preferredVersion != "" {
if p := CachedMxPath(preferredVersion); p != "" {
return p, nil
}
}
if p := AnyCachedMxPath(); p != "" {
return p, nil
}

return "", fmt.Errorf("mx not found; specify --mxbuild-path pointing to Mendix installation directory")
}

func CachedMxPath(version string) string {
cacheDir, err := MxBuildCacheDir(version)
if err != nil {
return ""
}
return cachedBinaryPath(cacheDir, mxBinaryNames())
}

func AnyCachedMxPath() string {
return anyCachedBinaryPath(mxBinaryNames())
}
32 changes: 32 additions & 0 deletions cmd/mxcli/docker/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,35 @@ func TestCheck_SkipUpdateWidgetsFlag(t *testing.T) {
t.Error("check should still be called")
}
}

func TestResolveMxForVersion_PrefersExactCachedVersion(t *testing.T) {
dir := t.TempDir()
setTestHomeDir(t, dir)
// Point PATH at an empty temp dir (rather than clearing it) so exec.LookPath
// still works for any other testing infrastructure but can't find mx.
t.Setenv("PATH", t.TempDir())

versions := []string{"9.24.40.80973", "11.6.3", "11.9.0"}
var expected string
for _, version := range versions {
modelerDir := filepath.Join(dir, ".mxcli", "mxbuild", version, "modeler")
if err := os.MkdirAll(modelerDir, 0755); err != nil {
t.Fatal(err)
}
bin := filepath.Join(modelerDir, mxBinaryName())
if err := os.WriteFile(bin, []byte("fake"), 0755); err != nil {
t.Fatal(err)
}
if version == "11.9.0" {
expected = bin
}
}

result, err := ResolveMxForVersion("", "11.9.0")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != expected {
t.Errorf("expected exact cached mx %s, got %s", expected, result)
}
}
35 changes: 29 additions & 6 deletions cmd/mxcli/docker/detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,12 @@ func findMxBuildInDir(dir string) string {
// CDN downloads are Linux binaries that cannot run natively on Windows.
// The explicit path can be the binary itself or a directory containing it
// (e.g., a Mendix installation root with modeler/mxbuild inside).
func resolveMxBuild(explicitPath string) (string, error) {
func resolveMxBuild(explicitPath string, preferredVersion ...string) (string, error) {
targetVersion := ""
if len(preferredVersion) > 0 {
targetVersion = preferredVersion[0]
}

if explicitPath != "" {
info, err := os.Stat(explicitPath)
if err != nil {
Expand All @@ -94,13 +99,31 @@ func resolveMxBuild(explicitPath string) (string, error) {
return p, nil
}

// Try the exact Studio Pro installation for the project version first.
// This keeps Docker builds on the same Mendix line as the project when
// multiple installs are present locally.
if targetVersion != "" {
if studioProDir := ResolveStudioProDir(targetVersion); studioProDir != "" {
if found := findMxBuildInDir(studioProDir); found != "" {
return found, nil
}
}
}

// Try OS-specific known locations (Studio Pro on Windows) BEFORE cached downloads.
// On Windows, CDN downloads are Linux binaries — Studio Pro's mxbuild.exe is preferred.
for _, pattern := range mxbuildSearchPaths() {
matches, _ := filepath.Glob(pattern)
if len(matches) > 0 {
// Return the last match (likely newest version)
return matches[len(matches)-1], nil
if matches := globVersionedMatches(mxbuildSearchPaths()); len(matches) > 0 {
if exact := exactVersionedPath(matches, targetVersion); exact != "" {
return exact, nil
}
if newest := NewestVersionedPath(matches); newest != "" {
return newest, nil
}
}

if targetVersion != "" {
if p := CachedMxBuildPath(targetVersion); p != "" {
return p, nil
}
}

Expand Down
32 changes: 32 additions & 0 deletions cmd/mxcli/docker/detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,3 +268,35 @@ func TestResolveMxBuild_PrefersStudioProOverCache(t *testing.T) {
t.Errorf("expected Studio Pro binary %s, got %s (should prefer Studio Pro over cache)", studioBin, result)
}
}

func TestResolveMxBuild_PrefersExactCachedVersion(t *testing.T) {
dir := t.TempDir()
setTestHomeDir(t, dir)
// Point PATH at an empty temp dir (rather than clearing it) so exec.LookPath
// still works for any other testing infrastructure but can't find mxbuild.
t.Setenv("PATH", t.TempDir())

versions := []string{"9.24.40.80973", "11.6.3", "11.9.0"}
var expected string
for _, version := range versions {
modelerDir := filepath.Join(dir, ".mxcli", "mxbuild", version, "modeler")
if err := os.MkdirAll(modelerDir, 0755); err != nil {
t.Fatal(err)
}
bin := filepath.Join(modelerDir, mxbuildBinaryName())
if err := os.WriteFile(bin, []byte("fake"), 0755); err != nil {
t.Fatal(err)
}
if version == "11.9.0" {
expected = bin
}
}

result, err := resolveMxBuild("", "11.9.0")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != expected {
t.Errorf("expected exact cached version %s, got %s", expected, result)
}
}
Loading