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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion .claude/skills/mendix/run-local.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,9 @@ Launch `run --local` as the **sole** command in its invocation (don't chain a tr

| Flag | Default | Purpose |
|------|---------|---------|
| `--local` | — | Required; run without Docker |
| `--local` | — | Required; run without Docker (implied by `--hub`) |
| `--hub` | — | Expose the app in a browser at a tunnel-hub URL (see below) |
| `--hub-secret` | — | Shared auth (`user:pass`) matching the hub's `--secret` |
| `--watch` | off | Rebuild + hot-apply on each change |
| `--ensure-db` | off | Provision local Postgres + app database if missing |
| `--setup` | off | Cache MxBuild+runtime + ensure DB, then exit (SessionStart bring-up) |
Expand Down Expand Up @@ -147,6 +149,46 @@ mxcli run --local -p app.mpr --watch --screenshot
reuse the session, so pages behind login render authenticated. Best-effort: an
anonymous app with no login form proceeds unauthenticated.

## External browser preview (`--hub`)

`--hub <url>` exposes the running app in a **browser at a public URL** without the app
leaving this machine and without committing — for reviewing work-in-progress from a
phone/tablet, or from an egress-only environment like Claude Code on the web. The app
stays here; a **chisel reverse tunnel** dials *out* to a hub over 443 and the hub proxies
browser requests back down it. Nothing is pushed — only live HTTP — and everything rides
one 443 connection, so it works through an egress-only proxy.

**You run your own hub — there is no hosted service.** Stand up `mxcli tunnel-hub` once on
a host you control (a small VPS with a domain), then point apps at it.

```bash
# on your VPS: *.example.com + hub.example.com -> this host, inbound 80+443 open
mxcli tunnel-hub --domain example.com --secret alice:s3cret

# where the app runs:
mxcli run --hub https://hub.example.com --hub-secret alice:s3cret -p app.mpr
# -> registers and prints e.g. "Preview available at https://app.example.com"
```

The hub is **multi-tenant**: it fronts many previews at per-preview subdomains
(`<project>-<branch>.example.com`; `main`/`master` collapses to `<project>`) with a
sortable overview at `https://hub.example.com/`. Each `run --hub` self-registers:

- **Project** and **branch** auto-detect (`.mpr` name + git); override with
`--hub-project` / `--hub-branch`. `--hub-worktree` distinguishes worktrees of one branch.
- **`--hub-prefix`** namespaces the hostname (org/solution/team/env) →
`<prefix>-<project>-<branch>`; **`--hub-solution`** groups a solution's apps in the overview.
- The overview shows availability (a reaped/idle container goes **stale**), sortable by
last-used / registered / project. Re-registering keeps a **stable URL**.
- `--hub` **implies `--local`**, boots the runtime with `ApplicationRootUrl` set to the
assigned URL (so the SPA/`originURI` work), and the tunnel reconnects forever. Combine
with `--watch` for the full loop: edit here → hot-apply → refresh the browser.

**Hub setup:** wildcard `*.example.com` A record (+ `hub.example.com`) → the VPS; inbound
80+443 open (per-subdomain Let's Encrypt on demand). **Security:** this version uses one
shared `--secret` and open registration, so keep the hub to people you trust and don't
expose it publicly (per-tenant auth is a follow-up).

## Validation checklist

- [ ] Project is Mendix 11.x.
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,7 @@ Full syntax tables for all MDL statements (microflows, pages, security, navigati
- VS Code extension (`vscode-mdl`) with context menu commands (Run/Check/Selection)
- Docker build integration (`mxcli docker build`) with PAD patching (Phase 1)
- Warm local dev loop (`mxcli run --local [--watch] [--screenshot]`): Docker-free `mxbuild --serve` + standalone runtime, hot `reload_model` for behavioural changes and restart+DDL for structural ones (chosen from the serve build's `restartRequired`). Bundles the browser client (`web/dist/` via mxbuild's rollup runner, which the serve Deploy target skips) so Mendix 11.x apps render in a browser. `--watch` keeps an incremental rollup bundler hot (CHOKIDAR_USEPOLLING for container fs; ~3-4s page re-bundle, skipped for model-only edits) and watches only model source (`.mpr`+`mprcontents/`). `--ensure-db` provisions the local Postgres + app database if missing; `--setup` does the non-blocking prerequisites (cache mxbuild+runtime, ensure DB) and exits — `mxcli init` wires it into a Claude Code SessionStart hook so a fresh/reaped web session self-bootstraps, and `docs-site/src/tools/bootstrap-prompt.md` is the empty-repo seed prompt. `--screenshot` captures a Playwright PNG each change (pixel-perfect page loop), with `--screenshot-url` deep links (repeatable for multi-page sets, one PNG per page) and `--screenshot-user`/`--screenshot-password` form login (session saved as Playwright storage state, reused via `screenshot --load-storage`). See `docs/11-proposals/PROPOSAL_mxcli_dev_warm_loop.md`
- External browser preview (`mxcli run --hub <url>` + `mxcli tunnel-hub`): the app stays local and reverse-tunnels out over a single 443 connection (embedded chisel) to a static relay, so it is reachable in a browser at a public URL — works from egress-only environments (Claude Code web), verified live through the session's MITM egress proxy. `run --hub` implies `--local`, boots the runtime with `ApplicationRootUrl` set to the assigned URL (so the SPA/`originURI` work under the public origin), resolves the control proxy honouring `NO_PROXY`, and retries forever. `mxcli tunnel-hub --domain <base>` is the **multi-tenant** relay: a registry keyed by prefix/project/solution/branch/worktree (stable URLs on reconnect) fronts many previews at per-subdomain hosts (`[prefix-]project[-branch].<base>`; main collapses to the project) over one 443 with per-subdomain autocert, a registration API (`/api/register|status|deregister|backends`), and a sortable availability overview at `hub.<base>/`. Client identity flags: `--hub-prefix`/`--hub-project`/`--hub-solution`/`--hub-branch`/`--hub-worktree` (project + branch auto-detected). Package: `cmd/mxcli/tunnelhub/`. See `docs/11-proposals/PROPOSAL_mxcli_dev_warm_loop.md` (slices 3–4)
- OQL query execution against running runtime (`mxcli oql`)
- Business event services (SHOW/DESCRIBE/CREATE/DROP)
- Project settings (SHOW/DESCRIBE/ALTER)
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,14 @@ mxcli run --local -p app.mpr --watch --screenshot # hot-reload + auto screensh

`mxcli run --local` keeps `mxbuild --serve` and a standalone runtime hot: a page/microflow edit is hot-applied in seconds (a hot `reload_model`, or a restart + DDL for entity changes), and `--screenshot` captures each page with Playwright. See **[Local Dev Loop](https://mendixlabs.github.io/mxcli/tools/run-local.html)**.

Add `--hub` to preview the running app **in a browser at a public URL** — the app stays local and reverse-tunnels out over a single 443 connection, so it works even from egress-only environments (e.g. Claude Code on the web):

```bash
mxcli run --hub https://hub.example.com -p app.mpr # -> a shareable preview URL
```

`mxcli tunnel-hub --domain example.com` is the static relay you run once on a small VPS; it can front many previews at per-subdomain hosts across projects, solutions, branches, and worktrees, with a sortable availability overview at `hub.example.com/`. See **[Local Dev Loop → External browser preview](https://mendixlabs.github.io/mxcli/tools/run-local.html)**.

### Existing project

For an existing Mendix project, use `mxcli init` to add AI tooling and a Dev Container:
Expand Down
33 changes: 33 additions & 0 deletions cmd/mxcli/cmd_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,32 @@ Requirements:
already exist. Defaults: 127.0.0.1:5432, user 'mendix', db from the project
name. Override with --db-host/--db-name/--db-user/--db-password.

With --hub, the running app is exposed in a browser at a public URL through an
mxcli tunnel-hub, without leaving this machine: a chisel client reverse-tunnels
the local app out over 443, and the runtime boots with ApplicationRootUrl set to
the hub URL so the app works under that origin. --hub implies --local.

Examples:
mxcli run --local -p app.mpr
mxcli run --local -p app.mpr --watch
mxcli run --local -p app.mpr --app-port 8081 --db-name myapp
mxcli run --hub https://hub.example.com -p app.mpr # browser preview
mxcli run --hub https://hub.example.com --hub-secret u:pass -p app.mpr --watch
`,
Run: func(cmd *cobra.Command, args []string) {
local, _ := cmd.Flags().GetBool("local")
hub, _ := cmd.Flags().GetString("hub")
hubSecret, _ := cmd.Flags().GetString("hub-secret")
hubPrefix, _ := cmd.Flags().GetString("hub-prefix")
hubProject, _ := cmd.Flags().GetString("hub-project")
hubSolution, _ := cmd.Flags().GetString("hub-solution")
hubBranch, _ := cmd.Flags().GetString("hub-branch")
hubWorktree, _ := cmd.Flags().GetString("hub-worktree")
// --hub is a cross-cutting ingress and implies the local serving path (the
// only serving mode wired today; a future PAD path will accept --hub too).
if hub != "" {
local = true
}
if !local {
fmt.Fprintln(os.Stderr, "Error: only --local is supported for now (use 'mxcli docker run' for the container workflow)")
os.Exit(1)
Expand Down Expand Up @@ -67,6 +86,13 @@ Examples:

opts := docker.LocalRunOptions{
ProjectPath: projectPath,
Hub: hub,
HubSecret: hubSecret,
HubPrefix: hubPrefix,
HubProject: hubProject,
HubSolution: hubSolution,
HubBranch: hubBranch,
HubWorktree: hubWorktree,
AppPort: appPort,
AdminPort: adminPort,
ServePort: servePort,
Expand Down Expand Up @@ -97,6 +123,13 @@ Examples:

func init() {
runCmd.Flags().Bool("local", false, "Run locally without Docker (warm serve + standalone runtime)")
runCmd.Flags().String("hub", "", "Expose the running app in a browser via your own mxcli tunnel-hub URL (e.g. https://hub.example.com). Implies --local; the app stays local and is reverse-tunnelled out")
runCmd.Flags().String("hub-secret", "", "Shared auth secret for --hub (\"user:pass\"), matching the hub's --secret")
runCmd.Flags().String("hub-prefix", "", "Optional subdomain prefix on the hub (org/solution/team/env): <prefix>-<project>-<branch>")
runCmd.Flags().String("hub-project", "", "Project name for the hub subdomain + overview (default: the .mpr name)")
runCmd.Flags().String("hub-solution", "", "Solution name to group this app under in the hub overview (multi-app solutions)")
runCmd.Flags().String("hub-branch", "", "Branch for the hub subdomain + overview (default: the git branch)")
runCmd.Flags().String("hub-worktree", "", "Worktree label to distinguish multiple worktrees of one branch")
runCmd.Flags().Bool("watch", false, "Rebuild and hot-apply on every project change")
runCmd.Flags().Bool("ensure-db", false, "Provision the local Postgres + app database if missing (fresh-session bootstrap)")
runCmd.Flags().Bool("setup", false, "Prepare prerequisites (cache MxBuild+runtime, ensure DB) and exit without booting — for a SessionStart hook")
Expand Down
107 changes: 107 additions & 0 deletions cmd/mxcli/cmd_tunnelhub.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: Apache-2.0

package main

import (
"context"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"

"github.com/mendixlabs/mxcli/cmd/mxcli/tunnelhub"
"github.com/spf13/cobra"
)

// tunnelHubCmd runs the multi-tenant ingress relay. It fronts many locally-running
// Mendix apps — across projects, solutions, branches, and worktrees — each at its
// own <subdomain>.<domain> over a single 443 connection, with a registration API
// and an admin overview at the hub host. Apps stay in their own (possibly
// egress-only) environments and reverse-tunnel out; nothing is pushed here.
var tunnelHubCmd = &cobra.Command{
Use: "tunnel-hub",
Short: "Multi-tenant ingress relay: front many locally-running mxcli apps at per-preview subdomains",
Long: `Run the static ingress relay that exposes locally-running Mendix apps
(started elsewhere with 'mxcli run --hub <this-url>') in a browser.

Each app self-registers and is served at its own subdomain
(<project>-<branch>.<domain>, or <prefix>-<project>-<branch> with --hub-prefix);
the hub host (hub.<domain>) serves the registration API, the admin overview, and
the chisel control connection. Everything rides one 443 connection, so apps in
egress-only environments (e.g. Claude Code on the web) can reverse-tunnel out.

You run your own hub — there is no hosted service. Stand it up on a host you
control (a small VPS with a domain).

DNS: point a wildcard '*.<domain>' A record (and 'hub.<domain>') at this host.
TLS is issued per subdomain via Let's Encrypt on demand (needs inbound 80+443).

Security: set --secret and keep the hub to people you trust — this version uses a
single shared secret and open registration, so anyone with it can register a
preview (per-tenant auth is a follow-up). Don't expose it to the public.

Example (on your own VPS; *.example.com -> this host, ports 80+443 open):
mxcli tunnel-hub --domain example.com --secret alice:s3cret

Then, in each app's environment:
mxcli run --hub https://hub.example.com --hub-secret alice:s3cret \
--hub-solution CustomerPortal -p app.mpr
`,
Run: func(cmd *cobra.Command, args []string) {
domain, _ := cmd.Flags().GetString("domain")
hubHost, _ := cmd.Flags().GetString("hub-host")
secret, _ := cmd.Flags().GetString("secret")
httpsPort, _ := cmd.Flags().GetInt("port")
httpPort, _ := cmd.Flags().GetInt("http-port")
certCache, _ := cmd.Flags().GetString("cert-cache")

if domain == "" {
fmt.Fprintln(os.Stderr, "Error: --domain is required (the wildcard base, e.g. example.com)")
os.Exit(1)
}
if certCache == "" {
home, _ := os.UserHomeDir()
certCache = filepath.Join(home, ".mxcli", "hub-certs")
}

reg := tunnelhub.NewRegistry(tunnelhub.RegistryOptions{Domain: domain})
srv, err := tunnelhub.NewServer(tunnelhub.ServerOptions{
Domain: domain,
HubHost: hubHost,
Registry: reg,
TunnelAuth: secret,
RegisterSecret: secret,
CertCacheDir: certCache,
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error: configuring tunnel-hub: %v\n", err)
os.Exit(1)
}

host := hubHost
if host == "" {
host = "hub." + domain
}
fmt.Printf("mxcli tunnel-hub: serving *.%s (control/admin at https://%s) on :%d\n", domain, host, httpsPort)
fmt.Printf(" admin overview: https://%s/\n", host)

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

if err := srv.Start(ctx, fmt.Sprintf(":%d", httpsPort), fmt.Sprintf(":%d", httpPort)); err != nil {
fmt.Fprintf(os.Stderr, "Error: tunnel-hub: %v\n", err)
os.Exit(1)
}
},
}

func init() {
tunnelHubCmd.Flags().String("domain", "", "Wildcard base domain you control, e.g. example.com (previews served at <sub>.<domain>)")
tunnelHubCmd.Flags().String("hub-host", "", "Control/admin host (default hub.<domain>)")
tunnelHubCmd.Flags().String("secret", "", "Shared secret (\"user:pass\") apps present via --hub-secret; empty = open")
tunnelHubCmd.Flags().Int("port", 443, "HTTPS port to listen on")
tunnelHubCmd.Flags().Int("http-port", 80, "HTTP port for ACME challenges + http->https redirect")
tunnelHubCmd.Flags().String("cert-cache", "", "Directory for Let's Encrypt certificates (default ~/.mxcli/hub-certs)")
rootCmd.AddCommand(tunnelHubCmd)
}
Loading
Loading