From 6c74296cdaa057cf66a57db44625c2ee49b1ec06 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 09:47:36 +0000 Subject: [PATCH 1/8] docs(bootstrap-prompt): interview the user before provisioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt hardcoded "App" as the application name, so every repo bootstrapped from it ended up with App.mpr regardless of what was being built — and the name is awkward to change afterwards: it is the .mpr file name, the Studio Pro app name, and the path baked into the SessionStart hook. A Step 0 now asks for the app name, what the app is for, what it keeps track of, who logs in, the theme and the Mendix version — all in one message with defaults, so "defaults" is a valid answer and the agent is told not to block twice. The answers become the brief, written to README.md and committed, so a session resuming after an idle reap knows what it is building. The provisioning steps take throughout, and a closing step has the agent propose the model from the brief and wait rather than inventing one. Two corrections while rewriting, both verified rather than assumed: - "Create the app at the repo root: mxcli new App" cannot work. mxcli new refuses a directory that is not empty and .git alone trips it, and with no --output-dir it creates ./App/, leaving every later `-p App.mpr` wrong by one directory. The prompt now creates in a subfolder and moves the contents up, which is what the SessionStart hook's relative path needs. - The blank template's module is MyFirstModule, not the app name, so the closing step asks for a module name explicitly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- docs-site/src/tools/bootstrap-prompt.md | 110 +++++++++++++++++++----- 1 file changed, 87 insertions(+), 23 deletions(-) diff --git a/docs-site/src/tools/bootstrap-prompt.md b/docs-site/src/tools/bootstrap-prompt.md index 5d85e75d1..b78271db6 100644 --- a/docs-site/src/tools/bootstrap-prompt.md +++ b/docs-site/src/tools/bootstrap-prompt.md @@ -2,8 +2,15 @@ The **primary** way to start a Mendix + mxcli project from the web or an iPad — no local CLI, no GitHub template to pick from a (short) mobile list. Open an **empty -repo** in Claude Code Web and paste the prompt below; the agent provisions -everything and commits the result so future sessions self-bootstrap. +repo** in Claude Code Web and paste the prompt below; the agent asks you what the app +is, then provisions everything and commits the result so future sessions +self-bootstrap. + +The interview comes first for a reason: the app name becomes the `.mpr` file name, the +Studio Pro app name and the path baked into the SessionStart hook, so it is far cheaper +to ask than to rename afterwards. The rest of the answers are the brief — they get +written into the repo, so the session that resumes after an idle reap knows what it is +building. Why a prompt instead of a GitHub template repo: the mobile "New repository" template dropdown shows only a small subset of templates, and a template repo needs per-Mendix- @@ -13,7 +20,37 @@ can seed the model from a design prototype in the same session — nothing to ma ## The prompt ````text -This is an empty repo. Provision it as a Mendix app developed with mxcli: +This is an empty repo. You are going to provision it as a Mendix app developed with +mxcli — but first find out what the app is. + +## Step 0 — interview me, and WAIT for my answers before running anything + +Ask all of these in ONE message, numbered, each with the default you would pick, so I +can reply "defaults" or answer only the ones I care about. Do not start provisioning +until I have replied. + +1. **App name.** Becomes the `.mpr` file name, the app name in Studio Pro, and the + path in the session hook, so it is awkward to change later. One PascalCase word, + letters and digits only — `OrderPortal`, `FieldService`, `ClubAdmin`. Propose one + from my answer to Q2. +2. **What is the app for?** One or two sentences: who uses it, and what it lets them + do. If my answer is vague ("a tool for work"), ask one follow-up — everything below + is derived from this. +3. **What does it keep track of?** Three to six nouns that will become entities, and a + word on how they relate (e.g. "a Job has many Visits; each Visit has Photos"). +4. **Who logs in?** The user roles, and roughly what each may do (e.g. "Requester + creates and sees their own; Approver sees everything and approves"). +5. **Look and feel.** One of the bundled themes: `signal` (light, high contrast), + `ledger` (light, dense, data-heavy), `console` (dark), or `none` for stock Atlas. + Default `signal`. +6. **Mendix version.** Default `11.6.3`. + +If I say "defaults" or ignore a question, choose something sensible for it, tell me +what you chose in one line, and keep going — do not block on me twice. + +## Then provision + +Substitute my answers for ``, `` and `` throughout. 1. Ensure `mxcli` is available. It should be pre-installed by the environment; if not, download a prebuilt binary for your OS/arch and put it at `./mxcli`, e.g.: @@ -30,28 +67,55 @@ This is an empty repo. Provision it as a Mendix app developed with mxcli: release instead (`.../releases/download/vX.Y.Z/mxcli--`). Note: `go install …@latest` does **not** work — the generated ANTLR parser isn't committed, so use the prebuilt binary (a from-source build needs `make grammar`). -2. Create the app at the repo root: `mxcli new App --version 11.6.3` - (or `mxcli init` if an .mpr already exists). -3. Ensure the Claude tooling is set up: `mxcli init --tool claude`. This adds a - SessionStart hook to `.claude/settings.json` that self-bootstraps future sessions. -4. Bring prerequisites up: `./mxcli run --local --setup --ensure-db -p App.mpr` +2. Create the app, and put it at the **repo root** — that is where `.claude/` and the + `./mxcli` binary have to live for future sessions to self-bootstrap. `mxcli new` + refuses to write into a directory that is not empty, and a git repo always has + `.git`, so create it in a subfolder and move it up: + + ```bash + ./mxcli new --version --theme + shopt -s dotglob && mv /* . && rmdir + ``` + + (Use `mxcli init` instead if an `.mpr` already exists.) `mxcli new` also runs + `mxcli init`, which writes `.claude/settings.json` with a SessionStart hook + pointing at `-p .mpr` — check that the path in it is right after the move. +3. Confirm the Claude tooling: `./mxcli init --tool claude` (idempotent — it is what + step 2 already ran, and re-running it is the cheapest way to be sure the hook, + skills and commands are in place). +4. Bring prerequisites up: `./mxcli run --local --setup --ensure-db -p .mpr` (caches MxBuild + runtime, starts Postgres, creates the app database). -5. Create a `FINDINGS.md` at the repo root and keep appending to it as you work. +5. Write the brief to `README.md` at the repo root: the app name, my answers to Q2–Q4 + in my words, and the theme and Mendix version you used. This is what tells the next + session — after an idle reap, with none of this conversation — what it is building. + Keep it short enough that it stays true. +6. Create a `FINDINGS.md` at the repo root and keep appending to it as you work. Log anything surprising or broken: an mxcli command that errored, a workaround you applied, a `mxcli check` that passed but a real `mx check` later flagged. Note the Mendix + mxcli versions and how each finding was verified. This is durable context for the next session, and the most useful thing to share back to improve mxcli. -6. COMMIT everything now — `App.mpr`, `.devcontainer/`, `.claude/` (including the - SessionStart hook), and `FINDINGS.md` — so that after idle reaping the next session - bootstraps from files, not from re-running this prompt. -7. Boot and verify: `./mxcli run --local -p App.mpr` in the background, then confirm - the app answers HTTP 200 at http://localhost:8080/ and report. -8. (Optional) For a browser preview from this cloud session, run - `./mxcli run --hub https://hub.mxcli.org -p App.mpr` and report the preview URL it - prints. This needs `MXCLI_HUB_KEY` set on the environment (see the workflow page); - without it, continue as a normal local run. - -(Optional) Seed the domain model, pages, and microflows from this prototype: . +7. COMMIT everything now — `.mpr`, `.devcontainer/`, `.claude/` (including the + SessionStart hook), `README.md` and `FINDINGS.md` — so that after idle reaping the + next session bootstraps from files, not from re-running this prompt. +8. Boot and verify: `./mxcli run --local -p .mpr` in the background, then + confirm the app answers HTTP 200 at http://localhost:8080/ and report. +9. (Optional) For a browser preview from this cloud session, run + `./mxcli run --hub https://hub.mxcli.org -p .mpr` and report the preview + URL it prints. This needs `MXCLI_HUB_KEY` set on the environment (see the workflow + page); without it, continue as a normal local run. + +## Then propose the model — do not build it yet + +The blank template ships a `MyFirstModule`; the app's own work belongs in a module +named after it. From the brief, propose in chat: + +- a module name, and the entities from Q3 with their attributes and associations +- the user roles from Q4 and what each may read/write +- the handful of pages that make it usable + +Show me that as MDL I can read, and wait for my go-ahead before executing it. If I +gave you a design to work from, use it as the source of truth for the model and the +pages: . ```` ## Which mxcli version gets installed @@ -82,7 +146,7 @@ tag (latest is v0.16.0) **and** as a rolling `nightly` pre-release, with assets ## Two rules that make this robust -- **Committing the config (step 5) is mandatory.** The prompt is a *one-time seed*. +- **Committing the config (step 7) is mandatory.** The prompt is a *one-time seed*. Its output — `.mpr` + `.devcontainer/` + `.claude/` with the SessionStart hook — must be committed so the steady state is file-driven and deterministic. After that, every new session runs the hook (`run --local --setup --ensure-db`) automatically; you @@ -96,8 +160,8 @@ tag (latest is v0.16.0) **and** as a rolling `nightly` pre-release, with assets ## After bootstrap — the inner loop ```bash -./mxcli run --local -p App.mpr --watch --screenshot # warm dev loop + screenshots -./mxcli exec change.mdl -p App.mpr # edit the model; the loop hot-applies +./mxcli run --local -p .mpr --watch --screenshot # warm dev loop + screenshots +./mxcli exec change.mdl -p .mpr # edit the model; the loop hot-applies ``` See [mxcli run --local](run-local.md) for the warm loop, `--watch`, `--ensure-db`, and From d85e8f1c94a2979bf9bd771783f87971f7f50cdd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 09:54:19 +0000 Subject: [PATCH 2/8] docs(bootstrap-prompt): cover multi-app solutions; default to 11.13.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt assumed one app at the repo root, which is most of what people want but silently wrong for a solution — a backend that owns the data and publishes OData, plus a frontend that consumes it. The interview now opens by asking the shape, and a new section carries the deltas that a second app actually needs. They are deltas, not a rewrite: per-app subfolders instead of moving to the root, explicit ports for the second app (8180/8190/6643 — 8081/8091/6544 belong to `mxcli test --local`), and `--hub-solution` so previews group. Databases need no action, since the name is derived from the .mpr file name. Two things the agent could not have inferred, both verified: - `mxcli init` dedupes the SessionStart hook on the command, not on the project, so a second app never gets its own entry. Claude Code reads the root `.claude/settings.json`, which has to be written by hand, one line per app. - `CREATE ODATA CLIENT` fetches the $metadata at creation time and caches it (warning, not failing, when unreachable). So the producer must be published and running before the consumer is wired, and ServiceUrl belongs in a constant since it will not stay localhost. Default Mendix version 11.6.3 -> 11.13.0, with a note on what "newest supported" means: both mxbuild- and mendix- must be on the CDN, and a solution's apps should share a version. Confirmed 11.13.0 serves both tarballs and 11.14.0 serves neither. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- docs-site/src/tools/bootstrap-prompt.md | 96 +++++++++++++++++++++---- 1 file changed, 83 insertions(+), 13 deletions(-) diff --git a/docs-site/src/tools/bootstrap-prompt.md b/docs-site/src/tools/bootstrap-prompt.md index b78271db6..2f051a290 100644 --- a/docs-site/src/tools/bootstrap-prompt.md +++ b/docs-site/src/tools/bootstrap-prompt.md @@ -29,28 +29,34 @@ Ask all of these in ONE message, numbered, each with the default you would pick, can reply "defaults" or answer only the ones I care about. Do not start provisioning until I have replied. -1. **App name.** Becomes the `.mpr` file name, the app name in Studio Pro, and the +1. **One app, or a solution of several?** One Mendix app is the default. Say + "solution" if this is several apps in one repo — e.g. a backend that owns the data + and publishes OData/REST, and a frontend that consumes it. If so, ask for each + app's name and one line on what it owns, and follow the multi-app deltas below. +2. **App name.** Becomes the `.mpr` file name, the app name in Studio Pro, and the path in the session hook, so it is awkward to change later. One PascalCase word, letters and digits only — `OrderPortal`, `FieldService`, `ClubAdmin`. Propose one - from my answer to Q2. -2. **What is the app for?** One or two sentences: who uses it, and what it lets them + from my answer to Q3. +3. **What is the app for?** One or two sentences: who uses it, and what it lets them do. If my answer is vague ("a tool for work"), ask one follow-up — everything below is derived from this. -3. **What does it keep track of?** Three to six nouns that will become entities, and a - word on how they relate (e.g. "a Job has many Visits; each Visit has Photos"). -4. **Who logs in?** The user roles, and roughly what each may do (e.g. "Requester +4. **What does it keep track of?** Three to six nouns that will become entities, and a + word on how they relate (e.g. "a Job has many Visits; each Visit has Photos"). For + a solution, also ask which app owns each noun. +5. **Who logs in?** The user roles, and roughly what each may do (e.g. "Requester creates and sees their own; Approver sees everything and approves"). -5. **Look and feel.** One of the bundled themes: `signal` (light, high contrast), +6. **Look and feel.** One of the bundled themes: `signal` (light, high contrast), `ledger` (light, dense, data-heavy), `console` (dark), or `none` for stock Atlas. Default `signal`. -6. **Mendix version.** Default `11.6.3`. +7. **Mendix version.** Default `11.13.0`. If I say "defaults" or ignore a question, choose something sensible for it, tell me what you chose in one line, and keep going — do not block on me twice. ## Then provision -Substitute my answers for ``, `` and `` throughout. +Substitute my answers for ``, `` and `` throughout. For a +solution, do steps 2–4 once per app and read "If this is a solution" first. 1. Ensure `mxcli` is available. It should be pre-installed by the environment; if not, download a prebuilt binary for your OS/arch and put it at `./mxcli`, e.g.: @@ -85,8 +91,9 @@ Substitute my answers for ``, `` and `` throughout. skills and commands are in place). 4. Bring prerequisites up: `./mxcli run --local --setup --ensure-db -p .mpr` (caches MxBuild + runtime, starts Postgres, creates the app database). -5. Write the brief to `README.md` at the repo root: the app name, my answers to Q2–Q4 - in my words, and the theme and Mendix version you used. This is what tells the next +5. Write the brief to `README.md` at the repo root: the app name(s), my answers to + Q3–Q5 in my words, and the theme and Mendix version you used. For a solution, say + which app owns what and how they talk to each other. This is what tells the next session — after an idle reap, with none of this conversation — what it is building. Keep it short enough that it stays true. 6. Create a `FINDINGS.md` at the repo root and keep appending to it as you work. @@ -104,14 +111,51 @@ Substitute my answers for ``, `` and `` throughout. URL it prints. This needs `MXCLI_HUB_KEY` set on the environment (see the workflow page); without it, continue as a normal local run. +## If this is a solution (several apps in one repo) + +Each app is a full Mendix project — one `.mpr`, one runtime, one database. Same steps, +with these deltas: + +- **Layout.** One subfolder per app, nothing at the repo root but `README.md`, + `FINDINGS.md` and `.claude/`. Run `mxcli new --version --theme + ` once per app and leave each where it lands; do not move anything up. +- **Ports.** Every app defaults to 8080/8090/6543 and they will collide. Give the + first app the defaults and the second `--app-port 8180 --admin-port 8190 + --serve-port 6643`. Avoid 8081/8091/6544 — `mxcli test --local` uses those. +- **Databases** need no action: the name is derived from the `.mpr` file name, so + differently-named apps get different databases. +- **The session hook.** `mxcli init` writes `.claude/settings.json` inside each app + folder, but Claude Code reads the one at the **repo root** — and it will not add a + second entry for you (it dedupes on the command, not on the project). Write the root + one yourself, one line per app, e.g. + `test -x backend/mxcli && (cd backend && ./mxcli run --local --setup --ensure-db -p Backend.mpr) || true`. + Verify it by checking that a fresh shell can boot each app. +- **Previews.** Pass `--hub-solution ` to every `run --hub` so the apps + appear grouped in the hub overview instead of as unrelated previews. + +**Wire the integration in dependency order — the producer must be running first.** +`CREATE ODATA CLIENT` fetches the `$metadata` at the moment you create it and caches +it in the model; if the URL is unreachable it warns and leaves the client unvalidated, +with no external entities to import. So: publish on the producer +(`CREATE ODATA SERVICE … publish entity …`), boot it (`run --local`), and only then, +on the consumer, `CREATE ODATA CLIENT … MetadataUrl: 'http://localhost:8080/odata/…/$metadata'` +followed by `CREATE EXTERNAL ENTITIES FROM …`. Point `ServiceUrl` at a **constant** +(`ServiceUrl: @Module.SvcUrl`) so the address can be changed per environment without +touching the model — it will not stay `localhost`. `mxcli syntax odata.publish` and +`mxcli syntax odata.consume` have the full syntax; business events +(`mxcli syntax business-events`) are the alternative when the link should be +asynchronous. + ## Then propose the model — do not build it yet The blank template ships a `MyFirstModule`; the app's own work belongs in a module named after it. From the brief, propose in chat: -- a module name, and the entities from Q3 with their attributes and associations -- the user roles from Q4 and what each may read/write +- a module name, and the entities from Q4 with their attributes and associations +- the user roles from Q5 and what each may read/write - the handful of pages that make it usable +- for a solution: which app owns each entity, and what crosses the boundary — publish + only what the other app actually needs Show me that as MDL I can read, and wait for my go-ahead before executing it. If I gave you a design to work from, use it as the source of truth for the model and the @@ -144,6 +188,22 @@ tag (latest is v0.16.0) **and** as a rolling `nightly` pre-release, with assets `make grammar` first). Enabling `go install` would require committing the generated parser (or generating it during module build) — a maintainer decision. +## Which Mendix version to ask for + +The prompt defaults to the newest version that has a published MxBuild — everything +mxcli does starts with downloading it, so "supported" means "on the CDN". Check before +bumping the default: + +```bash +curl -sI -o /dev/null -w '%{http_code}\n' https://cdn.mendix.com/runtime/mxbuild-11.13.0.tar.gz # 200 +curl -sI -o /dev/null -w '%{http_code}\n' https://cdn.mendix.com/runtime/mendix-11.13.0.tar.gz # 200 (runtime) +``` + +Both have to answer `200` — `run --local` needs the runtime tarball as well as +MxBuild. In a solution, give every app the **same** version: they share the +`~/.mxcli/mxbuild` cache, and a mismatch means a second multi-hundred-MB download and +two runtimes to keep straight. + ## Two rules that make this robust - **Committing the config (step 7) is mandatory.** The prompt is a *one-time seed*. @@ -164,5 +224,15 @@ tag (latest is v0.16.0) **and** as a rolling `nightly` pre-release, with assets ./mxcli exec change.mdl -p .mpr # edit the model; the loop hot-applies ``` +In a solution, run one loop per app from its own folder, with the second app on the +alternate ports, and start the producer first so the consumer's external entities +resolve: + +```bash +(cd backend && ./mxcli run --local -p Backend.mpr --watch) +(cd frontend && ./mxcli run --local -p Frontend.mpr --watch \ + --app-port 8180 --admin-port 8190 --serve-port 6643) +``` + See [mxcli run --local](run-local.md) for the warm loop, `--watch`, `--ensure-db`, and the screenshot flags. From 0bb93b8f23fa825837dc7c790d8dbcb7ccccb211 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 10:04:26 +0000 Subject: [PATCH 3/8] docs(bootstrap-prompt): give each app in a solution its own hostname MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separating a solution's apps by port alone does not separate their sessions: cookies are keyed on host name and ignore the port, so localhost:8080 and localhost:8180 share one jar and a login to one can replace the other's XASSESSIONID. Distinct hostnames fix it and need no mxcli change, which is worth stating explicitly because the obvious alternative — one loopback IP per app on a shared port — is not possible today: the runtime binds 127.0.0.1 and there is no listen-address flag. Verified against a booted app on 11.12.1: a foreign Host header is served 200 (via /etc/hosts, nip.io and localtest.me alike), and the client uses relative URLs, so no ApplicationRootUrl is needed for this. /etc/hosts is recommended over public wildcard DNS for locked-down containers, where localtest.me resolves to ::1. Also notes the one thing the hostname alone does not cover: ApplicationRootUrl is only set by --hub, so absolute-URL cases (OIDC/SAML redirect URIs, deep links) still advertise the listen address. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- docs-site/src/tools/bootstrap-prompt.md | 30 +++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/docs-site/src/tools/bootstrap-prompt.md b/docs-site/src/tools/bootstrap-prompt.md index 2f051a290..c2e19f73b 100644 --- a/docs-site/src/tools/bootstrap-prompt.md +++ b/docs-site/src/tools/bootstrap-prompt.md @@ -122,6 +122,27 @@ with these deltas: - **Ports.** Every app defaults to 8080/8090/6543 and they will collide. Give the first app the defaults and the second `--app-port 8180 --admin-port 8190 --serve-port 6643`. Avoid 8081/8091/6544 — `mxcli test --local` uses those. +- **Give each app its own hostname**, not just its own port. Cookies are keyed on + host name and **ignore the port**, so two apps on `localhost:8080` and + `localhost:8180` share one cookie jar: logging into one can silently replace the + other's `XASSESSIONID`. Two hostnames give two jars, and the differing ports do no + harm. Add them to `/etc/hosts` — + + ``` + 127.0.0.1 backend.local frontend.local + ``` + + — and browse `http://backend.local:8080/` and `http://frontend.local:8180/`. No + mxcli flag is involved: the runtime binds `127.0.0.1` and serves any `Host` you + send it, and the client uses relative URLs, so it works under any name that + resolves to loopback. (`*.nip.io` works too if you would rather not touch + `/etc/hosts`; prefer `/etc/hosts` in a locked-down container, where public wildcard + DNS may not resolve — `localtest.me` resolves to `::1` in some of them.) + + One limit worth knowing before you design around it: the app's + `ApplicationRootUrl` is only set by `--hub`, so anything needing an **absolute** + URL — an OIDC/SAML redirect URI, a deep link in an email — still points at the + listen address rather than your chosen hostname. - **Databases** need no action: the name is derived from the `.mpr` file name, so differently-named apps get different databases. - **The session hook.** `mxcli init` writes `.claude/settings.json` inside each app @@ -138,8 +159,9 @@ with these deltas: it in the model; if the URL is unreachable it warns and leaves the client unvalidated, with no external entities to import. So: publish on the producer (`CREATE ODATA SERVICE … publish entity …`), boot it (`run --local`), and only then, -on the consumer, `CREATE ODATA CLIENT … MetadataUrl: 'http://localhost:8080/odata/…/$metadata'` -followed by `CREATE EXTERNAL ENTITIES FROM …`. Point `ServiceUrl` at a **constant** +on the consumer, `CREATE ODATA CLIENT … MetadataUrl: 'http://backend.local:8080/odata/…/$metadata'` +followed by `CREATE EXTERNAL ENTITIES FROM …`. Use the hostname here too, so the +cached contract and the constant below agree with what the browser sees. Point `ServiceUrl` at a **constant** (`ServiceUrl: @Module.SvcUrl`) so the address can be changed per environment without touching the model — it will not stay `localhost`. `mxcli syntax odata.publish` and `mxcli syntax odata.consume` have the full syntax; business events @@ -234,5 +256,9 @@ resolve: --app-port 8180 --admin-port 8190 --serve-port 6643) ``` +With `127.0.0.1 backend.local frontend.local` in `/etc/hosts`, browse them at +`http://backend.local:8080/` and `http://frontend.local:8180/` so each app gets its +own cookie jar. + See [mxcli run --local](run-local.md) for the warm loop, `--watch`, `--ensure-db`, and the screenshot flags. From 27f6426ef9833d30b929006d9749b7a4d408c370 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 10:17:54 +0000 Subject: [PATCH 4/8] feat(run): honour the app's configured Application root URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project's own configuration (App Settings -> Configurations in Studio Pro, `alter settings configuration '…' ApplicationRootUrl = …` in MDL) is where a custom host name belongs — versioned with the app, per configuration, no flag to repeat. `run --local` ignored it: the boot payload's ApplicationRootUrl came only from a --hub registration, and since update_configuration replaces rather than merges, nothing else could supply it. It is now read at boot and used when no hub URL was assigned (a hub assignment wins, being the URL actually serving the app). Without it, serving a solution's apps under their own host names still works — the runtime accepts any Host and the client uses relative URLs — but the absolute URLs Mendix generates for OIDC/SAML redirects and deep links kept naming the listen address. The trap: a blank Mendix app already ships ApplicationRootUrl = http://localhost:8080/, so "is set" does not mean "was chosen". Honouring every value would change behaviour for every existing project and advertise the wrong port under --app-port. Only a non-loopback host is passed through, and a port disagreeing with --app-port warns. Verified end-to-end on 11.12.1: with backend.local configured, the boot prints the configuration it came from and the app answers 200 on both the host name and the listen address. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- .claude/skills/fix-issue.md | 1 + cmd/mxcli/docker/runlocal.go | 97 +++++++++++++++++++++++++ cmd/mxcli/docker/runlocal_test.go | 90 +++++++++++++++++++++++ docs-site/src/tools/bootstrap-prompt.md | 31 +++++--- 4 files changed, 208 insertions(+), 11 deletions(-) diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index 8011b92d8..2dde76bff 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -359,6 +359,7 @@ cases for these three BSON types — they fell to `default: return nil`. | A loop's variable used after `end loop;` passes `mxcli check`, then `mx check` fails `[error] [CE0108] "Variable 'item' is defined but not in scope at this location."` at the referencing activity. Applies to the **iterator** and to anything the body introduces (a `retrieve`, a `$X = create …`, a call output) | Nothing tracked loop-variable *visibility*. MDL052 already covered the sibling rule — names are unique across the whole microflow (CE0111) — and the wording of that rule ("scoped to the WHOLE microflow") reads as if the variable is readable flow-wide. Uniqueness and visibility are different: the name is reserved everywhere, readable only inside the loop body | `mdl/executor/validate_microflow_loop_scope.go` (new `MDL053`, wired from `microflowValidator.validate`), skill `.claude/skills/mendix/write-microflows.md` | Map each loop-scoped name to the loop whose **own** body introduces it (nested loops keep their own names), then walk the flow with the set of enclosing loops and flag any reference from outside the owner. **A name claimed by two loops is dropped, not reported** — that is the MDL052/CE0111 case, and without the guard the MDL052 negative example started failing for the wrong reason: the first loop's own use of `$R` was blamed on the second loop's claim. **Generalisable**: a rule keyed by variable *name* needs an ambiguity escape hatch whenever another rule exists precisely because names can collide. Both flavours verified against mxbuild 11.12.1 (2 × CE0108 in one probe). Repro `mdl-examples/bug-tests/loop-variable-out-of-scope.fail.mdl`; tests `mdl/executor/validate_microflow_loop_scope_test.go`. Found while working the sudoku findings, but **not** one of them — the numbered finding it was filed under is an app bug in that project, not an mxcli defect | | `mxcli oql` silently omits a whole column: a `select A, B …` renders only `A`, and the JSON output has no `B` key at all — no error, no empty column. Reproduces whenever the value of `B` is **null in the first row** | The runtime omits a null-valued column from a row's JSON object entirely, and `parseOQLFeedback` took the column list from `extractColumnOrder(rows[0])` — one row, chosen for its key *order*. Every later row was then projected onto that short list, so the column vanished from the result rather than showing NULLs | `cmd/mxcli/docker/oql.go` (`parseOQLFeedback`, `mergeColumnOrder`, `hasOnlyKnownKeys`) | Union the keys of **all** rows, inserting each new key directly after the last key already known rather than appending — merging `[A, C]` with `[A, B, C]` must give `[A, B, C]`, not `[A, C, B]`, or a column that is null early in the result set jumps to the end of the table. The per-row re-scan is skipped when a row carries no unseen key, so the uniform case still costs one length check. **Generalisable**: any "take the shape from the first element" over a sparse encoding is a silent-wrong-answer bug, not a formatting bug — the output looks complete. Tests `TestParseOQLFeedback_ColumnUnionAcrossRows`, `TestMergeColumnOrder`; proven by stubbing the union back to first-row-only and watching the reported symptom return. sudoku #39, first half | | `mxcli test` cannot run at all in a container without a Docker daemon — parsing, runner generation, model injection and the **entire mxbuild build** succeed natively, then `docker up` fails with "failed to connect to the docker API at unix:///var/run/docker.sock". So microflow tests are unavailable in exactly the environment mxcli targets (Claude Code web containers ship `/usr/bin/docker` with no daemon) | Only one step of the run was containerised — start the runtime and read its log — but it was wired directly to `docker compose`, with no seam for another way to run the app. `run --local` had all three pieces already (boot a standalone runtime, tee its log, restore) | `cmd/mxcli/docker/localapp.go` (new `StartLocalApp`), `cmd/mxcli/testrunner/runner_local.go` (new), `cmd/mxcli/testrunner/runner.go` (docker path extracted to `runDockerAndCapture`), `cmd/mxcli/cmd_test_run.go` + `main.go` (`--local`) | Give the run a seam — `runLocalAndCapture` / `runDockerAndCapture` — so both modes share parse/inject/parse-results/cleanup and differ only in how the app is started. **Two traps, both found by running it rather than reasoning about it.** (a) The runner reports via an **after-startup** microflow, so its LOG output happens DURING the start action, before the runtime's log subscriber attaches; registering the subscriber early is not possible (the runtime answers `LoggingException` pre-start). What actually carries the output is the JVM console tee, live from spawn — verified by A/B running with the early attach removed. (b) A **failing** test makes the runner return false, which makes the after-startup action fail, which makes `start` return an error — the first version reported that as a broken run and dumped a stack trace instead of the test report. A failed boot whose log shows a verdict is a normal outcome. Local runs use their own ports (8081/8091) and a `_test` database so a `run --local` dev loop can keep serving. Verified end-to-end in a daemon-less container: 2 passed; then 1 passed / 1 failed with exit 1, project restored. sudoku #41 | +| A solution's apps are served on one host with different ports, so they share a cookie jar (cookies key on host name and **ignore the port**) — logging into one silently replaces the other's `XASSESSIONID`. Giving each app a host name in **App Settings -> Configurations -> Application root URL** appears to do nothing under `run --local` | `runtimeConfigParams` builds the entire boot `update_configuration` payload from `LocalRuntimeOptions`, and `ApplicationRootUrl` was only ever populated from a `--hub` registration. The model's own value had no path into the payload — and since the admin action REPLACES rather than merges, nothing else could supply it either | `cmd/mxcli/docker/runlocal.go` (`configuredApplicationRootURL`, `applicationRootURLFrom`, `customHostRootURL`) | Read the setting off the project at boot and use it when no hub URL was assigned (hub wins — that URL is the one actually serving the app). **The trap is that a blank Mendix app already ships `ApplicationRootUrl = http://localhost:8080/`**, so "is set" does not mean "was chosen": honouring every value would change behaviour for every existing project and, under `--app-port`, advertise a port the app is not serving on. Only a **non-loopback host** is passed through, and a port that disagrees with `--app-port` warns. Serving under the host name needs no flag at all — the runtime accepts any `Host` and the client uses relative URLs (verified: 200 via `/etc/hosts`, nip.io and localtest.me); the setting matters only for the **absolute** URLs Mendix generates. **Generalisable**: before defaulting from a model setting, check what a blank project already has in it — a non-empty default makes "fall back to the model" a behaviour change, not a no-op. Verified end-to-end on 11.12.1: with `backend.local` configured, boot prints `Application root URL from configuration "Default"` and the app answers 200 on both the host name and the listen address. Tests `TestApplicationRootURLFrom`, `TestCustomHostRootURL` | **Key insight:** `microflows$ListRange` stores offset/limit inside a nested `CustomRange` map — must cast `raw["CustomRange"].(map[string]any)` before diff --git a/cmd/mxcli/docker/runlocal.go b/cmd/mxcli/docker/runlocal.go index 8cc54c8a0..d8de9cf6c 100644 --- a/cmd/mxcli/docker/runlocal.go +++ b/cmd/mxcli/docker/runlocal.go @@ -9,6 +9,7 @@ import ( "io/fs" "net" "net/http" + "net/url" "os" "os/signal" "path/filepath" @@ -17,6 +18,7 @@ import ( "syscall" "time" + "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/mpr" ) @@ -235,6 +237,80 @@ func parseRuntimeSetting(s string) (string, any, error) { // deriveDBName turns a project file name into a safe Postgres database name: // lowercased, non-alphanumerics collapsed to underscores, leading digit prefixed. +// configuredApplicationRootURL returns the ApplicationRootUrl set on the +// project's server configuration, plus the name of the configuration it came +// from. Empty when no configuration sets one, which is the default. +// +// The model has no "active configuration" marker — Studio Pro remembers the +// selection per developer — so the one named "Default" wins, falling back to +// the first configuration that actually sets a URL. A settings read failure is +// not fatal: the run simply proceeds without a root URL, exactly as before. +func configuredApplicationRootURL(reader *mpr.Reader) (rootURL, configName string) { + settings, err := reader.GetProjectSettings() + if err != nil { + return "", "" + } + return applicationRootURLFrom(settings) +} + +// applicationRootURLFrom implements the selection rule over already-read +// settings, so it is testable without a project on disk. +func applicationRootURLFrom(settings *model.ProjectSettings) (rootURL, configName string) { + if settings == nil || settings.Configuration == nil { + return "", "" + } + first, firstName := "", "" + for _, cfg := range settings.Configuration.Configurations { + if cfg == nil || cfg.ApplicationRootUrl == "" { + continue + } + if strings.EqualFold(cfg.Name, "Default") { + return cfg.ApplicationRootUrl, cfg.Name + } + if first == "" { + first, firstName = cfg.ApplicationRootUrl, cfg.Name + } + } + return first, firstName +} + +// customHostRootURL reports whether an ApplicationRootUrl names a host worth +// telling the runtime about. +// +// A blank Mendix app already ships `http://localhost:8080/`, so "set" does not +// mean "chosen". Passing that back would be a behaviour change for every +// existing project — and an actively wrong one under --app-port, where the +// stock value names a port the app is not serving on. A loopback host also adds +// nothing: with no ApplicationRootUrl the runtime derives one from the listen +// address, which is the same thing. So only a real host name — the case this +// exists for, giving each app in a solution its own name — is honoured. +func customHostRootURL(rootURL string) bool { + if rootURL == "" { + return false + } + u, err := url.Parse(rootURL) + if err != nil || u.Host == "" { + return false + } + host := strings.ToLower(u.Hostname()) + if host == "localhost" || host == "::1" { + return false + } + if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { + return false + } + return true +} + +// urlPort returns the explicit port of a URL, or "" when it has none. +func urlPort(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return "" + } + return u.Port() +} + // DeriveDBName is the local-run database name for a project: the .mpr file name // lowercased and sanitised to a legal identifier. Exported so callers that boot // their own local app (e.g. the test runner, which appends a suffix to keep test @@ -457,6 +533,11 @@ func RunLocal(opts LocalRunOptions) error { return fmt.Errorf("opening project: %w", err) } pv := reader.ProjectVersion() + // Read the configured application root URL while the project is open — the + // app's own configuration is where a custom host name belongs (App Settings -> + // Configurations in Studio Pro, `alter settings` in MDL), versioned with the + // app rather than repeated on every command line. + modelRootURL, modelRootConfig := configuredApplicationRootURL(reader) reader.Close() version := pv.ProductVersion fmt.Fprintf(w, " Mendix version: %s\n", version) @@ -566,6 +647,22 @@ func RunLocal(opts LocalRunOptions) error { } } + // No hub URL: fall back to the one configured in the project. This is what + // makes "give each app its own host name" work for a local run — Mendix needs + // to know the URL it is reached at to generate absolute URLs (OIDC/SAML + // redirect URIs, deep links) that point at the host name rather than the + // listen address. A hub assignment wins, since that URL is the one actually + // serving the app. + if appRootURL == "" && customHostRootURL(modelRootURL) { + appRootURL = modelRootURL + fmt.Fprintf(w, "Application root URL from configuration %q: %s\n", modelRootConfig, appRootURL) + if port := urlPort(appRootURL); port != "" && port != fmt.Sprint(opts.AppPort) { + fmt.Fprintf(stderr, "Warning: configuration %q says port %s but the app is serving on %d — "+ + "absolute URLs will point at %s. Update the configuration or pass --app-port %s.\n", + modelRootConfig, port, opts.AppPort, appRootURL, port) + } + } + // 6. Boot the runtime against the fresh deployment. Tee the runtime's own // stdout/stderr to a log file so server-side errors are debuggable ("-" // disables). (findings #25) diff --git a/cmd/mxcli/docker/runlocal_test.go b/cmd/mxcli/docker/runlocal_test.go index 06eb1cd3b..15c2e6d4f 100644 --- a/cmd/mxcli/docker/runlocal_test.go +++ b/cmd/mxcli/docker/runlocal_test.go @@ -11,6 +11,8 @@ import ( "strings" "testing" "time" + + "github.com/mendixlabs/mxcli/model" ) func TestDeriveDBName(t *testing.T) { @@ -489,3 +491,91 @@ func TestCheckTargetPortsFree(t *testing.T) { t.Errorf("error should explain the port is in use; got: %v", err) } } + +// The app's host name belongs in the project's configuration (App Settings -> +// Configurations), not on the command line. These pin the selection rule. +func TestApplicationRootURLFrom(t *testing.T) { + cfg := func(name, url string) *model.ServerConfiguration { + return &model.ServerConfiguration{Name: name, ApplicationRootUrl: url} + } + settings := func(cfgs ...*model.ServerConfiguration) *model.ProjectSettings { + return &model.ProjectSettings{Configuration: &model.ConfigurationSettings{Configurations: cfgs}} + } + + tests := []struct { + name string + in *model.ProjectSettings + wantURL string + wantConfig string + }{ + {"no settings at all", nil, "", ""}, + {"no configuration part", &model.ProjectSettings{}, "", ""}, + {"no configurations", settings(), "", ""}, + {"configuration without a URL", settings(cfg("Default", "")), "", ""}, + { + "single configuration", + settings(cfg("Default", "http://backend.local:8080/")), + "http://backend.local:8080/", "Default", + }, + { + // No "active configuration" marker exists in the model, so Default wins + // wherever it sits in the list. + "Default wins over others", + settings(cfg("Acceptance", "https://acc.example.com/"), cfg("Default", "http://backend.local:8080/")), + "http://backend.local:8080/", "Default", + }, + {"Default match is case-insensitive", settings(cfg("default", "http://x/")), "http://x/", "default"}, + { + "first one that sets a URL when there is no Default", + settings(cfg("Local", ""), cfg("Test", "https://test.example.com/"), cfg("Acc", "https://acc.example.com/")), + "https://test.example.com/", "Test", + }, + {"nil entries are skipped", settings(nil, cfg("Default", "http://y/")), "http://y/", "Default"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + url, name := applicationRootURLFrom(tt.in) + if url != tt.wantURL || name != tt.wantConfig { + t.Errorf("got (%q, %q), want (%q, %q)", url, name, tt.wantURL, tt.wantConfig) + } + }) + } +} + +// A blank Mendix app already sets ApplicationRootUrl to http://localhost:8080/, +// so honouring every configured value would change behaviour for every existing +// project — and name the wrong port under --app-port. Only a real host name is +// worth passing to the runtime. +func TestCustomHostRootURL(t *testing.T) { + tests := map[string]bool{ + "": false, + "http://localhost:8080/": false, // the stock value in a blank app + "http://LOCALHOST:8080/": false, + "http://127.0.0.1:8080/": false, + "http://127.0.0.2:8080/": false, + "http://[::1]:8080/": false, + "not a url": false, + "http://backend.local:8080/": true, + "https://app.example.com/": true, + "http://app.127.0.0.1.nip.io:8080/": true, // a name, even if it resolves to loopback + } + for in, want := range tests { + if got := customHostRootURL(in); got != want { + t.Errorf("customHostRootURL(%q) = %v, want %v", in, got, want) + } + } +} + +func TestURLPort(t *testing.T) { + tests := map[string]string{ + "http://backend.local:8080/": "8080", + "https://app.example.com/": "", + "": "", + } + for in, want := range tests { + if got := urlPort(in); got != want { + t.Errorf("urlPort(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/docs-site/src/tools/bootstrap-prompt.md b/docs-site/src/tools/bootstrap-prompt.md index c2e19f73b..920201fc5 100644 --- a/docs-site/src/tools/bootstrap-prompt.md +++ b/docs-site/src/tools/bootstrap-prompt.md @@ -132,17 +132,26 @@ with these deltas: 127.0.0.1 backend.local frontend.local ``` - — and browse `http://backend.local:8080/` and `http://frontend.local:8180/`. No - mxcli flag is involved: the runtime binds `127.0.0.1` and serves any `Host` you - send it, and the client uses relative URLs, so it works under any name that - resolves to loopback. (`*.nip.io` works too if you would rather not touch - `/etc/hosts`; prefer `/etc/hosts` in a locked-down container, where public wildcard - DNS may not resolve — `localtest.me` resolves to `::1` in some of them.) - - One limit worth knowing before you design around it: the app's - `ApplicationRootUrl` is only set by `--hub`, so anything needing an **absolute** - URL — an OIDC/SAML redirect URI, a deep link in an email — still points at the - listen address rather than your chosen hostname. + — and browse `http://backend.local:8080/` and `http://frontend.local:8180/`. The + runtime binds `127.0.0.1` and serves any `Host` you send it, and the client uses + relative URLs, so it works under any name that resolves to loopback. (`*.nip.io` + works too if you would rather not touch `/etc/hosts`; prefer `/etc/hosts` in a + locked-down container, where public wildcard DNS may not resolve — `localtest.me` + resolves to `::1` in some of them.) + + Then record the name in each app's own configuration, so the runtime knows the URL + it is reached at and generates absolute URLs — OIDC/SAML redirect URIs, deep links + — against the host name rather than the listen address: + + ```sql + alter settings configuration 'Default' + ApplicationRootUrl = 'http://backend.local:8080/'; + ``` + + `run --local` picks that up at boot and prints which configuration it came from. + A blank app ships `http://localhost:8080/` there, and that stock loopback value is + deliberately ignored — otherwise every project would start advertising a URL, and + the wrong port under `--app-port`. Only a real host name is passed through. - **Databases** need no action: the name is derived from the `.mpr` file name, so differently-named apps get different databases. - **The session hook.** `mxcli init` writes `.claude/settings.json` inside each app From 3db05ddbc8ecb74af144ce0907002a730977fd78 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 11:18:43 +0000 Subject: [PATCH 5/8] fix(pages): reference an inherited attribute against its declaring entity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A widget bound to an attribute the context entity inherits rather than declares passed `mxcli check --references` and `mxcli lint`, and then the real MxBuild rejected the project: [error] [CE1613] "The selected attribute 'TaskBoard.Person.FullName' no longer exists." Mendix stores the reference against the entity that DECLARES the attribute; mxcli qualified it with the entity in context, leaving a dangling reference. The message reads as a deletion — it never existed there. Two independent resolvers had the bug and both are fixed: the direct binding (resolveAttributePath) and the final attribute of an association path (resolveAssociationAttributePath). The reporter's own table showed both failing, and the association case still failed after the first patch — a probe of the direct case alone would have shipped half a fix. Unknown names keep their previous context qualification rather than being re-pointed, and a cyclic generalization chain terminates. Attribute resolution now consults the domain models, so the lookup bails out when there is no backend and nothing cached — several unit tests build a pageBuilder with neither. A/B on Mendix 11.12.1: pre-fix binary gives CE1613 for the inherited column and 0 errors for the own column; fixed binary gives 0 errors for both shapes, direct and over an association. Repro: mdl-examples/bug-tests/todo-12-inherited-attribute-on-page.mdl Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- .claude/skills/fix-issue.md | 1 + .../todo-12-inherited-attribute-on-page.mdl | 68 +++++++ .../cmd_pages_builder_inheritance_test.go | 173 ++++++++++++++++++ mdl/executor/cmd_pages_builder_input.go | 78 +++++++- mdl/executor/cmd_pages_builder_v3.go | 9 +- 5 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 mdl-examples/bug-tests/todo-12-inherited-attribute-on-page.mdl create mode 100644 mdl/executor/cmd_pages_builder_inheritance_test.go diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index 2dde76bff..1bf818320 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -360,6 +360,7 @@ cases for these three BSON types — they fell to `default: return nil`. | `mxcli oql` silently omits a whole column: a `select A, B …` renders only `A`, and the JSON output has no `B` key at all — no error, no empty column. Reproduces whenever the value of `B` is **null in the first row** | The runtime omits a null-valued column from a row's JSON object entirely, and `parseOQLFeedback` took the column list from `extractColumnOrder(rows[0])` — one row, chosen for its key *order*. Every later row was then projected onto that short list, so the column vanished from the result rather than showing NULLs | `cmd/mxcli/docker/oql.go` (`parseOQLFeedback`, `mergeColumnOrder`, `hasOnlyKnownKeys`) | Union the keys of **all** rows, inserting each new key directly after the last key already known rather than appending — merging `[A, C]` with `[A, B, C]` must give `[A, B, C]`, not `[A, C, B]`, or a column that is null early in the result set jumps to the end of the table. The per-row re-scan is skipped when a row carries no unseen key, so the uniform case still costs one length check. **Generalisable**: any "take the shape from the first element" over a sparse encoding is a silent-wrong-answer bug, not a formatting bug — the output looks complete. Tests `TestParseOQLFeedback_ColumnUnionAcrossRows`, `TestMergeColumnOrder`; proven by stubbing the union back to first-row-only and watching the reported symptom return. sudoku #39, first half | | `mxcli test` cannot run at all in a container without a Docker daemon — parsing, runner generation, model injection and the **entire mxbuild build** succeed natively, then `docker up` fails with "failed to connect to the docker API at unix:///var/run/docker.sock". So microflow tests are unavailable in exactly the environment mxcli targets (Claude Code web containers ship `/usr/bin/docker` with no daemon) | Only one step of the run was containerised — start the runtime and read its log — but it was wired directly to `docker compose`, with no seam for another way to run the app. `run --local` had all three pieces already (boot a standalone runtime, tee its log, restore) | `cmd/mxcli/docker/localapp.go` (new `StartLocalApp`), `cmd/mxcli/testrunner/runner_local.go` (new), `cmd/mxcli/testrunner/runner.go` (docker path extracted to `runDockerAndCapture`), `cmd/mxcli/cmd_test_run.go` + `main.go` (`--local`) | Give the run a seam — `runLocalAndCapture` / `runDockerAndCapture` — so both modes share parse/inject/parse-results/cleanup and differ only in how the app is started. **Two traps, both found by running it rather than reasoning about it.** (a) The runner reports via an **after-startup** microflow, so its LOG output happens DURING the start action, before the runtime's log subscriber attaches; registering the subscriber early is not possible (the runtime answers `LoggingException` pre-start). What actually carries the output is the JVM console tee, live from spawn — verified by A/B running with the early attach removed. (b) A **failing** test makes the runner return false, which makes the after-startup action fail, which makes `start` return an error — the first version reported that as a broken run and dumped a stack trace instead of the test report. A failed boot whose log shows a verdict is a normal outcome. Local runs use their own ports (8081/8091) and a `_test` database so a `run --local` dev loop can keep serving. Verified end-to-end in a daemon-less container: 2 passed; then 1 passed / 1 failed with exit 1, project restored. sudoku #41 | | A solution's apps are served on one host with different ports, so they share a cookie jar (cookies key on host name and **ignore the port**) — logging into one silently replaces the other's `XASSESSIONID`. Giving each app a host name in **App Settings -> Configurations -> Application root URL** appears to do nothing under `run --local` | `runtimeConfigParams` builds the entire boot `update_configuration` payload from `LocalRuntimeOptions`, and `ApplicationRootUrl` was only ever populated from a `--hub` registration. The model's own value had no path into the payload — and since the admin action REPLACES rather than merges, nothing else could supply it either | `cmd/mxcli/docker/runlocal.go` (`configuredApplicationRootURL`, `applicationRootURLFrom`, `customHostRootURL`) | Read the setting off the project at boot and use it when no hub URL was assigned (hub wins — that URL is the one actually serving the app). **The trap is that a blank Mendix app already ships `ApplicationRootUrl = http://localhost:8080/`**, so "is set" does not mean "was chosen": honouring every value would change behaviour for every existing project and, under `--app-port`, advertise a port the app is not serving on. Only a **non-loopback host** is passed through, and a port that disagrees with `--app-port` warns. Serving under the host name needs no flag at all — the runtime accepts any `Host` and the client uses relative URLs (verified: 200 via `/etc/hosts`, nip.io and localtest.me); the setting matters only for the **absolute** URLs Mendix generates. **Generalisable**: before defaulting from a model setting, check what a blank project already has in it — a non-empty default makes "fall back to the model" a behaviour change, not a no-op. Verified end-to-end on 11.12.1: with `backend.local` configured, boot prints `Application root URL from configuration "Default"` and the app answers 200 on both the host name and the listen address. Tests `TestApplicationRootURLFrom`, `TestCustomHostRootURL` | +| A page bound to an attribute the entity **inherits** (`Person extends Administration.Account`, page binds `FullName`) passes `mxcli check --references` AND `mxcli lint`, then the real MxBuild fails `[error] [CE1613] "The selected attribute 'TaskBoard.Person.FullName' no longer exists."` The message reads as a deletion; it never existed there | Mendix stores a page's attribute reference against the entity that **declares** the attribute. `resolveAttributePath` qualified a bare name with `pb.entityContext` unconditionally, so a specialization that merely inherits the attribute got a dangling reference. Two independent resolvers had the same bug: the direct binding, and the final attribute of an association path (`resolveAssociationAttributePath`) | `mdl/executor/cmd_pages_builder_input.go` (`declaringEntityFor`, `entityAttributeOwners`), `mdl/executor/cmd_pages_builder_v3.go` (final attribute of the association path) | Walk the generalization chain and qualify with the first entity that declares the name. **Fix both resolvers** — a probe that only tested the direct case would have shipped half a fix; the reporter's own table already showed the associated case failing, and it did still fail after the first patch. Unknown names keep today's context qualification rather than being re-pointed, and a cyclic chain terminates. **Watch the new dependency**: attribute resolution now consults the domain models, and `getDomainModels` panics on a nil backend — several unit tests build a `pageBuilder` with neither backend nor cache, so the lookup bails out early instead. A/B on Mendix 11.12.1: pre-fix binary → CE1613 for the inherited column and 0 errors for the own column; fixed binary → 0 errors for both shapes. Repro `mdl-examples/bug-tests/todo-12-inherited-attribute-on-page.mdl`; tests `mdl/executor/cmd_pages_builder_inheritance_test.go`. mxcli-todo #12 | **Key insight:** `microflows$ListRange` stores offset/limit inside a nested `CustomRange` map — must cast `raw["CustomRange"].(map[string]any)` before diff --git a/mdl-examples/bug-tests/todo-12-inherited-attribute-on-page.mdl b/mdl-examples/bug-tests/todo-12-inherited-attribute-on-page.mdl new file mode 100644 index 000000000..f79622686 --- /dev/null +++ b/mdl-examples/bug-tests/todo-12-inherited-attribute-on-page.mdl @@ -0,0 +1,68 @@ +-- ============================================================================ +-- mxcli-todo finding #12: a page bound to an INHERITED attribute failed the build +-- ============================================================================ +-- +-- Symptom (before fix): a widget bound to an attribute the context entity +-- inherits rather than declares passed `mxcli check --references` AND `mxcli +-- lint`, and then the real MxBuild rejected the project: +-- +-- [error] [CE1613] "The selected attribute 'TaskBoard.Person.FullName' no +-- longer exists." at Columns (n/n) of data grid 2 'gridTasks' +-- +-- The message reads as if the attribute had been deleted. It never existed +-- there: Mendix stores the reference against the entity that DECLARES the +-- attribute, and mxcli qualified it with the entity in context. +-- +-- Both shapes were affected, and they go through different resolvers: +-- 1. a direct binding on a grid over the specialization +-- 2. the final attribute of an association path ending at the specialization +-- +-- Entity access rules were never affected — `grant … on TaskBoard.Person` +-- resolves inherited members correctly — so this was the page layer alone. +-- +-- After fix: the reference is qualified with the declaring entity found by +-- walking the generalization chain. An own attribute is unaffected, and an +-- unknown name keeps its previous (context) qualification rather than being +-- silently re-pointed. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/todo-12-inherited-attribute-on-page.mdl -p app.mpr +-- then a real build must report 0 errors: +-- scripts/mx-check.sh -p app.mpr +-- ============================================================================ + +create module TODO12; + +-- Person inherits FullName/Email from Administration.Account and adds one of +-- its own, so a single page can bind both kinds. +create persistent entity TODO12.Person extends Administration.Account ( + IsAvailable : boolean +); + +create persistent entity TODO12.Task ( + Title : string(200) +); + +create association TODO12.Task_Assignee + from TODO12.Task to TODO12.Person; + +-- (1) Direct binding: the grid's data source IS the specialization. +create or replace page TODO12.People +( Title: 'People', Layout: Atlas_Core.Atlas_Default ) +{ + datagrid gridPeople (datasource: database from TODO12.Person) { + column colOwn (caption: 'Available', attribute: IsAvailable) + column colInherited (caption: 'Full name', attribute: FullName) + } +} + +-- (2) Association path: the final hop lands on the specialization. +create or replace page TODO12.Tasks +( Title: 'Tasks', Layout: Atlas_Core.Atlas_Default ) +{ + datagrid gridTasks (datasource: database from TODO12.Task) { + column colTitle (caption: 'Title', attribute: Title) + column colOwnHop (caption: 'Available', attribute: Task_Assignee/IsAvailable) + column colInhHop (caption: 'Full name', attribute: Task_Assignee/FullName) + } +} diff --git a/mdl/executor/cmd_pages_builder_inheritance_test.go b/mdl/executor/cmd_pages_builder_inheritance_test.go new file mode 100644 index 000000000..4f4c6c5bf --- /dev/null +++ b/mdl/executor/cmd_pages_builder_inheritance_test.go @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// mxcli-todo findings #12: Mendix stores a page's attribute reference against +// the entity that DECLARES the attribute. mxcli qualified it with the entity in +// context, so a binding to an inherited attribute produced a dangling reference +// and mxbuild failed with +// +// [CE1613] "The selected attribute 'TaskBoard.Person.FullName' no longer exists." +// +// Both `mxcli check` (including --references) and `mxcli lint` passed. +// +// The fixture mirrors the reporter's model: Person extends Administration.Account +// (which declares FullName/Email) and adds IsAvailable of its own; Task points at +// Person through an association. +func inheritancePB(entityContext string) *pageBuilder { + const ( + appID = model.ID("mod-app") + adminID = model.ID("mod-admin") + personID = model.ID("e-person") + taskID = model.ID("e-task") + accountID = model.ID("e-account") + ) + return &pageBuilder{ + entityContext: entityContext, + execCache: &executorCache{ + hierarchy: &ContainerHierarchy{moduleNames: map[model.ID]string{ + appID: "TaskBoard", + adminID: "Administration", + }}, + domainModels: []*domainmodel.DomainModel{ + { + ContainerID: appID, + Entities: []*domainmodel.Entity{ + { + BaseElement: model.BaseElement{ID: personID}, + Name: "Person", + GeneralizationRef: "Administration.Account", + Attributes: []*domainmodel.Attribute{ + {Name: "IsAvailable"}, + }, + }, + { + BaseElement: model.BaseElement{ID: taskID}, + Name: "Task", + Attributes: []*domainmodel.Attribute{{Name: "Title"}}, + }, + }, + CrossAssociations: []*domainmodel.CrossModuleAssociation{ + {Name: "Task_Assignee", ParentID: taskID, ChildRef: "TaskBoard.Person", Type: domainmodel.AssociationTypeReference}, + }, + Associations: []*domainmodel.Association{ + {Name: "Task_Assignee", ParentID: taskID, ChildID: personID, Type: domainmodel.AssociationTypeReference}, + }, + }, + { + ContainerID: adminID, + Entities: []*domainmodel.Entity{ + { + BaseElement: model.BaseElement{ID: accountID}, + Name: "Account", + Attributes: []*domainmodel.Attribute{ + {Name: "FullName"}, + {Name: "Email"}, + }, + }, + }, + }, + }, + }, + } +} + +func TestResolveAttributePath_InheritedAttribute(t *testing.T) { + pb := inheritancePB("TaskBoard.Person") + + // Inherited: must be qualified with the declaring entity, not the context. + if got := pb.resolveAttributePath("FullName"); got != "Administration.Account.FullName" { + t.Errorf("inherited attribute: got %q, want Administration.Account.FullName", got) + } + // Own: unchanged. + if got := pb.resolveAttributePath("IsAvailable"); got != "TaskBoard.Person.IsAvailable" { + t.Errorf("own attribute: got %q, want TaskBoard.Person.IsAvailable", got) + } + // Unknown name: no invented qualification, today's behaviour is kept. + if got := pb.resolveAttributePath("Nonexistent"); got != "TaskBoard.Person.Nonexistent" { + t.Errorf("unknown attribute: got %q, want the context qualification", got) + } + // Already qualified: untouched. + if got := pb.resolveAttributePath("Other.Entity.Attr"); got != "Other.Entity.Attr" { + t.Errorf("qualified attribute was rewritten: %q", got) + } +} + +// The same rule applies to the final attribute of an association path — the +// reporter's table had this failing too, and it goes through a different +// resolver. +func TestResolveAssociationAttributePath_InheritedFinalAttribute(t *testing.T) { + pb := inheritancePB("TaskBoard.Task") + + finalQN, steps, ok := pb.resolveAssociationAttributePath("Task_Assignee/FullName") + if !ok { + t.Fatal("expected the association path to resolve") + } + if finalQN != "Administration.Account.FullName" { + t.Errorf("inherited final attribute: got %q, want Administration.Account.FullName", finalQN) + } + if len(steps) != 1 || steps[0].DestinationEntity != "TaskBoard.Person" { + t.Errorf("steps = %+v, want one hop to TaskBoard.Person", steps) + } + + // An own attribute on the same destination keeps the destination entity. + finalQN, _, ok = pb.resolveAssociationAttributePath("Task_Assignee/IsAvailable") + if !ok || finalQN != "TaskBoard.Person.IsAvailable" { + t.Errorf("own final attribute: got %q (ok=%v), want TaskBoard.Person.IsAvailable", finalQN, ok) + } +} + +func TestDeclaringEntityFor(t *testing.T) { + pb := inheritancePB("TaskBoard.Person") + + tests := []struct { + entity, attr string + want string + wantOK bool + }{ + {"TaskBoard.Person", "IsAvailable", "TaskBoard.Person", true}, + {"TaskBoard.Person", "FullName", "Administration.Account", true}, + {"TaskBoard.Person", "Email", "Administration.Account", true}, + // Case-insensitive, since MDL identifiers are matched that way elsewhere. + {"TaskBoard.Person", "fullname", "Administration.Account", true}, + {"TaskBoard.Person", "Missing", "", false}, + {"Unknown.Entity", "Attr", "", false}, + {"", "Attr", "", false}, + {"TaskBoard.Person", "", "", false}, + } + for _, tt := range tests { + got, ok := pb.declaringEntityFor(tt.entity, tt.attr) + if got != tt.want || ok != tt.wantOK { + t.Errorf("declaringEntityFor(%q, %q) = (%q, %v), want (%q, %v)", + tt.entity, tt.attr, got, ok, tt.want, tt.wantOK) + } + } +} + +// A generalization cycle must not hang the walk. Mendix cannot express one, but +// a corrupt or partially-written model could. +func TestDeclaringEntityFor_CycleTerminates(t *testing.T) { + const modID = model.ID("mod") + pb := &pageBuilder{ + execCache: &executorCache{ + hierarchy: &ContainerHierarchy{moduleNames: map[model.ID]string{modID: "M"}}, + domainModels: []*domainmodel.DomainModel{{ + ContainerID: modID, + Entities: []*domainmodel.Entity{ + {Name: "A", GeneralizationRef: "M.B"}, + {Name: "B", GeneralizationRef: "M.A"}, + }, + }}, + }, + } + if _, ok := pb.declaringEntityFor("M.A", "Whatever"); ok { + t.Error("expected no declaring entity for a cyclic chain") + } +} diff --git a/mdl/executor/cmd_pages_builder_input.go b/mdl/executor/cmd_pages_builder_input.go index 65ecfaef1..73da8ba11 100644 --- a/mdl/executor/cmd_pages_builder_input.go +++ b/mdl/executor/cmd_pages_builder_input.go @@ -40,13 +40,89 @@ func (pb *pageBuilder) resolveAttributePath(attr string) string { if strings.Contains(attr, ".") { return attr } - // If we have an entity context, prefix the attribute with it + // If we have an entity context, prefix the attribute with it — but with the + // entity that actually DECLARES it, which for an inherited attribute is an + // ancestor rather than the context entity itself. if pb.entityContext != "" { + if declaring, ok := pb.declaringEntityFor(pb.entityContext, attr); ok { + return declaring + "." + attr + } return pb.entityContext + "." + attr } return attr } +// declaringEntityFor returns the entity in entityQN's generalization chain that +// declares attrName — entityQN itself when the attribute is its own. +// +// Mendix stores a page's attribute reference against the declaring entity. A +// reference qualified with a specialization that merely inherits the attribute +// is dangling, and the build fails with +// +// [CE1613] "The selected attribute 'Module.Sub.Attr' no longer exists." +// +// which reads as if the attribute had been deleted; it never existed there. +// Entity access rules resolve inherited members correctly, so this was the page +// layer alone. (mxcli-todo findings #12) +// +// ok is false when nothing in the chain declares the name — an unknown +// attribute, or a domain model we cannot read. The caller then keeps today's +// behaviour rather than inventing a qualification. +func (pb *pageBuilder) declaringEntityFor(entityQN, attrName string) (string, bool) { + if entityQN == "" || attrName == "" { + return "", false + } + // Resolution is best-effort: without a model to consult (no backend and + // nothing cached — e.g. a unit test building widgets in isolation) keep the + // caller's plain context qualification rather than panicking on the lookup. + if pb.backend == nil && (pb.execCache == nil || pb.execCache.domainModels == nil) { + return "", false + } + owners, parents, err := pb.entityAttributeOwners() + if err != nil { + return "", false + } + lower := strings.ToLower(attrName) + seen := map[string]bool{} + for cur := entityQN; cur != "" && !seen[cur]; cur = parents[cur] { + seen[cur] = true + if attrs, ok := owners[cur]; ok && attrs[lower] { + return cur, true + } + } + return "", false +} + +// entityAttributeOwners indexes, for every entity in the project, the set of +// attribute names it declares itself, plus each entity's direct parent. +func (pb *pageBuilder) entityAttributeOwners() (owners map[string]map[string]bool, parents map[string]string, err error) { + dms, err := pb.getDomainModels() + if err != nil { + return nil, nil, err + } + h, err := pb.getHierarchy() + if err != nil { + return nil, nil, err + } + owners = make(map[string]map[string]bool, len(dms)*8) + parents = make(map[string]string) + for _, dm := range dms { + mod := h.GetModuleName(dm.ContainerID) + for _, e := range dm.Entities { + qn := mod + "." + e.Name + attrs := make(map[string]bool, len(e.Attributes)) + for _, a := range e.Attributes { + attrs[strings.ToLower(a.Name)] = true + } + owners[qn] = attrs + if e.GeneralizationRef != "" { + parents[qn] = e.GeneralizationRef + } + } + } + return owners, parents, nil +} + // systemMemberBindingNames maps the name an audit member is DECLARED under to // the name Mendix actually stores it as. // diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index c09f0f4e5..e2409483e 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -1681,7 +1681,14 @@ func (pb *pageBuilder) resolveAssociationAttributePath(attrRef string) (finalQN current = dest } - return current + "." + storedSystemMemberName(attrName), steps, true + // The final attribute is qualified with the entity that DECLARES it, which + // for an inherited attribute is an ancestor of the association's destination + // — same rule (and same CE1613 when broken) as a direct binding. + stored := storedSystemMemberName(attrName) + if declaring, ok := pb.declaringEntityFor(current, stored); ok { + return declaring + "." + stored, steps, true + } + return current + "." + stored, steps, true } // associationDestination returns the entity reached by navigating assocQN from From a87de0adf325bd311727ce3c529d720c69e5c64d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 11:19:00 +0000 Subject: [PATCH 6/8] fix(init): make the SessionStart hook survive an idle reap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated hook was `test -x ./mxcli && ./mxcli run --local --setup --ensure-db -p App.mpr || true`, and .gitignore excludes that binary (~85 MB) on purpose. In an ephemeral container the two combine badly: the container is reclaimed, the repo re-cloned without the binary, the guard fails, and the hook silently no-ops through `|| true`. The next session has no mxcli, no MxBuild cache and no database — exactly the state the hook exists to prevent — with nothing said about it. `mxcli init` now writes a committed `.claude/bootstrap-mxcli.sh` that resolves OS/arch, fetches the binary when it is missing (MXCLI_TAG pins a version, default nightly) and then runs the setup; the hook is reduced to `sh .claude/bootstrap-mxcli.sh || true`. A hook line cannot reasonably do OS/arch detection, but a committed script can — and the binary stays out of git. That changes the hook command, which is what dedupe matched on, so addSessionStartHook now recognises any known marker and rewrites the entry in place. An existing project migrates on the next `mxcli init` instead of ending up with two hooks that both run. Also adds /theme-cache/ to the generated .gitignore: MxBuild regenerates the compiled theme on every build, so tracking it means a fresh clone goes dirty the first time anyone builds. Verified by reproducing the reap: moved ./mxcli out of a project, ran the hook command verbatim, and watched it re-download the binary (88 MB, new mtime) and finish with "Setup complete … database ready". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- .claude/skills/fix-issue.md | 1 + cmd/mxcli/init.go | 3 + cmd/mxcli/init_hook.go | 141 +++++++++++++++++++++--- cmd/mxcli/init_hook_test.go | 52 +++++++-- docs-site/src/tools/bootstrap-prompt.md | 24 ++-- 5 files changed, 188 insertions(+), 33 deletions(-) diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index 1bf818320..95508570e 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -361,6 +361,7 @@ cases for these three BSON types — they fell to `default: return nil`. | `mxcli test` cannot run at all in a container without a Docker daemon — parsing, runner generation, model injection and the **entire mxbuild build** succeed natively, then `docker up` fails with "failed to connect to the docker API at unix:///var/run/docker.sock". So microflow tests are unavailable in exactly the environment mxcli targets (Claude Code web containers ship `/usr/bin/docker` with no daemon) | Only one step of the run was containerised — start the runtime and read its log — but it was wired directly to `docker compose`, with no seam for another way to run the app. `run --local` had all three pieces already (boot a standalone runtime, tee its log, restore) | `cmd/mxcli/docker/localapp.go` (new `StartLocalApp`), `cmd/mxcli/testrunner/runner_local.go` (new), `cmd/mxcli/testrunner/runner.go` (docker path extracted to `runDockerAndCapture`), `cmd/mxcli/cmd_test_run.go` + `main.go` (`--local`) | Give the run a seam — `runLocalAndCapture` / `runDockerAndCapture` — so both modes share parse/inject/parse-results/cleanup and differ only in how the app is started. **Two traps, both found by running it rather than reasoning about it.** (a) The runner reports via an **after-startup** microflow, so its LOG output happens DURING the start action, before the runtime's log subscriber attaches; registering the subscriber early is not possible (the runtime answers `LoggingException` pre-start). What actually carries the output is the JVM console tee, live from spawn — verified by A/B running with the early attach removed. (b) A **failing** test makes the runner return false, which makes the after-startup action fail, which makes `start` return an error — the first version reported that as a broken run and dumped a stack trace instead of the test report. A failed boot whose log shows a verdict is a normal outcome. Local runs use their own ports (8081/8091) and a `_test` database so a `run --local` dev loop can keep serving. Verified end-to-end in a daemon-less container: 2 passed; then 1 passed / 1 failed with exit 1, project restored. sudoku #41 | | A solution's apps are served on one host with different ports, so they share a cookie jar (cookies key on host name and **ignore the port**) — logging into one silently replaces the other's `XASSESSIONID`. Giving each app a host name in **App Settings -> Configurations -> Application root URL** appears to do nothing under `run --local` | `runtimeConfigParams` builds the entire boot `update_configuration` payload from `LocalRuntimeOptions`, and `ApplicationRootUrl` was only ever populated from a `--hub` registration. The model's own value had no path into the payload — and since the admin action REPLACES rather than merges, nothing else could supply it either | `cmd/mxcli/docker/runlocal.go` (`configuredApplicationRootURL`, `applicationRootURLFrom`, `customHostRootURL`) | Read the setting off the project at boot and use it when no hub URL was assigned (hub wins — that URL is the one actually serving the app). **The trap is that a blank Mendix app already ships `ApplicationRootUrl = http://localhost:8080/`**, so "is set" does not mean "was chosen": honouring every value would change behaviour for every existing project and, under `--app-port`, advertise a port the app is not serving on. Only a **non-loopback host** is passed through, and a port that disagrees with `--app-port` warns. Serving under the host name needs no flag at all — the runtime accepts any `Host` and the client uses relative URLs (verified: 200 via `/etc/hosts`, nip.io and localtest.me); the setting matters only for the **absolute** URLs Mendix generates. **Generalisable**: before defaulting from a model setting, check what a blank project already has in it — a non-empty default makes "fall back to the model" a behaviour change, not a no-op. Verified end-to-end on 11.12.1: with `backend.local` configured, boot prints `Application root URL from configuration "Default"` and the app answers 200 on both the host name and the listen address. Tests `TestApplicationRootURLFrom`, `TestCustomHostRootURL` | | A page bound to an attribute the entity **inherits** (`Person extends Administration.Account`, page binds `FullName`) passes `mxcli check --references` AND `mxcli lint`, then the real MxBuild fails `[error] [CE1613] "The selected attribute 'TaskBoard.Person.FullName' no longer exists."` The message reads as a deletion; it never existed there | Mendix stores a page's attribute reference against the entity that **declares** the attribute. `resolveAttributePath` qualified a bare name with `pb.entityContext` unconditionally, so a specialization that merely inherits the attribute got a dangling reference. Two independent resolvers had the same bug: the direct binding, and the final attribute of an association path (`resolveAssociationAttributePath`) | `mdl/executor/cmd_pages_builder_input.go` (`declaringEntityFor`, `entityAttributeOwners`), `mdl/executor/cmd_pages_builder_v3.go` (final attribute of the association path) | Walk the generalization chain and qualify with the first entity that declares the name. **Fix both resolvers** — a probe that only tested the direct case would have shipped half a fix; the reporter's own table already showed the associated case failing, and it did still fail after the first patch. Unknown names keep today's context qualification rather than being re-pointed, and a cyclic chain terminates. **Watch the new dependency**: attribute resolution now consults the domain models, and `getDomainModels` panics on a nil backend — several unit tests build a `pageBuilder` with neither backend nor cache, so the lookup bails out early instead. A/B on Mendix 11.12.1: pre-fix binary → CE1613 for the inherited column and 0 errors for the own column; fixed binary → 0 errors for both shapes. Repro `mdl-examples/bug-tests/todo-12-inherited-attribute-on-page.mdl`; tests `mdl/executor/cmd_pages_builder_inheritance_test.go`. mxcli-todo #12 | +| The SessionStart hook `mxcli init` writes cannot survive an idle reap: it is guarded on `test -x ./mxcli`, and `.gitignore` excludes that binary (~85 MB) on purpose. The container is reclaimed, the repo re-cloned without it, the guard fails and the hook no-ops through `\|\| true` — the next session has no mxcli, no MxBuild cache, no database, and no message saying so | The hook inlined the whole bring-up in one shell line, so "binary missing" could only be expressed as "skip". A hook line cannot reasonably do OS/arch detection and a download; something committed has to | `cmd/mxcli/init_hook.go` (`bootstrapScriptTemplate`, `writeBootstrapScript`, `sessionStartHookCommand`, `sessionStartHookMarkers`) | Emit a committed `.claude/bootstrap-mxcli.sh` that resolves OS/arch, fetches the binary when absent (`MXCLI_TAG` to pin), then runs the setup; the hook becomes `sh .claude/bootstrap-mxcli.sh \|\| true`. **Changing the hook command breaks dedupe**, which matched on the old command string — so `addSessionStartHook` now recognises *any* known marker and **rewrites the entry in place**, migrating an old project instead of leaving it with two hooks that both run. **Generalisable**: a guard whose condition is something you deliberately do not commit is a silent no-op waiting for a fresh clone — make the guard able to satisfy itself. Verified by reproducing the reap: moved `./mxcli` out of the project, ran the hook command verbatim, watched it re-download (88 MB, new mtime) and finish with "Setup complete … database ready". Tests `TestAddSessionStartHook_MigratesLegacyCommand`, `TestEnsureSessionStartHook_WritesFile`. mxcli-todo #2 | **Key insight:** `microflows$ListRange` stores offset/limit inside a nested `CustomRange` map — must cast `raw["CustomRange"].(map[string]any)` before diff --git a/cmd/mxcli/init.go b/cmd/mxcli/init.go index fdb1fb51b..13e4e84d8 100644 --- a/cmd/mxcli/init.go +++ b/cmd/mxcli/init.go @@ -38,6 +38,9 @@ const mendixGitignore = `# Mendix project /packages/ /project-settings.user.json /releases/ +# Compiled theme output. MxBuild regenerates it on every build, so tracking it +# means a fresh clone goes dirty the first time anyone builds. (mxcli-todo #7) +/theme-cache/ *.mpr.lock *.mpr.bak /vendorlib/temp/ diff --git a/cmd/mxcli/init_hook.go b/cmd/mxcli/init_hook.go index aa4304422..007f740a6 100644 --- a/cmd/mxcli/init_hook.go +++ b/cmd/mxcli/init_hook.go @@ -3,6 +3,7 @@ package main import ( + "bytes" "encoding/json" "fmt" "os" @@ -19,14 +20,88 @@ import ( // The hook is setup-only (non-blocking): it must return, so it prepares // prerequisites and exits rather than booting the long-lived warm loop. -// sessionStartHookMarker identifies our hook command so re-running init is -// idempotent and we never clobber a user's own SessionStart hooks. -const sessionStartHookMarker = "run --local --setup" +// bootstrapScriptName is the committed script the SessionStart hook runs, +// relative to the project root. +const bootstrapScriptName = ".claude/bootstrap-mxcli.sh" -// sessionStartHookCommand is the shell command the hook runs. It is guarded so a -// missing ./mxcli (or a setup hiccup) never blocks the session from starting. -func sessionStartHookCommand(mprFile string) string { - return fmt.Sprintf("test -x ./mxcli && ./mxcli run --local --setup --ensure-db -p %s || true", mprFile) +// bootstrapScriptTemplate is written to bootstrapScriptName. %s is the .mpr file +// name. It is POSIX sh (no bashisms) and safe to re-run: every step is a no-op +// once satisfied. +// +// It is COMMITTED on purpose — the opposite of the mxcli binary, which +// .gitignore excludes for its size. After an idle reap the container is +// reclaimed and the repo re-cloned, and this file is then the only thing in the +// tree that can bring the binary back. +const bootstrapScriptTemplate = `#!/bin/sh +# Generated by 'mxcli init'. COMMIT THIS FILE. +# +# Run by the Claude Code SessionStart hook to make a fresh (or reaped and +# re-cloned) session ready to work: fetch mxcli if the binary is missing, then +# cache MxBuild + the runtime and provision the local database. +# +# The mxcli binary is git-ignored (~85 MB), so after an idle reap it is NOT in +# the fresh clone — which is exactly why this script fetches it rather than +# skipping when it is absent. +# +# Pin a specific mxcli with MXCLI_TAG=vX.Y.Z (default: nightly). +set -e + +MPR='%s' +TAG="${MXCLI_TAG:-nightly}" + +if [ ! -x ./mxcli ]; then + os=$(uname -s | tr 'A-Z' 'a-z') + case "$(uname -m)" in + x86_64|amd64) arch=amd64 ;; + arm64|aarch64) arch=arm64 ;; + *) arch=$(uname -m) ;; + esac + url="https://github.com/mendixlabs/mxcli/releases/download/${TAG}/mxcli-${os}-${arch}" + echo "mxcli not found — downloading ${TAG} for ${os}/${arch}..." + if ! curl -fsSL -o ./mxcli "$url"; then + echo "Could not download mxcli from ${url}." >&2 + echo "Fetch it manually, or set MXCLI_TAG to a released version." >&2 + exit 1 + fi + chmod +x ./mxcli +fi + +exec ./mxcli run --local --setup --ensure-db -p "$MPR" +` + +// sessionStartHookMarkers identify OUR hook command — so re-running init updates +// it instead of duplicating it, and a user's own SessionStart hooks are never +// touched. The first is the current form; the rest are older spellings kept so +// existing projects migrate rather than accumulate a second hook. +var sessionStartHookMarkers = []string{ + bootstrapScriptName, // current: the committed bootstrap script + "run --local --setup", // pre-bootstrap-script: the command inlined in the hook +} + +// isMxcliSessionStartHook reports whether a hook command is one mxcli wrote. +func isMxcliSessionStartHook(command string) bool { + for _, m := range sessionStartHookMarkers { + if strings.Contains(command, m) { + return true + } + } + return false +} + +// sessionStartHookCommand is the shell command the hook runs. It delegates to a +// committed script rather than inlining the work, for two reasons: +// +// 1. The inlined form was guarded on `test -x ./mxcli`, and the binary it +// guards on is git-ignored (~85 MB, deliberately). In an ephemeral container +// the repo is re-cloned after an idle reap, `./mxcli` is not in the clone, +// the guard fails and the hook silently no-ops through `|| true` — leaving +// exactly the unprepared session the hook exists to prevent, with no error +// saying so. The script can fetch the binary back; a hook line cannot +// reasonably do OS/arch detection. (mxcli-todo findings #2) +// 2. The command string stays constant, so re-running `mxcli init` recognises +// its own hook even when the project is renamed. +func sessionStartHookCommand() string { + return fmt.Sprintf("sh %s || true", bootstrapScriptName) } // ensureSessionStartHook adds (idempotently) the mxcli bring-up to @@ -36,15 +111,22 @@ func sessionStartHookCommand(mprFile string) string { func ensureSessionStartHook(claudeDir, mprFile string) (changed bool, err error) { path := filepath.Join(claudeDir, "settings.json") + // The script is rewritten every time so a renamed .mpr (or an improvement to + // the script itself) is picked up; it holds no user content. + scriptChanged, err := writeBootstrapScript(claudeDir, mprFile) + if err != nil { + return false, err + } + settings := map[string]any{} if data, readErr := os.ReadFile(path); readErr == nil { if json.Unmarshal(data, &settings) != nil { - return false, fmt.Errorf("%s exists but is not valid JSON; leaving it untouched — add a SessionStart hook manually", path) + return scriptChanged, fmt.Errorf("%s exists but is not valid JSON; leaving it untouched — add a SessionStart hook manually", path) } } - if updated := addSessionStartHook(settings, sessionStartHookCommand(mprFile)); !updated { - return false, nil // already present + if updated := addSessionStartHook(settings, sessionStartHookCommand()); !updated { + return scriptChanged, nil // hook already current } out, err := json.MarshalIndent(settings, "", " ") @@ -58,11 +140,31 @@ func ensureSessionStartHook(claudeDir, mprFile string) (changed bool, err error) return true, nil } +// writeBootstrapScript writes (or refreshes) the committed bootstrap script the +// SessionStart hook runs. Reports whether the content changed. +func writeBootstrapScript(claudeDir, mprFile string) (changed bool, err error) { + // claudeDir is /.claude and bootstrapScriptName is project-relative, + // so take the base name to land beside settings.json. + path := filepath.Join(claudeDir, filepath.Base(bootstrapScriptName)) + want := []byte(fmt.Sprintf(bootstrapScriptTemplate, mprFile)) + if existing, readErr := os.ReadFile(path); readErr == nil && bytes.Equal(existing, want) { + return false, nil + } + if err := os.MkdirAll(claudeDir, 0o755); err != nil { + return false, err + } + if err := os.WriteFile(path, want, 0o755); err != nil { + return false, fmt.Errorf("writing %s: %w", path, err) + } + return true, nil +} + // addSessionStartHook inserts a SessionStart command hook into a parsed settings -// map, preserving existing keys and hooks. It returns false if a SessionStart -// hook whose command contains sessionStartHookMarker already exists (idempotent). -// Exported-for-test via the package; operates on the generic JSON shape so it -// never drops unknown settings. +// map, preserving existing keys and hooks. An entry matching any known marker is +// UPDATED in place rather than duplicated, so a project written by an older +// mxcli (which inlined the whole command) migrates to the current one instead of +// ending up with two hooks that both run. Returns whether anything changed. +// Operates on the generic JSON shape so it never drops unknown settings. func addSessionStartHook(settings map[string]any, command string) bool { hooks, _ := settings["hooks"].(map[string]any) if hooks == nil { @@ -81,9 +183,16 @@ func addSessionStartHook(settings map[string]any, command string) bool { if !ok { continue } - if c, _ := hm["command"].(string); strings.Contains(c, sessionStartHookMarker) { - return false // already configured + c, _ := hm["command"].(string) + if !isMxcliSessionStartHook(c) { + continue + } + if c == command { + return false // already current } + hm["command"] = command + settings["hooks"] = hooks + return true } } diff --git a/cmd/mxcli/init_hook_test.go b/cmd/mxcli/init_hook_test.go index 305e20d4d..087f99087 100644 --- a/cmd/mxcli/init_hook_test.go +++ b/cmd/mxcli/init_hook_test.go @@ -24,10 +24,11 @@ func TestAddSessionStartHook_Empty(t *testing.T) { func TestAddSessionStartHook_Idempotent(t *testing.T) { s := map[string]any{} - addSessionStartHook(s, "x run --local --setup y") - // A second add with the marker present must be a no-op. - if addSessionStartHook(s, "run --local --setup --ensure-db -p App.mpr") { - t.Error("expected no change when the marker is already present") + cmd := sessionStartHookCommand() + addSessionStartHook(s, cmd) + // Re-adding the identical command is a no-op. + if addSessionStartHook(s, cmd) { + t.Error("expected no change when the current command is already present") } ss := s["hooks"].(map[string]any)["SessionStart"].([]any) if len(ss) != 1 { @@ -35,6 +36,27 @@ func TestAddSessionStartHook_Idempotent(t *testing.T) { } } +// A project written by an older mxcli inlined the whole command in the hook. +// Re-running init must REWRITE that entry, not add a second one that runs the +// same bring-up twice. (mxcli-todo findings #2) +func TestAddSessionStartHook_MigratesLegacyCommand(t *testing.T) { + s := map[string]any{} + addSessionStartHook(s, "test -x ./mxcli && ./mxcli run --local --setup --ensure-db -p App.mpr || true") + + want := sessionStartHookCommand() + if !addSessionStartHook(s, want) { + t.Fatal("expected the legacy hook to be migrated") + } + ss := s["hooks"].(map[string]any)["SessionStart"].([]any) + if len(ss) != 1 { + t.Fatalf("SessionStart len = %d, want 1 (migrated in place, not duplicated)", len(ss)) + } + inner := ss[0].(map[string]any)["hooks"].([]any) + if got := inner[0].(map[string]any)["command"].(string); got != want { + t.Errorf("command = %q, want %q", got, want) + } +} + func TestAddSessionStartHook_PreservesExisting(t *testing.T) { // Existing unrelated settings + a different SessionStart hook must survive. s := map[string]any{ @@ -72,9 +94,25 @@ func TestEnsureSessionStartHook_WritesFile(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(string(data), "run --local --setup --ensure-db -p App.mpr") { + if !strings.Contains(string(data), sessionStartHookCommand()) { t.Errorf("settings.json missing the hook command:\n%s", data) } + // The hook delegates to a committed script, which must exist, name the + // project's .mpr, and be executable. + scriptPath := filepath.Join(dir, filepath.Base(bootstrapScriptName)) + script, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatalf("bootstrap script not written: %v", err) + } + if !strings.Contains(string(script), "MPR='App.mpr'") { + t.Errorf("bootstrap script does not name the project:\n%s", script) + } + if !strings.Contains(string(script), "releases/download/") { + t.Error("bootstrap script must be able to fetch mxcli after a reap") + } + if info, err := os.Stat(scriptPath); err == nil && info.Mode().Perm()&0o100 == 0 { + t.Errorf("bootstrap script is not executable: %v", info.Mode()) + } // Valid JSON round-trips. var check map[string]any if err := json.Unmarshal(data, &check); err != nil { @@ -95,9 +133,7 @@ func TestEnsureSessionStartHook_InvalidJSONUntouched(t *testing.T) { if err == nil { t.Error("expected an error for invalid existing settings.json") } - if changed { - t.Error("must not report a change when leaving invalid JSON untouched") - } + _ = changed // the script may be (re)written even when settings.json is not // The original content is preserved. data, _ := os.ReadFile(path) if string(data) != "{ not json" { diff --git a/docs-site/src/tools/bootstrap-prompt.md b/docs-site/src/tools/bootstrap-prompt.md index 920201fc5..738cfac3a 100644 --- a/docs-site/src/tools/bootstrap-prompt.md +++ b/docs-site/src/tools/bootstrap-prompt.md @@ -80,12 +80,15 @@ solution, do steps 2–4 once per app and read "If this is a solution" first. ```bash ./mxcli new --version --theme + rm -f /mxcli # a hardlink to the ./mxcli you just ran; mv would + # refuse it as "the same file" shopt -s dotglob && mv /* . && rmdir ``` (Use `mxcli init` instead if an `.mpr` already exists.) `mxcli new` also runs - `mxcli init`, which writes `.claude/settings.json` with a SessionStart hook - pointing at `-p .mpr` — check that the path in it is right after the move. + `mxcli init`, which writes `.claude/settings.json` with a SessionStart hook plus + the `.claude/bootstrap-mxcli.sh` it runs — check that the `.mpr` named in the + script is right after the move. 3. Confirm the Claude tooling: `./mxcli init --tool claude` (idempotent — it is what step 2 already ran, and re-running it is the cheapest way to be sure the hook, skills and commands are in place). @@ -101,9 +104,12 @@ solution, do steps 2–4 once per app and read "If this is a solution" first. applied, a `mxcli check` that passed but a real `mx check` later flagged. Note the Mendix + mxcli versions and how each finding was verified. This is durable context for the next session, and the most useful thing to share back to improve mxcli. -7. COMMIT everything now — `.mpr`, `.devcontainer/`, `.claude/` (including the - SessionStart hook), `README.md` and `FINDINGS.md` — so that after idle reaping the - next session bootstraps from files, not from re-running this prompt. +7. COMMIT everything now — `.mpr`, `.devcontainer/`, `.claude/` (the + SessionStart hook **and** `.claude/bootstrap-mxcli.sh`), `README.md` and + `FINDINGS.md` — so that after idle reaping the next session bootstraps from files, + not from re-running this prompt. The `mxcli` binary itself stays git-ignored (~85 + MB); the bootstrap script is what fetches it back into a fresh clone, so committing + the script is what makes the hook survive a reap. 8. Boot and verify: `./mxcli run --local -p .mpr` in the background, then confirm the app answers HTTP 200 at http://localhost:8080/ and report. 9. (Optional) For a browser preview from this cloud session, run @@ -238,10 +244,10 @@ two runtimes to keep straight. ## Two rules that make this robust - **Committing the config (step 7) is mandatory.** The prompt is a *one-time seed*. - Its output — `.mpr` + `.devcontainer/` + `.claude/` with the SessionStart hook — must - be committed so the steady state is file-driven and deterministic. After that, every - new session runs the hook (`run --local --setup --ensure-db`) automatically; you - never re-paste the prompt. + Its output — `.mpr` + `.devcontainer/` + `.claude/` with the SessionStart hook and + `bootstrap-mxcli.sh` — must be committed so the steady state is file-driven and + deterministic. After that, every new session runs the hook automatically; you never + re-paste the prompt. Miss the script and the hook has nothing to run after a reap. - **mxcli delivery is an environment concern, not the prompt's.** Step 1 is the fragile part in a gated web session (a GitHub release `curl` may be blocked). The robust fix is for the Claude Code Web **environment image / setup script to pre-install mxcli** From ae8563559fdef2f48c25d07f4b0ede1de843a41a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 11:41:31 +0000 Subject: [PATCH 7/8] feat(exec,check): accept "-" to read MDL from stdin A heredoc is the natural way to drive MDL from an agent or a shell script, and `-` is how every other Unix tool spells stdin. It was taken literally as a filename: Error reading file: open -: no such file or directory so every ad-hoc script needed writing to a temp file first. One helper now backs both commands, so `check` gained the same spelling rather than only the reported one; it reports the source as instead of a bare dash. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- .claude/skills/fix-issue.md | 1 + cmd/mxcli/cmd_check.go | 11 ++++--- cmd/mxcli/cmd_exec.go | 12 +++++-- cmd/mxcli/mdlsource.go | 38 +++++++++++++++++++++ cmd/mxcli/mdlsource_test.go | 66 +++++++++++++++++++++++++++++++++++++ 5 files changed, 121 insertions(+), 7 deletions(-) create mode 100644 cmd/mxcli/mdlsource.go create mode 100644 cmd/mxcli/mdlsource_test.go diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index 95508570e..fbe2a86af 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -362,6 +362,7 @@ cases for these three BSON types — they fell to `default: return nil`. | A solution's apps are served on one host with different ports, so they share a cookie jar (cookies key on host name and **ignore the port**) — logging into one silently replaces the other's `XASSESSIONID`. Giving each app a host name in **App Settings -> Configurations -> Application root URL** appears to do nothing under `run --local` | `runtimeConfigParams` builds the entire boot `update_configuration` payload from `LocalRuntimeOptions`, and `ApplicationRootUrl` was only ever populated from a `--hub` registration. The model's own value had no path into the payload — and since the admin action REPLACES rather than merges, nothing else could supply it either | `cmd/mxcli/docker/runlocal.go` (`configuredApplicationRootURL`, `applicationRootURLFrom`, `customHostRootURL`) | Read the setting off the project at boot and use it when no hub URL was assigned (hub wins — that URL is the one actually serving the app). **The trap is that a blank Mendix app already ships `ApplicationRootUrl = http://localhost:8080/`**, so "is set" does not mean "was chosen": honouring every value would change behaviour for every existing project and, under `--app-port`, advertise a port the app is not serving on. Only a **non-loopback host** is passed through, and a port that disagrees with `--app-port` warns. Serving under the host name needs no flag at all — the runtime accepts any `Host` and the client uses relative URLs (verified: 200 via `/etc/hosts`, nip.io and localtest.me); the setting matters only for the **absolute** URLs Mendix generates. **Generalisable**: before defaulting from a model setting, check what a blank project already has in it — a non-empty default makes "fall back to the model" a behaviour change, not a no-op. Verified end-to-end on 11.12.1: with `backend.local` configured, boot prints `Application root URL from configuration "Default"` and the app answers 200 on both the host name and the listen address. Tests `TestApplicationRootURLFrom`, `TestCustomHostRootURL` | | A page bound to an attribute the entity **inherits** (`Person extends Administration.Account`, page binds `FullName`) passes `mxcli check --references` AND `mxcli lint`, then the real MxBuild fails `[error] [CE1613] "The selected attribute 'TaskBoard.Person.FullName' no longer exists."` The message reads as a deletion; it never existed there | Mendix stores a page's attribute reference against the entity that **declares** the attribute. `resolveAttributePath` qualified a bare name with `pb.entityContext` unconditionally, so a specialization that merely inherits the attribute got a dangling reference. Two independent resolvers had the same bug: the direct binding, and the final attribute of an association path (`resolveAssociationAttributePath`) | `mdl/executor/cmd_pages_builder_input.go` (`declaringEntityFor`, `entityAttributeOwners`), `mdl/executor/cmd_pages_builder_v3.go` (final attribute of the association path) | Walk the generalization chain and qualify with the first entity that declares the name. **Fix both resolvers** — a probe that only tested the direct case would have shipped half a fix; the reporter's own table already showed the associated case failing, and it did still fail after the first patch. Unknown names keep today's context qualification rather than being re-pointed, and a cyclic chain terminates. **Watch the new dependency**: attribute resolution now consults the domain models, and `getDomainModels` panics on a nil backend — several unit tests build a `pageBuilder` with neither backend nor cache, so the lookup bails out early instead. A/B on Mendix 11.12.1: pre-fix binary → CE1613 for the inherited column and 0 errors for the own column; fixed binary → 0 errors for both shapes. Repro `mdl-examples/bug-tests/todo-12-inherited-attribute-on-page.mdl`; tests `mdl/executor/cmd_pages_builder_inheritance_test.go`. mxcli-todo #12 | | The SessionStart hook `mxcli init` writes cannot survive an idle reap: it is guarded on `test -x ./mxcli`, and `.gitignore` excludes that binary (~85 MB) on purpose. The container is reclaimed, the repo re-cloned without it, the guard fails and the hook no-ops through `\|\| true` — the next session has no mxcli, no MxBuild cache, no database, and no message saying so | The hook inlined the whole bring-up in one shell line, so "binary missing" could only be expressed as "skip". A hook line cannot reasonably do OS/arch detection and a download; something committed has to | `cmd/mxcli/init_hook.go` (`bootstrapScriptTemplate`, `writeBootstrapScript`, `sessionStartHookCommand`, `sessionStartHookMarkers`) | Emit a committed `.claude/bootstrap-mxcli.sh` that resolves OS/arch, fetches the binary when absent (`MXCLI_TAG` to pin), then runs the setup; the hook becomes `sh .claude/bootstrap-mxcli.sh \|\| true`. **Changing the hook command breaks dedupe**, which matched on the old command string — so `addSessionStartHook` now recognises *any* known marker and **rewrites the entry in place**, migrating an old project instead of leaving it with two hooks that both run. **Generalisable**: a guard whose condition is something you deliberately do not commit is a silent no-op waiting for a fresh clone — make the guard able to satisfy itself. Verified by reproducing the reap: moved `./mxcli` out of the project, ran the hook command verbatim, watched it re-download (88 MB, new mtime) and finish with "Setup complete … database ready". Tests `TestAddSessionStartHook_MigratesLegacyCommand`, `TestEnsureSessionStartHook_WritesFile`. mxcli-todo #2 | +| `mxcli exec -p app.mpr - <<'EOF' … EOF` fails with `Error reading file: open -: no such file or directory` — `-` is taken literally as a filename, so MDL cannot be piped or written as a heredoc and every ad-hoc script needs a temp file first | `exec` (and `check`) called `os.ReadFile(path)` directly, with no case for the conventional stdin spelling | `cmd/mxcli/mdlsource.go` (new `readMDLSource`, `mdlSourceLabel`), `cmd/mxcli/cmd_exec.go`, `cmd/mxcli/cmd_check.go` | One helper both commands share, so `check` gained the same spelling rather than only the reported one; `check` reports the source as `` instead of a bare `-`. Verified live: a heredoc through `exec` and a pipe through `check` both run. Tests `cmd/mxcli/mdlsource_test.go`. mxcli-todo #5 | **Key insight:** `microflows$ListRange` stores offset/limit inside a nested `CustomRange` map — must cast `raw["CustomRange"].(map[string]any)` before diff --git a/cmd/mxcli/cmd_check.go b/cmd/mxcli/cmd_check.go index eacfb5931..81cec26fa 100644 --- a/cmd/mxcli/cmd_check.go +++ b/cmd/mxcli/cmd_check.go @@ -15,7 +15,7 @@ import ( ) var checkCmd = &cobra.Command{ - Use: "check ", + Use: "check ", Short: "Check an MDL script for errors without executing it", Long: `Check an MDL script file for syntax errors and optionally validate references. @@ -46,6 +46,9 @@ Examples: # Output as JSON or SARIF mxcli check script.mdl --format json mxcli check script.mdl --format sarif + + # Read the script from stdin + cat script.mdl | mxcli check - `, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { @@ -59,8 +62,8 @@ Examples: outputFormat := linter.OutputFormat(format) formatter := linter.GetFormatter(outputFormat, !isStructured) - // Read the file - content, err := os.ReadFile(filePath) + // Read the script (a path, or "-" for stdin) + content, err := readMDLSource(filePath) if err != nil { fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err) os.Exit(1) @@ -68,7 +71,7 @@ Examples: // Parse the script if !isStructured { - fmt.Printf("Checking syntax: %s\n", filePath) + fmt.Printf("Checking syntax: %s\n", mdlSourceLabel(filePath)) } prog, errs := visitor.Build(string(content)) if len(errs) > 0 { diff --git a/cmd/mxcli/cmd_exec.go b/cmd/mxcli/cmd_exec.go index 4fbe54c9a..b40c15d22 100644 --- a/cmd/mxcli/cmd_exec.go +++ b/cmd/mxcli/cmd_exec.go @@ -13,7 +13,7 @@ import ( ) var execCmd = &cobra.Command{ - Use: "exec ", + Use: "exec ", Short: "Execute an MDL script file", Long: `Execute an MDL script file containing MDL commands. @@ -24,10 +24,16 @@ makes a partially-applied domain script re-runnable — the already-applied statements (e.g. "attribute already exists") error individually while the not- yet-applied ones still run — without a failure masking later work. +Pass "-" as the file to read the script from standard input, so MDL can be +piped or written inline as a heredoc without a temporary file. + Example: mxcli exec setup.mdl mxcli exec -p app.mpr script.mdl mxcli exec -p app.mpr script.mdl --continue-on-error + mxcli exec -p app.mpr - <<'EOF' + SHOW STRUCTURE DEPTH 1; + EOF `, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { @@ -35,8 +41,8 @@ Example: projectPath, _ := cmd.Flags().GetString("project") continueOnError, _ := cmd.Flags().GetBool("continue-on-error") - // Read the file - content, err := os.ReadFile(filePath) + // Read the script (a path, or "-" for stdin) + content, err := readMDLSource(filePath) if err != nil { fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err) os.Exit(1) diff --git a/cmd/mxcli/mdlsource.go b/cmd/mxcli/mdlsource.go new file mode 100644 index 000000000..e2fb5c854 --- /dev/null +++ b/cmd/mxcli/mdlsource.go @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "io" + "os" +) + +// stdinPath is the conventional spelling for "read from standard input". +const stdinPath = "-" + +// readMDLSource reads an MDL script from a file, or from standard input when the +// path is "-". +// +// A heredoc is the natural way to drive MDL from an agent or a shell script, and +// `-` is how every other Unix tool spells it — without this the dash was taken +// literally and the command failed with "open -: no such file or directory", +// forcing a temp file. (mxcli-todo findings #5) +func readMDLSource(path string) ([]byte, error) { + if path == stdinPath { + content, err := io.ReadAll(os.Stdin) + if err != nil { + return nil, fmt.Errorf("reading MDL from stdin: %w", err) + } + return content, nil + } + return os.ReadFile(path) +} + +// mdlSourceLabel names the source in messages: a real path, or "". +func mdlSourceLabel(path string) string { + if path == stdinPath { + return "" + } + return path +} diff --git a/cmd/mxcli/mdlsource_test.go b/cmd/mxcli/mdlsource_test.go new file mode 100644 index 000000000..6d723839f --- /dev/null +++ b/cmd/mxcli/mdlsource_test.go @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// mxcli-todo findings #5: `-` was taken literally as a filename, so a heredoc — +// the natural way to drive MDL from an agent or a shell script — failed with +// "open -: no such file or directory" and forced a temp file. +func TestReadMDLSource_Stdin(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + const script = "SHOW STRUCTURE DEPTH 1;\n" + go func() { + _, _ = w.WriteString(script) + w.Close() + }() + + orig := os.Stdin + os.Stdin = r + defer func() { os.Stdin = orig }() + + got, err := readMDLSource(stdinPath) + if err != nil { + t.Fatalf("readMDLSource(%q): %v", stdinPath, err) + } + if string(got) != script { + t.Errorf("got %q, want %q", got, script) + } +} + +func TestReadMDLSource_File(t *testing.T) { + path := filepath.Join(t.TempDir(), "script.mdl") + const script = "create module M;\n" + if err := os.WriteFile(path, []byte(script), 0o644); err != nil { + t.Fatal(err) + } + got, err := readMDLSource(path) + if err != nil { + t.Fatalf("readMDLSource(%q): %v", path, err) + } + if string(got) != script { + t.Errorf("got %q, want %q", got, script) + } +} + +func TestReadMDLSource_MissingFile(t *testing.T) { + if _, err := readMDLSource(filepath.Join(t.TempDir(), "absent.mdl")); err == nil { + t.Error("expected an error for a missing file") + } +} + +func TestMDLSourceLabel(t *testing.T) { + if got := mdlSourceLabel(stdinPath); got != "" { + t.Errorf("label for stdin = %q, want ", got) + } + if got := mdlSourceLabel("script.mdl"); got != "script.mdl" { + t.Errorf("label for a path = %q, want the path", got) + } +} From 8515fa9b5960d29e79a84b22c377b0032b82aef4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 11:41:43 +0000 Subject: [PATCH 8/8] docs(syntax): correct two spellings the parser rejects, and pin them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mxcli syntax` is the reference an agent reads before writing MDL, and nothing checked it against the parser. Two entries had drifted, each costing a build round-trip to discover: - TEXTBOX/TEXTAREA/COMBOBOX/DATEPICKER/CHECKBOX were documented with `Binds:`, which the parser rejects outright ("'Binds:' is no longer supported, use 'Attribute:' instead"). - `DataSource: MICROFLOW Module.MF()` — a zero-argument microflow DATASOURCE takes no parentheses, unlike RETRIEVE/CALL where they are normal. The parser errors at the `)`. Both verified against the parser before and after. A table-driven test now fails if either spelling reappears in any topic's Syntax or Example field. It is a spelling guard rather than a parse: the snippets are fragments (a DATAVIEW body, a property line) that do not stand alone as statements. Proven by reintroducing `Binds:` and watching the test name the topic and field. A third claim in the same report did not reproduce — a CONTAINER with `OnClick: SHOW_PAGE M.Page(Param: $currentObject)` parses fine on current main — so it was left alone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- .claude/skills/fix-issue.md | 1 + cmd/mxcli/syntax/features_page.go | 16 ++++----- cmd/mxcli/syntax/retired_spellings_test.go | 42 ++++++++++++++++++++++ 3 files changed, 51 insertions(+), 8 deletions(-) create mode 100644 cmd/mxcli/syntax/retired_spellings_test.go diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index fbe2a86af..6422c5c9b 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -363,6 +363,7 @@ cases for these three BSON types — they fell to `default: return nil`. | A page bound to an attribute the entity **inherits** (`Person extends Administration.Account`, page binds `FullName`) passes `mxcli check --references` AND `mxcli lint`, then the real MxBuild fails `[error] [CE1613] "The selected attribute 'TaskBoard.Person.FullName' no longer exists."` The message reads as a deletion; it never existed there | Mendix stores a page's attribute reference against the entity that **declares** the attribute. `resolveAttributePath` qualified a bare name with `pb.entityContext` unconditionally, so a specialization that merely inherits the attribute got a dangling reference. Two independent resolvers had the same bug: the direct binding, and the final attribute of an association path (`resolveAssociationAttributePath`) | `mdl/executor/cmd_pages_builder_input.go` (`declaringEntityFor`, `entityAttributeOwners`), `mdl/executor/cmd_pages_builder_v3.go` (final attribute of the association path) | Walk the generalization chain and qualify with the first entity that declares the name. **Fix both resolvers** — a probe that only tested the direct case would have shipped half a fix; the reporter's own table already showed the associated case failing, and it did still fail after the first patch. Unknown names keep today's context qualification rather than being re-pointed, and a cyclic chain terminates. **Watch the new dependency**: attribute resolution now consults the domain models, and `getDomainModels` panics on a nil backend — several unit tests build a `pageBuilder` with neither backend nor cache, so the lookup bails out early instead. A/B on Mendix 11.12.1: pre-fix binary → CE1613 for the inherited column and 0 errors for the own column; fixed binary → 0 errors for both shapes. Repro `mdl-examples/bug-tests/todo-12-inherited-attribute-on-page.mdl`; tests `mdl/executor/cmd_pages_builder_inheritance_test.go`. mxcli-todo #12 | | The SessionStart hook `mxcli init` writes cannot survive an idle reap: it is guarded on `test -x ./mxcli`, and `.gitignore` excludes that binary (~85 MB) on purpose. The container is reclaimed, the repo re-cloned without it, the guard fails and the hook no-ops through `\|\| true` — the next session has no mxcli, no MxBuild cache, no database, and no message saying so | The hook inlined the whole bring-up in one shell line, so "binary missing" could only be expressed as "skip". A hook line cannot reasonably do OS/arch detection and a download; something committed has to | `cmd/mxcli/init_hook.go` (`bootstrapScriptTemplate`, `writeBootstrapScript`, `sessionStartHookCommand`, `sessionStartHookMarkers`) | Emit a committed `.claude/bootstrap-mxcli.sh` that resolves OS/arch, fetches the binary when absent (`MXCLI_TAG` to pin), then runs the setup; the hook becomes `sh .claude/bootstrap-mxcli.sh \|\| true`. **Changing the hook command breaks dedupe**, which matched on the old command string — so `addSessionStartHook` now recognises *any* known marker and **rewrites the entry in place**, migrating an old project instead of leaving it with two hooks that both run. **Generalisable**: a guard whose condition is something you deliberately do not commit is a silent no-op waiting for a fresh clone — make the guard able to satisfy itself. Verified by reproducing the reap: moved `./mxcli` out of the project, ran the hook command verbatim, watched it re-download (88 MB, new mtime) and finish with "Setup complete … database ready". Tests `TestAddSessionStartHook_MigratesLegacyCommand`, `TestEnsureSessionStartHook_WritesFile`. mxcli-todo #2 | | `mxcli exec -p app.mpr - <<'EOF' … EOF` fails with `Error reading file: open -: no such file or directory` — `-` is taken literally as a filename, so MDL cannot be piped or written as a heredoc and every ad-hoc script needs a temp file first | `exec` (and `check`) called `os.ReadFile(path)` directly, with no case for the conventional stdin spelling | `cmd/mxcli/mdlsource.go` (new `readMDLSource`, `mdlSourceLabel`), `cmd/mxcli/cmd_exec.go`, `cmd/mxcli/cmd_check.go` | One helper both commands share, so `check` gained the same spelling rather than only the reported one; `check` reports the source as `` instead of a bare `-`. Verified live: a heredoc through `exec` and a pipe through `check` both run. Tests `cmd/mxcli/mdlsource_test.go`. mxcli-todo #5 | +| `mxcli syntax` documents spellings the parser rejects, so an agent following the reference writes MDL that fails — `TEXTBOX … (Binds: Attr)` ("'Binds:' is no longer supported, use 'Attribute:' instead") and `DataSource: MICROFLOW Module.MF()` (a zero-arg microflow datasource takes NO parens, unlike RETRIEVE/CALL) | Nothing checks the `syntax` corpus against the parser. `make check-skill-mdl` validates MDL blocks in the skills and the docs site, but the `Syntax`/`Example` strings in `cmd/mxcli/syntax/*.go` are not covered, so a retired spelling can sit there indefinitely | `cmd/mxcli/syntax/features_page.go` (10 × `Binds:` → `Attribute:`, the datasource parens), `cmd/mxcli/syntax/retired_spellings_test.go` (new guard) | Fix the text **and** pin it: a table-driven test fails if a retired spelling reappears in any topic's Syntax or Example. It is a spelling guard rather than a parse — the snippets are fragments (a DATAVIEW body, a property line) that do not stand alone as statements, so they cannot just be fed to the parser. Proven by reintroducing `Binds:` and watching the test name the topic and field. **A third claim in the same report did not reproduce**: `CONTAINER (OnClick: SHOW_PAGE M.P(Param: $currentObject))` parses fine on current main, so only the two verified ones were changed. mxcli-todo #8 | **Key insight:** `microflows$ListRange` stores offset/limit inside a nested `CustomRange` map — must cast `raw["CustomRange"].(map[string]any)` before diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index bf95bd9b0..197d1f503 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -13,7 +13,7 @@ func init() { "widget", "layout", "screen", }, Syntax: "CREATE PAGE Module.Name\n (\n Title: 'Page Title',\n Layout: Module.LayoutName\n [, Params: { $Param: Module.Entity }]\n [, Url: 'page-url']\n [, Folder: 'FolderPath']\n [, Variables: { $var: Boolean = 'true' }]\n [, PopupWidth: 800, PopupHeight: 480, PopupResizable: true]\n [, Class: 'css-class', Style: 'css: rule']\n )\n {\n -- widgets\n }", - Example: "CREATE PAGE MyModule.EditCustomer\n (\n Params: { $Customer: MyModule.Customer },\n Title: 'Edit Customer',\n Layout: Atlas_Core.PopupLayout,\n Class: 'container-fluid'\n )\n {\n DATAVIEW dvCustomer (DataSource: $Customer) {\n TEXTBOX txtName (Label: 'Name', Binds: Name)\n FOOTER footer1 {\n ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE_CHANGES, ButtonStyle: Primary)\n ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL_CHANGES)\n }\n }\n }", + Example: "CREATE PAGE MyModule.EditCustomer\n (\n Params: { $Customer: MyModule.Customer },\n Title: 'Edit Customer',\n Layout: Atlas_Core.PopupLayout,\n Class: 'container-fluid'\n )\n {\n DATAVIEW dvCustomer (DataSource: $Customer) {\n TEXTBOX txtName (Label: 'Name', Attribute: Name)\n FOOTER footer1 {\n ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE_CHANGES, ButtonStyle: Primary)\n ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL_CHANGES)\n }\n }\n }", SeeAlso: []string{"page.create", "page.widgets", "page.alter", "snippet"}, }) @@ -39,8 +39,8 @@ func init() { "dynamictext", "snippetcall", "navigationlist", "column", "row", "footer", "header", "controlbar", }, - Syntax: "-- Containers\nLAYOUTGRID name { ROW r { COLUMN c (DesktopWidth: 6) { ... } } }\nCONTAINER name (Class: 'cls') { ... }\nCONTAINER name (OnClick: MICROFLOW Module.MF) { ... } -- clickable container\n\n-- Data widgets\nDATAVIEW name (DataSource: $Param) { ... FOOTER f { ... } }\nDATAGRID name (DataSource: DATABASE Module.Entity) { COLUMN c (Attribute: A) }\nGALLERY name (DataSource: DATABASE Module.Entity, DesktopColumns: 3) { ... }\nLISTVIEW name (DataSource: DATABASE Module.Entity) { ... }\n\n-- Inputs\nTEXTBOX name (Label: 'L', Binds: Attr)\nTEXTAREA | DATEPICKER | COMBOBOX | CHECKBOX | RADIOBUTTONS\n\n-- Actions\nACTIONBUTTON name (Caption: 'C', Action: SAVE_CHANGES, ButtonStyle: Primary)\n\n-- Display\nDYNAMICTEXT name (Content: 'Hello, {1}!', ContentParams: [{1} = Name])", - Example: "DATAVIEW dvCustomer (DataSource: $Customer) {\n TEXTBOX txtName (Label: 'Name', Binds: Name)\n COMBOBOX cbStatus (Label: 'Status', Binds: Status)\n FOOTER footer1 {\n ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE_CHANGES, ButtonStyle: Primary)\n ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL_CHANGES)\n }\n}", + Syntax: "-- Containers\nLAYOUTGRID name { ROW r { COLUMN c (DesktopWidth: 6) { ... } } }\nCONTAINER name (Class: 'cls') { ... }\nCONTAINER name (OnClick: MICROFLOW Module.MF) { ... } -- clickable container\n\n-- Data widgets\nDATAVIEW name (DataSource: $Param) { ... FOOTER f { ... } }\nDATAGRID name (DataSource: DATABASE Module.Entity) { COLUMN c (Attribute: A) }\nGALLERY name (DataSource: DATABASE Module.Entity, DesktopColumns: 3) { ... }\nLISTVIEW name (DataSource: DATABASE Module.Entity) { ... }\n\n-- Inputs\nTEXTBOX name (Label: 'L', Attribute: Attr)\nTEXTAREA | DATEPICKER | COMBOBOX | CHECKBOX | RADIOBUTTONS\n\n-- Actions\nACTIONBUTTON name (Caption: 'C', Action: SAVE_CHANGES, ButtonStyle: Primary)\n\n-- Display\nDYNAMICTEXT name (Content: 'Hello, {1}!', ContentParams: [{1} = Name])", + Example: "DATAVIEW dvCustomer (DataSource: $Customer) {\n TEXTBOX txtName (Label: 'Name', Attribute: Name)\n COMBOBOX cbStatus (Label: 'Status', Attribute: Status)\n FOOTER footer1 {\n ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE_CHANGES, ButtonStyle: Primary)\n ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL_CHANGES)\n }\n}", SeeAlso: []string{"page.create", "page.datasource"}, }) @@ -51,7 +51,7 @@ func init() { "datasource", "data source", "database", "microflow", "selection", "variable", "binding", "binds", "association", "data from context", }, - Syntax: "DataSource: $Variable -- Parameter/variable binding\nDataSource: DATABASE Module.Entity -- Database query\nDataSource: MICROFLOW Module.MF() -- Microflow datasource\nDataSource: SELECTION widgetName -- Selection from another widget\nDataSource: $currentObject/Module.Assoc -- Over an association (\"data from context\")\n -- list widget → to-many collection\n -- nested DATAVIEW → the to-one referenced object\nBinds: AttributeName -- Attribute binding (inputs)", + Syntax: "DataSource: $Variable -- Parameter/variable binding\nDataSource: DATABASE Module.Entity -- Database query\nDataSource: MICROFLOW Module.MF -- Microflow datasource (no parens when it takes no arguments)\nDataSource: SELECTION widgetName -- Selection from another widget\nDataSource: $currentObject/Module.Assoc -- Over an association (\"data from context\")\n -- list widget → to-many collection\n -- nested DATAVIEW → the to-one referenced object\nAttribute: AttributeName -- Attribute binding (inputs)", Example: "-- Database datasource with grid\nDATAGRID grid (DataSource: DATABASE Module.Customer) {\n COLUMN colName (Attribute: Name, Caption: 'Name')\n}\n\n-- Microflow datasource\nDATAVIEW dv (DataSource: MICROFLOW Module.GetData()) { ... }\n\n-- Over an association: a nested DataView shows the referenced (to-one) object\nDATAVIEW dvOrder (DataSource: $Order) {\n DATAVIEW dvCustomer (DataSource: $currentObject/Order_Customer) {\n TEXTBOX (Label: 'Name', Attribute: Name)\n }\n}\n\n-- Over an association: a list widget shows the (to-many) collection\nLISTVIEW lvLines (DataSource: $currentObject/Order_OrderLine) { ... }", SeeAlso: []string{"page.widgets", "page.create"}, }) @@ -90,7 +90,7 @@ func init() { "popup width", "popup height", "popup resizable", }, Syntax: "ALTER PAGE Module.Name {\n SET property = value ON widgetName;\n SET (prop1 = val1, prop2 = val2) ON widgetName;\n SET Title = 'New Title'; -- page-level (case-sensitive)\n SET Class = 'css-class'; -- page-level CSS class / style\n SET Style = 'css: rule';\n SET PopupWidth = 800; -- page-level pop-up dimensions\n SET PopupHeight = 480;\n SET PopupResizable = true;\n INSERT AFTER widgetName { };\n INSERT BEFORE widgetName { };\n INSERT INTO containerName { };\n DROP WIDGET name1, name2;\n REPLACE widgetName WITH { };\n};", - Example: "ALTER PAGE Module.EditPage {\n SET (Caption = 'Save & Close', ButtonStyle = Success) ON btnSave;\n INSERT AFTER txtName {\n TEXTBOX txtMiddleName (Label: 'Middle Name', Binds: MiddleName)\n };\n DROP WIDGET txtUnused;\n};", + Example: "ALTER PAGE Module.EditPage {\n SET (Caption = 'Save & Close', ButtonStyle = Success) ON btnSave;\n INSERT AFTER txtName {\n TEXTBOX txtMiddleName (Label: 'Middle Name', Attribute: MiddleName)\n };\n DROP WIDGET txtUnused;\n};", SeeAlso: []string{"page.create", "page.show", "snippet.alter"}, }) @@ -153,7 +153,7 @@ func init() { "alter snippet", "modify snippet", "update snippet", }, Syntax: "ALTER SNIPPET Module.Name {\n SET property = value ON widgetName;\n INSERT AFTER widgetName { };\n INSERT BEFORE widgetName { };\n INSERT INTO containerName { };\n DROP WIDGET name1, name2;\n REPLACE widgetName WITH { };\n};", - Example: "ALTER SNIPPET Module.NavSnippet {\n REPLACE navItem1 WITH {\n ACTIONBUTTON btnHome (Caption: 'Home', Action: SHOW_PAGE Module.HomePage)\n };\n DROP WIDGET txtOldField;\n INSERT AFTER txtName {\n TEXTBOX txtNewField (Label: 'New Field', Binds: NewAttr)\n };\n};", + Example: "ALTER SNIPPET Module.NavSnippet {\n REPLACE navItem1 WITH {\n ACTIONBUTTON btnHome (Caption: 'Home', Action: SHOW_PAGE Module.HomePage)\n };\n DROP WIDGET txtOldField;\n INSERT AFTER txtName {\n TEXTBOX txtNewField (Label: 'New Field', Attribute: NewAttr)\n };\n};", SeeAlso: []string{"snippet", "page.alter"}, }) @@ -192,7 +192,7 @@ func init() { "use fragment", "template", "script scope", }, Syntax: "DEFINE FRAGMENT Name AS { };\nDEFINE FRAGMENT Name AS { SLOT [name] };\nDEFINE FRAGMENT Name ($d: datasource, $a: action) AS { };\nUSE FRAGMENT Name [(args)] [AS prefix_];\nUSE FRAGMENT Name [(args)] [AS prefix_] { };\nSHOW FRAGMENTS;\nDESCRIBE FRAGMENT Name;\nDESCRIBE FRAGMENT FROM PAGE Module.Page WIDGET widgetName;", - Example: "DEFINE FRAGMENT SaveCancelFooter AS {\n FOOTER footer1 {\n ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE_CHANGES, ButtonStyle: Primary)\n ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL_CHANGES)\n }\n};\n\nCREATE PAGE Module.EditPage (...) {\n DATAVIEW dv (DataSource: $Param) {\n TEXTBOX txtName (Label: 'Name', Binds: Name)\n USE FRAGMENT SaveCancelFooter\n }\n};", + Example: "DEFINE FRAGMENT SaveCancelFooter AS {\n FOOTER footer1 {\n ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE_CHANGES, ButtonStyle: Primary)\n ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL_CHANGES)\n }\n};\n\nCREATE PAGE Module.EditPage (...) {\n DATAVIEW dv (DataSource: $Param) {\n TEXTBOX txtName (Label: 'Name', Attribute: Name)\n USE FRAGMENT SaveCancelFooter\n }\n};", SeeAlso: []string{"fragment.define", "fragment.use", "fragment.slot", "fragment.params", "snippet"}, }) @@ -203,7 +203,7 @@ func init() { "define fragment", "declare fragment", "create fragment", }, Syntax: "DEFINE FRAGMENT Name AS {\n \n};", - Example: "DEFINE FRAGMENT FormFields AS {\n TEXTBOX txtName (Label: 'Name', Binds: Name)\n TEXTBOX txtEmail (Label: 'Email', Binds: Email)\n};", + Example: "DEFINE FRAGMENT FormFields AS {\n TEXTBOX txtName (Label: 'Name', Attribute: Name)\n TEXTBOX txtEmail (Label: 'Email', Attribute: Email)\n};", SeeAlso: []string{"fragment", "fragment.use"}, }) diff --git a/cmd/mxcli/syntax/retired_spellings_test.go b/cmd/mxcli/syntax/retired_spellings_test.go new file mode 100644 index 000000000..514f2ce4e --- /dev/null +++ b/cmd/mxcli/syntax/retired_spellings_test.go @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 + +package syntax + +import ( + "strings" + "testing" +) + +// `mxcli syntax` is the reference an agent reads before writing MDL, and nothing +// checks it against the parser — so a spelling the parser has dropped can sit in +// it indefinitely. Two did, and both cost a build round-trip to discover +// (mxcli-todo findings #8). +// +// This pins the corrections. It is a spelling guard, not a parse: the snippets +// are fragments (a DATAVIEW body, a property line) that do not stand alone as +// statements, so they cannot simply be fed to the parser. +func TestSyntaxDocs_NoRetiredSpellings(t *testing.T) { + retired := []struct { + text string + reason string + }{ + { + "Binds:", + "the parser rejects it: \"'Binds:' is no longer supported, use 'Attribute:' instead\"", + }, + { + "MICROFLOW Module.MF()", + "a zero-argument microflow DATASOURCE takes no parentheses (unlike RETRIEVE/CALL, where they are normal)", + }, + } + + for _, f := range All() { + for _, r := range retired { + for field, text := range map[string]string{"Syntax": f.Syntax, "Example": f.Example} { + if strings.Contains(text, r.text) { + t.Errorf("syntax topic %q, %s field, still shows %q — %s", f.Path, field, r.text, r.reason) + } + } + } + } +}