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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **`validation feedback` on an attribute named `Error` parses** (ako/mxcli#1913, mendixlabs/mxcli#1720) — `validation feedback $E/Error message 'x'`, which is what `describe` writes for such an attribute, failed with "missing MESSAGE" because `Error message` was read as the `error message` clause keyword. After a `/` or `.` the word is now always a member name, in any case and anywhere an attribute path appears; the `error message` clause itself is unchanged.
- **`dateFormat: custom` on a dynamic-text parameter is stored as Custom** (ako/mxcli#1864, mendixlabs/mxcli#1706) — `check` accepts a parameter's `dateFormat` and `enumFormat` in any case, but the writer stored the value as written, and `"custom"` is not a value Mendix knows, so the runtime showed the default format. The values are now stored in Mendix's spelling (`Custom`, `DateTime`, `Image`) on dynamic text and on Data grid 2 column templates.
- **A doc comment above `alter entity … add attribute` is stored on the attribute** (ako/mxcli#1863, mendixlabs/mxcli#1705) — `/** Why */` written above the statement was read by nothing: exec reported "Added attribute" and the attribute had no documentation, with only a later `check` warning MDL089. It now documents the attribute the statement adds, as it does above a `create entity` attribute, for `alter entities` too. When the statement adds two attributes, or the attribute has its own comment, the comment is still not stored and `check` still warns.
- **A copied app folder no longer inherits the original's run state, and run state no longer stores secrets** (mendixlabs/mxcli#1764) — `.mxcli/run-state.json`, `run-local.json` and `test-endpoint.json` record the project they were written for, but nothing compared it, so in a copy of a running app `mxcli run` refused to start and `mxcli run stop` signalled the original's process, and `mxcli test --attach` talked to the original's app. All three are now ignored when they name another project (symlinks resolved, case-folded on macOS and Windows); `run status` and `run stop` say that they ignored one. `--constant` values, the password flags and the `DatabasePassword` and `MicroflowConstants` runtime settings are no longer stored in `run-state.json`, and the dev-loop handshake no longer holds the resolved constants. The handshake file `run-local.json` still holds the database password (re-applying constants needs it); it is written owner-only (0600). **Behaviour change:** a run started with `--db-password` (or another inline secret) cannot be `run restart`ed; `run restart` says so and names the alternatives (the `PGPASSWORD` environment variable, or re-running `mxcli run` with the password) instead of starting the app with a placeholder. A run recorded from a devcontainer or WSL path is treated as another folder by the host side, so its state is ignored there.
- **`mxcli test` failure line shows the actual value of an enumeration compare** (mendixlabs/mxcli#1763) — `@expect $Paid/Status = Procurement.InvoiceStatus.PAID` failed with only `expected …`, because the actual value was reported only when the operand type was known and an attribute path next to an enumeration value never was. An enumeration value on one side now pins the other, so the line reads `expected $Paid/Status = Procurement.InvoiceStatus.PAID, actual: APPROVED` (the enumeration key, via `toString()`).

## [0.30.0] - 2026-11-02
Expand Down
7 changes: 5 additions & 2 deletions cmd/mxcli/cmd_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ Examples:
AppPort: info.AppPort,
AdminPort: info.AdminPort,
AdminPass: info.AdminPass,
BootConfig: info.BootConfig,
BootConfig: bootConfigForHandshake(info.BootConfig),
Started: time.Now(),
}); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not publish the dev-loop handshake: %v\n", err)
Expand All @@ -359,8 +359,11 @@ Examples:
if detachedChild {
logPath = docker.RunLogPath(projectPath)
}
// The stored arguments are for `run restart`, not a copy of the
// secrets: they stay out of the file (see redactArgs).
storedArgs, _ := redactArgs(detachArgs(os.Args[1:], projectPath))
state = docker.NewRunStateRecorder(projectPath, os.Getpid(), detachedChild, watch,
detachArgs(os.Args[1:], projectPath), logPath)
storedArgs, logPath)
state.Warn = func(err error) {
fmt.Fprintf(os.Stderr, "Warning: could not publish the run state: %v\n", err)
}
Expand Down
31 changes: 30 additions & 1 deletion cmd/mxcli/cmd_run_lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,11 @@ start it again in the background with the arguments it was started with (as

The arguments come from the project's run state, so this also restarts a run
that has already stopped or crashed. To change a flag, stop it and start a new
one: mxcli run stop -p app.mpr && mxcli run --local --watch --detach -p app.mpr …`,
one: mxcli run stop -p app.mpr && mxcli run --local --watch --detach -p app.mpr …

Secrets given on the command line (--constant values, --db-password,
--screenshot-password, --hub-secret, --debug-pass) are not stored with the run, so
a run started with them cannot be restarted: restart says so and changes nothing.`,
Example: ` mxcli run restart -p app.mpr`,
Run: func(cmd *cobra.Command, args []string) {
projectPath := lifecycleProject(cmd)
Expand All @@ -163,11 +167,23 @@ one: mxcli run stop -p app.mpr && mxcli run --local --watch --detach -p app.mpr
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(runExitFailed)
}
noteForeignRunState(projectPath, os.Stderr)
if st == nil || len(st.Args) == 0 {
fmt.Fprintf(os.Stderr, "nothing to restart: no run of this project is recorded\n"+
" start one: mxcli run --local --watch --detach -p %s\n", projectPath)
os.Exit(runExitNotRunning)
}
// Secrets given on the command line are not stored with the run, so a
// restart would hand the app the placeholder instead of the value. Say so
// before stopping anything.
if hasRedactedArgs(st.Args) {
fmt.Fprintf(os.Stderr, "cannot restart: this run was started with secrets on the command line "+
"(--constant values, --db-password, ...), and they are not stored.\n"+
" Either set the database password in the PGPASSWORD environment variable, or stop the run and\n"+
" start it again with the secrets: mxcli run stop -p %s && mxcli run --local --detach -p %s ...\n",
projectPath, projectPath)
os.Exit(runExitFailed)
}
if code := runStop(projectPath, os.Stdout); code != runExitOK {
os.Exit(code)
}
Expand Down Expand Up @@ -218,6 +234,17 @@ func detachArgs(args []string, projectPath string) []string {
return append(out, "-p", projectPath)
}

// noteForeignRunState says so when the run state in this project's .mxcli/ was
// written for another project, which a copied folder brings along. Every reader
// treats such a state as no run at all (docker.ReadRunState), so a command
// that goes on to report "not running" is not leaving the user guessing why.
func noteForeignRunState(projectPath string, out io.Writer) {
if st := docker.ForeignRunState(projectPath); st != nil {
fmt.Fprintf(out, "note: .mxcli/run-state.json here belongs to the run of %s (pid %d), not this project; ignoring it\n",
st.Project, st.PID)
}
}

// refuseIfRunning returns an error when a live run already serves the project
// (other than this process).
func refuseIfRunning(projectPath string) error {
Expand Down Expand Up @@ -375,6 +402,7 @@ func printLogWarnings(out io.Writer, logPath string, max int) {
// --- status ------------------------------------------------------------

func runStatus(projectPath string, out io.Writer) int {
noteForeignRunState(projectPath, out)
st, err := docker.ReadRunState(projectPath)
if err != nil {
fmt.Fprintf(out, "unknown: %v\n", err)
Expand Down Expand Up @@ -628,6 +656,7 @@ func runWaitReady(projectPath string, timeout time.Duration, out io.Writer) int
// --- stop --------------------------------------------------------------

func runStop(projectPath string, out io.Writer) int {
noteForeignRunState(projectPath, out)
st, err := docker.ReadRunState(projectPath)
if err != nil {
fmt.Fprintf(out, "failed: %v\n", err)
Expand Down
25 changes: 25 additions & 0 deletions cmd/mxcli/devloop_handshake.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"path/filepath"
"time"

"github.com/mendixlabs/mxcli/internal/pathutil"
"github.com/mendixlabs/mxcli/internal/procalive"
)

Expand Down Expand Up @@ -48,6 +49,23 @@ type devLoopHandshake struct {
Started time.Time `json:"started"`
}

// bootConfigForHandshake is the boot payload without MicroflowConstants. Every
// resolved constant is in there, secrets included, and nothing reads them back:
// docker.ApplyConstants replaces that key with the freshly resolved chain
// before it re-sends the payload.
func bootConfigForHandshake(boot map[string]any) map[string]any {
if _, ok := boot["MicroflowConstants"]; !ok {
return boot
}
out := make(map[string]any, len(boot))
for k, v := range boot {
if k != "MicroflowConstants" {
out[k] = v
}
}
return out
}

func devLoopHandshakePath(projectPath string) string {
return filepath.Join(filepath.Dir(projectPath), ".mxcli", devLoopHandshakeName)
}
Expand Down Expand Up @@ -121,6 +139,13 @@ func readDevLoopHandshake(projectPath string) (devLoopHandshake, error) {
if err := json.Unmarshal(body, &h); err != nil {
return h, fmt.Errorf("%s is not valid JSON: %w", path, err)
}
// A copied app folder carries the original's handshake. Its ports and
// password belong to the original's runtime, so it is no run of this project.
if !pathutil.SameProject(h.Project, projectPath) {
return devLoopHandshake{}, fmt.Errorf("%w\n"+
" (%s was left by the run of %s, not this project; ignoring it)%s",
errNoDevLoopHandshake, path, h.Project, runStatusNote(projectPath))
}
if !procalive.Alive(h.PID) {
return h, fmt.Errorf("%s refers to process %d, which is %w\n"+
" (the dev loop was stopped without cleaning up; start a new one)%s",
Expand Down
26 changes: 25 additions & 1 deletion cmd/mxcli/docker/runstate.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"strings"
"sync"
"time"

"github.com/mendixlabs/mxcli/internal/pathutil"
)

// runstate.go is the contract between a running `mxcli run --local` and the
Expand Down Expand Up @@ -119,8 +121,30 @@ type RunState struct {
Updated time.Time `json:"updated"`
}

// ReadRunState reads a project's state file. A missing file is (nil, nil).
// ReadRunState reads a project's state file. A missing file is (nil, nil), and
// so is one that was written for another project: copying an app folder copies
// .mxcli/ with it, and a state that names the original's pid must neither block
// the copy nor be acted on (`run stop` would signal the original's process).
// ForeignRunState says whether that is what was skipped.
func ReadRunState(projectPath string) (*RunState, error) {
st, err := readRunStateFile(projectPath)
if st != nil && !pathutil.SameProject(st.Project, projectPath) {
return nil, nil
}
return st, err
}

// ForeignRunState returns the state in projectPath's .mxcli/ when it was
// written for a different project, so a command can say that it is ignoring it.
func ForeignRunState(projectPath string) *RunState {
st, err := readRunStateFile(projectPath)
if err != nil || st == nil || pathutil.SameProject(st.Project, projectPath) {
return nil
}
return st
}

func readRunStateFile(projectPath string) (*RunState, error) {
body, err := os.ReadFile(RunStatePath(projectPath))
if os.IsNotExist(err) {
return nil, nil
Expand Down
Loading
Loading