MUL-5505: fix(selfhost): one source of truth for the backend host port (#6168)

* fix(selfhost): read the published host port back from Compose (MUL-5505)

`make selfhost` polled the wrong port when the backend host port was passed
through the environment. Make and Compose disagree on variable precedence: an
assignment in an included makefile (the Makefile `include`s .env) overrides a
real environment variable, while for Compose the environment overrides .env.
Because .env.example ships BACKEND_PORT uncommented, .env always pinned the
recipe's view of the port, so `BACKEND_PORT=9000 make selfhost` published the
stack on 9000 while the health check hammered 8080 for 60s and then reported
"Services are still starting" on a perfectly healthy install. The success
message printed the same wrong backend URL. FRONTEND_PORT had the same
inversion, affecting the printed frontend URL.

Stop re-deriving the host port in the recipe and ask Compose, which is the only
authority on what it published. The wait-and-report block (three port
references per target, duplicated across selfhost and selfhost-build) moves into
scripts/selfhost-wait.sh, which resolves both host ports via `docker compose
port` and falls back to the compose default chain when the stack is not up.

Also fix the input contract: `PORT` was the only port variable documented in
SELF_HOSTING_ADVANCED.md, yet compose read only BACKEND_PORT, so setting the
documented variable was silently ignored. The backend host port mapping now
falls back to `${PORT:-8080}`, matching Makefile and scripts/local-env.sh, and
the docs describe both variables as host ports.

Container-internal ports (8080/3000), the global PORT derivation used by local
dev, and the 127.0.0.1 bindings are unchanged; the default self-host experience
is byte-identical.

Fixes #6145

Co-authored-by: multica-agent <[email protected]>

* fix(selfhost): make PORT the real backend port and test the whole chain

Addresses review feedback on #6168.

The first round's root-cause claim was wrong. It said `BACKEND_PORT=9100 make
selfhost` published on 9100 while the health check probed 8080. It does not:
when make drives Compose, Compose inherits make's exported values, so both
sides agree on 8080 and the override is silently dropped instead. Re-measured
through the real recipe, the genuine disagreements were:

  - `.env` setting PORT with no BACKEND_PORT: probe followed PORT, Compose
    published ${BACKEND_PORT:-8080} — 9100 vs 8080.
  - `make selfhost PORT=8080` over a .env with BACKEND_PORT=9100: probe 8080,
    Compose published 9100.

Both are the "wrong port variable" defect the GitHub issue names, and reading
the port back from Compose fixes both. The comments and PR text that blamed
make/Compose precedence are corrected.

The PORT fallback added to docker-compose.selfhost.yml was also unreachable:
.env.example shipped BACKEND_PORT=8080 uncommented, so the fallback never
engaged and `SELF_HOSTING_AI.md`'s "edit PORT and FRONTEND_PORT" instruction did
nothing. Pick one contract and apply it everywhere: PORT is the backend port to
edit, BACKEND_PORT/API_PORT/SERVER_PORT are optional aliases that override it.
That is already what Makefile, scripts/local-env.sh, scripts/install.sh and
install.ps1 do; compose and the web dev fallback were the outliers.

  - .env.example ships BACKEND_PORT commented out, like its sibling aliases, so
    editing PORT works out of the box.
  - apps/web/config/runtime-urls.ts walks the same alias chain instead of
    reading BACKEND_PORT alone, so `pnpm dev` follows an edited PORT.
  - SELF_HOSTING_AI.md and SELF_HOSTING_ADVANCED.md state the contract.

Configuration that cannot take effect is now reported instead of ignored.
scripts/selfhost-preflight.sh warns when an alias in the env file overrides an
edited PORT, and when a port from the shell environment is overridden by the env
file. The Makefile captures the pristine origins before its include, because
afterwards there is no way to tell that `PORT=9000 make selfhost` was asked for.

Tests now cross environment -> make -> compose -> report for real. The docker
stub is no longer told what to publish: on `up` it asks the real
`docker compose config` to interpolate the compose file with the environment the
recipe actually handed it, records that, and answers `port` from the recording.
Verified failing on the old code — with the old recipe and compose file the
suite reports published 8080 against probe 9100, the exact defect.

Co-authored-by: multica-agent <[email protected]>

* ci: gate the self-host test on scripts/selfhost-preflight.sh too

The new preflight script was missing from the frontend path filter, so a
change to it alone would skip the test that covers it.

Co-authored-by: multica-agent <[email protected]>

* fix(selfhost): honour the full backend port alias chain in Compose

Addresses the second review round on #6168.

The contract this PR documents is

  BACKEND_PORT -> API_PORT -> SERVER_PORT -> PORT -> 8080

and Makefile, scripts/local-env.sh, scripts/install.sh, scripts/install.ps1 and
apps/web/config/runtime-urls.ts all implement it. docker-compose.selfhost.yml
implemented only BACKEND_PORT -> PORT -> 8080, so `API_PORT=9100` or
`SERVER_PORT=9200` in .env still published 8080.

That is not only a direct-Compose concern. scripts/install.sh:407 derives its
health-check port from the full chain and then runs this compose file, so an
alias it honours but Compose ignores puts the probe on 9100 while the stack
listens on 8080 — the original #6145 defect, reached through the installer.

  - docker-compose.selfhost.yml publishes the full nested chain, verified to
    keep the same precedence and the same 8080 default.
  - scripts/selfhost-wait.sh's fallback matches, so the degraded path agrees
    with what Compose would have published.
  - The Makefile captures the pristine origins of API_PORT and SERVER_PORT too,
    and the preflight reports them, so no alias can be silently overridden by
    the env file while the others are reported.

Tests pin the chain on the boundaries a recipe-level test cannot see, because
Makefile normalises all four aliases into PORT before any recipe runs: a matrix
over the direct Compose path asserts each alias moves the published port with
the right precedence, and every case additionally asserts that
scripts/install.sh's resolver returns the same value Compose published. That
invariant is the one that actually protects the installer.

Verified failing without the fix: with the two-level chain restored the suite
reports `expected 9200 / compose published 8080 / installer resolved 9200`, and
with the alias warnings narrowed it reports the missing API_PORT notice.

Co-authored-by: multica-agent <[email protected]>

* fix(selfhost): resolve one winner before reporting ignored port config

Addresses the third review round on #6168.

The preflight walked each alias independently, so it made a separate "wins"
claim per alias. With

  PORT=9000
  BACKEND_PORT=8000
  API_PORT=7000
  SERVER_PORT=6000

in .env it announced that BACKEND_PORT, API_PORT and SERVER_PORT each won, when
only BACKEND_PORT=8000 can. Two of the three notices were simply false.

It also compared only same-named shell and file variables, so shadowing across
aliases stayed silent: .env with PORT=8000 and BACKEND_PORT=8000 plus a shell
API_PORT=7000 produced no output at all, even though API_PORT was discarded.

Resolve the winner along the documented chain first — within a variable the env
file beats the environment, then BACKEND_PORT beats API_PORT beats SERVER_PORT
beats PORT — and report only the inputs that winner actually shadows. Output now
names one winning input and lists the rest, whichever variable or source they
came from. An input carrying the winning value is not reported: it is redundant
but the user still gets the port they asked for, so there is nothing to fix.

Tests cover both cases the old logic got wrong: every alias set at once must
yield exactly one winner claim and list the others as unused, and an env-file
alias beating a lower-priority shell alias must be reported. Also asserted that
a redundant same-value alias and a default configuration both stay silent.

Measured against the previous implementation, phrasing aside: with all four
values set it made 3 winner claims where there is now 1, and on the cross-alias
case it printed nothing where API_PORT is now reported.

Co-authored-by: multica-agent <[email protected]>

* fix(selfhost): track env-file existence so empty port assignments resolve right

Addresses the fourth review round on #6168.

The Makefile passed only values to the preflight, so the script inferred "the
env file does not set this" from an empty string. An explicit empty assignment is
a different input from an absent one: make lets `BACKEND_PORT=` in the env file
override `BACKEND_PORT=9000` from the environment, and the variable then drops out
of the alias chain instead of setting a port.

With .env holding PORT=8080 and BACKEND_PORT=, invoked as
`BACKEND_PORT=9000 make selfhost`, the stack published 8080 and the health check
probed 8080 — correct — while the preflight announced

  the backend host port resolves to 9000 from BACKEND_PORT (environment)
  Set but unused: PORT=8080 (.env)

naming the ignored value as the winner and the winning value as unused. A report
that contradicts the startup is worse than no report. FRONTEND_PORT= had the
matching hole: it shadowed the environment, Compose fell back to 3000, and the
preflight said nothing.

The Makefile now captures ENV_FILE_<VAR>_IS_SET from $(origin) alongside the
value, for all five port variables via one $(foreach) instead of hand-written
lines. The preflight treats a variable the file defines as taking the file's
value even when empty, so it shadows the environment, and an empty value never
wins — resolution continues down the chain and falls back to 8080. Inputs the
winner shadows are still listed, including ones shadowed by an empty assignment.
The frontend check mirrors this and now names the port that actually resolved.

Recipe-level tests cover an empty alias over a shell value, every link of the
chain emptied at once, and the frontend equivalent — each asserting the reported
winner, the Compose published port and the probed port all agree. Against the
previous implementation the first case fails with `reported: 9000 / actual: 8080`.

Co-authored-by: multica-agent <[email protected]>

* fix(selfhost): installers read the published port; drop the second parser

Addresses all four blockers from the consolidated review on #6168.

1. Both installers probed and printed the wrong port. scripts/install.sh:402 and
   scripts/install.ps1 start Compose inheriting the current environment, and
   Compose lets that environment outrank .env — but the installers then derived
   the probe port and the summary URL from .env alone. Measured on this branch
   before the fix, every port variable diverged:

     ambient PORT=9100        -> compose 9100, installer 8080
     ambient BACKEND_PORT=9200 -> compose 9200, installer 8080
     ambient API_PORT=9300     -> compose 9300, installer 8080
     ambient SERVER_PORT=9400  -> compose 9400, installer 8080
     ambient FRONTEND_PORT=3100 -> compose 3100, installer 3000

   Both now read the ports back once with `docker compose port` after `up -d`
   and reuse that single result for the health check and the summary. If the
   query fails they fail loudly instead of claiming success. The .env-derived
   helpers are deleted rather than left beside the new path.

2. The Make preflight is removed entirely, as the review recommended. It
   modelled `origin=environment` and `origin=file` but not `command line`, so
   `make selfhost BACKEND_PORT=9000` over a .env with API_PORT=7000 announced
   7000 while Compose published 9000. That was its fourth wrong report in four
   rounds, because it was a second parser of precedence rules — the very thing
   this PR removes elsewhere. `docker compose port` is the runtime truth, so the
   script, the Makefile captures and the macro are gone.

3. Tests now cover the installer paths that were unguarded. scripts/install.test.sh
   gains a `--with-server` matrix (defaults, .env PORT, all four backend aliases,
   FRONTEND_PORT, ambient overriding .env for all five, and explicit-empty
   fallback) plus a case proving an unresolvable port fails loudly. A new
   scripts/install.ps1.test.ps1 drives the same matrix through Start-LocalInstall.
   Every case asserts Compose's published port == the probed URL == the printed
   URL. Ambient variables are explicitly cleared per case so a runner's own PORT
   cannot leak. The Makefile matrix gains the command-line origin cases. CI runs
   the PowerShell suite on windows-latest in the always-on installer job, and the
   frontend filter now includes both installers.

4. Docs state the real contract: the alias order is shared, but which *source*
   wins is per entry point — Compose prefers the environment, make prefers the
   included env file, and a make command-line assignment outranks both. The
   removed preflight's promise is gone from SELF_HOSTING_AI.md, and the compose
   header no longer claims the installer derives its own health-check port.

Verified failing without the fixes: the Bash matrix reports
`[ambient PORT beats .env] compose published 9500 / probed 9100 / printed 9100`
against the old installer, and the PowerShell matrix reports
`printed backend=8080` against an injected summary divergence.

Co-authored-by: multica-agent <[email protected]>

* fix(selfhost): keep web dev proxy off frontend port

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Multica Eve
2026-07-31 14:46:13 +08:00
committed by GitHub
co-authored by multica-agent Eve
parent 51f44873cc
commit cac453fe01
14 changed files with 1210 additions and 148 deletions
+11 -4
View File
@@ -20,11 +20,18 @@ APP_ENV=
# If you need deterministic local automation, set a 6-digit value such as
# 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production.
MULTICA_DEV_VERIFICATION_CODE=
# Backend port. This is the one to edit: it is both the port the backend process
# listens on for a local/bare run, and the host port the Docker Compose
# self-host stack publishes. In Compose the container always listens on 8080
# internally, so changing this never needs a rebuild.
PORT=8080
# Docker Compose consumes flat port values. Set BACKEND_PORT directly to
# override the backend host port.
BACKEND_PORT=8080
# Optional aliases for local/self-host backend port helpers outside compose.
# Optional aliases that override PORT for the backend, in this order. Leave them
# commented unless you specifically need the host port to differ from the port
# the process listens on. An uncommented value here wins over PORT everywhere
# (Makefile, scripts/local-env.sh, docker-compose.selfhost.yml), and because
# this file is read by make it also overrides the same variable coming from your
# shell environment.
# BACKEND_PORT=8080
# API_PORT=8080
# SERVER_PORT=8080
FRONTEND_PORT=3000
+21 -5
View File
@@ -51,11 +51,18 @@ jobs:
- 'scripts/generate-reserved-slugs.mjs'
- 'server/internal/handler/reserved_slugs.json'
- 'scripts/selfhost-config.test.sh'
- 'scripts/selfhost-wait.sh'
# selfhost-config.test.sh asserts on the installers' side of
# the port contract, so a change to either must run it.
- 'scripts/install.sh'
- 'scripts/install.ps1'
- 'scripts/check.sh'
- 'scripts/dev.sh'
- 'scripts/local-env.sh'
- '.env.example'
- 'docker-compose.selfhost.yml'
- 'docker-compose.selfhost.build.yml'
- 'Makefile'
- name: Decide
id: decide
@@ -368,18 +375,27 @@ jobs:
run: go build ./cmd/multica
installer:
# Stub-driven shell tests for scripts/install.sh. Kept off the heavy
# backend job so installer regressions surface independently, and
# exercised on macOS too because the installer targets macOS/Homebrew
# and `tar` / `sed` / `mktemp` differ between BSD and GNU userlands.
# Stub-driven shell tests for scripts/install.sh and scripts/install.ps1.
# Kept off the heavy backend job so installer regressions surface
# independently, and exercised on macOS too because the installer targets
# macOS/Homebrew and `tar` / `sed` / `mktemp` differ between BSD and GNU
# userlands. Windows runs the PowerShell installer's own suite: the two
# installers share one port contract, and neither is covered by the
# frontend job.
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Test shell installers
if: runner.os != 'Windows'
run: bash scripts/install.test.sh
- name: Test PowerShell installer
if: runner.os == 'Windows'
shell: pwsh
run: ./scripts/install.ps1.test.ps1
+2 -54
View File
@@ -110,33 +110,7 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
fi
@echo "==> Starting Multica via Docker Compose..."
$(COMPOSE) -f docker-compose.selfhost.yml up -d
@echo "==> Waiting for backend to be ready..."
@for i in $$(seq 1 30); do \
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
break; \
fi; \
sleep 2; \
done
@if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
echo ""; \
echo "✓ Multica is running!"; \
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
echo " Backend: http://localhost:$${PORT:-8080}"; \
echo ""; \
echo "Images: $${MULTICA_BACKEND_IMAGE:-ghcr.io/multica-ai/multica-backend}:$${MULTICA_IMAGE_TAG:-latest}"; \
echo " $${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:$${MULTICA_IMAGE_TAG:-latest}"; \
echo ""; \
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
echo " or read the generated code from backend logs when Resend is unset."; \
echo ""; \
echo "Next — install the CLI and connect your machine:"; \
echo " brew install multica-ai/tap/multica"; \
echo " multica setup self-host"; \
else \
echo ""; \
echo "Services are still starting. Check logs:"; \
echo " $(COMPOSE) -f docker-compose.selfhost.yml logs"; \
fi
@bash scripts/selfhost-wait.sh official
selfhost-build: ## Build backend/web from the current checkout and start the self-hosted stack
$(REQUIRE_COMPOSE)
@@ -161,33 +135,7 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel
fi
@echo "==> Building Multica from the current checkout..."
$(COMPOSE) -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
@echo "==> Waiting for backend to be ready..."
@for i in $$(seq 1 30); do \
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
break; \
fi; \
sleep 2; \
done
@if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
echo ""; \
echo "✓ Multica is running!"; \
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
echo " Backend: http://localhost:$${PORT:-8080}"; \
echo ""; \
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
echo " or read the generated code from backend logs when Resend is unset."; \
echo ""; \
echo "Built images locally via docker-compose.selfhost.build.yml."; \
echo "Local tags: multica-backend:dev and multica-web:dev."; \
echo ""; \
echo "Next — install the CLI and connect your machine:"; \
echo " brew install multica-ai/tap/multica"; \
echo " multica setup self-host"; \
else \
echo ""; \
echo "Services are still starting. Check logs:"; \
echo " $(COMPOSE) -f docker-compose.selfhost.yml logs"; \
fi
@bash scripts/selfhost-wait.sh build
selfhost-stop: ## Stop the self-hosted Docker Compose stack
$(REQUIRE_COMPOSE)
+20 -2
View File
@@ -138,12 +138,30 @@ If the frontend and backend are served from different hostnames, `COOKIE_DOMAIN`
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8080` | Backend server port |
| `PORT` | `8080` | Backend port — the one to edit. It is the port the backend process listens on for a local/bare run, and the host port the Compose self-host stack publishes. In Compose the container always listens on `8080` internally, so changing this needs no rebuild. |
| `BACKEND_PORT` | Value of `PORT` | Optional alias that overrides `PORT` for the backend. `API_PORT` and `SERVER_PORT` are further aliases; the **alias order** is `BACKEND_PORT` → `API_PORT` → `SERVER_PORT` → `PORT` → `8080`, and it is the same in `Makefile`, `scripts/local-env.sh` and `docker-compose.selfhost.yml`. Leave them unset unless the host port must differ from the port the process listens on. |
| `METRICS_ADDR` | empty | Optional Prometheus metrics listener, for example `127.0.0.1:9090` |
| `FRONTEND_PORT` | `3000` | Frontend port |
| `FRONTEND_PORT` | `3000` | Frontend port. Host port in Compose; the container always listens on `3000` internally. |
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins. Governs **both** the HTTP CORS allowlist **and** the WebSocket `Origin` check. A browser origin that isn't listed here (and isn't `localhost`) has its real-time WebSocket upgrade rejected with `403`, so live updates stop working until a manual refresh. |
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
> **Which source wins depends on the entry point**, and only the alias order above
> is shared. Docker Compose lets the calling environment outrank `.env`
> (`PORT=9100 docker compose … up -d` publishes 9100). `make` is the other way
> round: it `include`s the env file, so a value there outranks the same variable
> from your environment, and a `make selfhost PORT=…` command-line assignment
> outranks both. Editing the env file is therefore the one method that behaves
> the same everywhere. None of this affects whether the reported port is correct:
> `make selfhost` and both installers read the published port back from
> `docker compose port`, so the health check and the printed URL always match
> what Compose actually published.
The web development server is one intentional exception to the generic `PORT`
fallback: Next uses `PORT` for its own frontend listener before it evaluates the
rewrite configuration. Its backend fallback therefore accepts
`BACKEND_PORT` → `API_PORT` → `SERVER_PORT` → `8080`, while an explicit
`REMOTE_API_URL` or `NEXT_PUBLIC_API_URL` still takes priority.
### CLI / Daemon
These are configured on each user's machine, not on the server:
+13 -1
View File
@@ -64,10 +64,22 @@ make selfhost-stop
If the default ports (8080/3000) are in use:
1. Edit `.env` and change `PORT` and `FRONTEND_PORT`
1. Edit `.env` and change `PORT` and `FRONTEND_PORT`. These are host ports; the
containers keep listening on 8080/3000 internally, so no rebuild is needed.
2. Run `make selfhost`
3. Run `multica setup self-host --port <PORT> --frontend-port <FRONTEND_PORT>`
Edit the file rather than relying on environment variables: `make` `include`s
`.env`, so a value in the file outranks the same variable from your shell
(`PORT=9100 make selfhost` is ignored when `.env` sets `PORT`). A command-line
assignment does take effect (`make selfhost PORT=9100`), and Docker Compose
invoked directly is the reverse again — there the environment outranks `.env`.
`BACKEND_PORT` is an optional alias that overrides `PORT` for the backend;
`API_PORT` and `SERVER_PORT` follow in that order. Whichever route you use, the
startup output is read back from Docker Compose, so the address it prints is the
one the stack is actually published on.
## Troubleshooting
- **Backend not ready:** `docker compose -f docker-compose.selfhost.yml logs backend`
+15
View File
@@ -197,6 +197,21 @@ describe("dev-only fallbacks", () => {
);
});
it("ignores the frontend process PORT while honoring backend-specific aliases", () => {
expect(resolveDevRemoteApiUrl({ PORT: "3000" })).toBe(
"http://localhost:8080",
);
expect(resolveDevRemoteApiUrl({ API_PORT: "19082", PORT: "3000" })).toBe(
"http://localhost:19082",
);
expect(
resolveDevRemoteApiUrl({ SERVER_PORT: "19083", PORT: "3000" }),
).toBe("http://localhost:19083");
expect(
resolveDevRemoteApiUrl({ BACKEND_PORT: "19080", PORT: "3000" }),
).toBe("http://localhost:19080");
});
it("prefers configured origins over the dev fallbacks", () => {
expect(
resolveDevRemoteApiUrl({ REMOTE_API_URL: "http://backend:8080" }),
+9 -1
View File
@@ -44,7 +44,15 @@ export function resolveDocsUrl(env: RuntimeEnv): string | undefined {
export function resolveDevRemoteApiUrl(env: RuntimeEnv): string {
const configured = resolveRemoteApiUrl(env);
if (configured) return configured;
const backendPort = env.BACKEND_PORT?.trim() || "8080";
// Next writes process.env.PORT with the frontend listener port before it
// evaluates next.config.ts. Treating that generic variable as a backend port
// would make every dev rewrite point back to the frontend itself. Only the
// backend-specific aliases are safe fallbacks in this process.
const backendPort =
env.BACKEND_PORT?.trim() ||
env.API_PORT?.trim() ||
env.SERVER_PORT?.trim() ||
"8080";
return `http://localhost:${backendPort}`;
}
+16 -2
View File
@@ -14,7 +14,21 @@
# docker compose -f docker-compose.selfhost.yml up -d
#
# Frontend: http://localhost:${FRONTEND_PORT:-3000}
# Backend: http://localhost:${BACKEND_PORT:-8080}
# Backend: http://localhost:${BACKEND_PORT:-${API_PORT:-${SERVER_PORT:-${PORT:-8080}}}}
#
# The published values above are HOST ports; the containers always listen on
# 8080 / 3000 internally, so changing them never needs a rebuild. PORT is the
# variable to edit; BACKEND_PORT, API_PORT and SERVER_PORT are optional aliases
# that override it in that order. Keep this alias order identical to Makefile
# and scripts/local-env.sh. The web dev fallback intentionally omits PORT
# because Next uses that variable for its own frontend listener.
#
# Note that *which source* wins differs per entry point — Compose lets the
# calling environment outrank this file, while make lets the included env file
# outrank the environment. Nothing re-derives the published port from the inputs
# any more: `make selfhost` (via scripts/selfhost-wait.sh) and both installers
# read it back with `docker compose port`, so the health check and the printed
# URL always match what was actually published.
name: multica
@@ -44,7 +58,7 @@ services:
postgres:
condition: service_healthy
ports:
- "127.0.0.1:${BACKEND_PORT:-8080}:8080"
- "127.0.0.1:${BACKEND_PORT:-${API_PORT:-${SERVER_PORT:-${PORT:-8080}}}}:8080"
volumes:
- backend_uploads:/app/data/uploads
environment:
+44 -35
View File
@@ -17,6 +17,11 @@ $RepoWebUrl = "https://github.com/multica-ai/multica"
$DefaultInstallDir = Join-Path $env:USERPROFILE ".multica\server"
$InstallDir = if ($env:MULTICA_INSTALL_DIR) { $env:MULTICA_INSTALL_DIR } else { $DefaultInstallDir }
# Host ports Compose reported after `up -d`; set by Setup-Server and reused by
# the summary so the health check and the printed URLs cannot diverge.
$script:SelfHostBackendPort = $null
$script:SelfHostFrontendPort = $null
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -43,44 +48,40 @@ function New-RandomHex {
return -join ($bytes | ForEach-Object { "{0:x2}" -f $_ })
}
function Get-EnvFileValue {
# Host port Docker Compose actually published for a service.
#
# This is the only authority. Compose's interpolation gives the calling process
# environment precedence over .env, so an ambient PORT / BACKEND_PORT / API_PORT
# / SERVER_PORT / FRONTEND_PORT moves the published port without touching the
# file. Re-deriving the port from .env alone made the installer probe and print
# a port the stack was never published on (#6145). Must be called from the
# installation directory, after `up -d`.
function Get-ComposePublishedPort {
param(
[string]$Path,
[string]$Name,
[string]$Default
[Parameter(Mandatory = $true)][string]$Service,
[Parameter(Mandatory = $true)][int]$ContainerPort
)
if (-not (Test-Path $Path)) {
return $Default
$output = $null
try {
$output = docker compose -f docker-compose.selfhost.yml port $Service $ContainerPort 2>$null
} catch {
return $null
}
if ($LASTEXITCODE -ne 0) {
return $null
}
$prefix = "$Name="
$line = Get-Content $Path |
Where-Object { $_.StartsWith($prefix) } |
Select-Object -Last 1
$line = @($output | Where-Object { $_ }) | Select-Object -Last 1
if (-not $line) {
return $Default
return $null
}
$value = $line.Substring($prefix.Length).Trim().Trim('"').Trim("'")
if ([string]::IsNullOrWhiteSpace($value)) {
return $Default
$published = ($line -split ":")[-1].Trim()
if ($published -notmatch '^[0-9]+$') {
return $null
}
return $value
}
function Get-SelfHostBackendPort {
foreach ($name in @("BACKEND_PORT", "API_PORT", "SERVER_PORT", "PORT")) {
$value = Get-EnvFileValue -Path (Join-Path $InstallDir ".env") -Name $name -Default ""
if (-not [string]::IsNullOrWhiteSpace($value)) {
return $value
}
}
return "8080"
}
function Get-SelfHostFrontendPort {
return Get-EnvFileValue -Path (Join-Path $InstallDir ".env") -Name "FRONTEND_PORT" -Default "3000"
return $published
}
function Get-LatestVersion {
@@ -443,12 +444,22 @@ function Install-Server {
Write-Info "Starting Multica services (this may take a few minutes on first run)..."
docker compose -f docker-compose.selfhost.yml up -d
# Read the ports Compose actually published, once, and reuse them for both
# the health check and the summary so the two can never disagree.
$script:SelfHostBackendPort = Get-ComposePublishedPort -Service "backend" -ContainerPort 8080
if (-not $script:SelfHostBackendPort) {
Write-Fail "Started the stack but could not read the backend host port from Docker Compose.`n Check it with: cd $InstallDir; docker compose -f docker-compose.selfhost.yml ps"
}
$script:SelfHostFrontendPort = Get-ComposePublishedPort -Service "frontend" -ContainerPort 3000
if (-not $script:SelfHostFrontendPort) {
Write-Fail "Started the stack but could not read the frontend host port from Docker Compose.`n Check it with: cd $InstallDir; docker compose -f docker-compose.selfhost.yml ps"
}
Write-Info "Waiting for backend to be ready..."
$backendPort = Get-SelfHostBackendPort
$ready = $false
for ($i = 1; $i -le 45; $i++) {
try {
$null = Invoke-WebRequest -Uri "http://localhost:$backendPort/health" -UseBasicParsing -TimeoutSec 2
$null = Invoke-WebRequest -Uri "http://localhost:$($script:SelfHostBackendPort)/health" -UseBasicParsing -TimeoutSec 2
$ready = $true
break
} catch {
@@ -510,10 +521,8 @@ function Start-LocalInstall {
Write-Host " [OK] Multica server is running and CLI is ready!" -ForegroundColor Green
Write-Host " ============================================" -ForegroundColor Green
Write-Host ""
$frontendPort = Get-SelfHostFrontendPort
$backendPort = Get-SelfHostBackendPort
Write-Host " Frontend: http://localhost:$frontendPort"
Write-Host " Backend: http://localhost:$backendPort"
Write-Host " Frontend: http://localhost:$($script:SelfHostFrontendPort)"
Write-Host " Backend: http://localhost:$($script:SelfHostBackendPort)"
Write-Host " Server at: $InstallDir"
Write-Host ""
Write-Host " Next: configure your CLI to connect"
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env pwsh
# Port-contract tests for scripts/install.ps1 — the PowerShell counterpart of the
# `--with-server` cases in scripts/install.test.sh.
#
# The installer used to derive the backend/frontend host port from .env with its
# own copy of the alias chain. Docker Compose gives the calling process
# environment precedence over .env, so any ambient PORT / BACKEND_PORT /
# API_PORT / SERVER_PORT / FRONTEND_PORT moved the published port while the
# installer kept probing and printing the file value (#6145).
#
# Here the docker stub plays Compose: it answers `port` from the same resolution
# Compose performs — environment first, then .env. The installer must take that
# answer as given for both the health check and the summary, which a .env-only
# derivation cannot do because the two disagree in every ambient case below.
# That real Compose resolves this way is proven against real `docker compose
# config` in scripts/selfhost-config.test.sh.
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$RepoRoot = Split-Path (Split-Path $PSCommandPath -Parent) -Parent
$InstallerPath = Join-Path $RepoRoot "scripts/install.ps1"
# install.ps1 resolves its default install directory from USERPROFILE at load
# time. Provide one so these tests also run on a non-Windows agent.
if (-not $env:USERPROFILE) { $env:USERPROFILE = [System.IO.Path]::GetTempPath() }
function Fail-Test {
param([string]$Message)
Write-Host "FAIL: $Message" -ForegroundColor Red
exit 1
}
# install.ps1 dispatches at the bottom on import, so load only the definitions.
function Get-InstallerDefinitions {
$source = Get-Content -Raw -Path $InstallerPath
$marker = "# Entry point"
$index = $source.IndexOf($marker)
if ($index -lt 0) {
Fail-Test "could not find the entry-point marker in install.ps1"
}
# Trim back to the comment banner that precedes the marker.
$banner = $source.LastIndexOf("# ---", $index)
if ($banner -ge 0) { $index = $banner }
return $source.Substring(0, $index)
}
# ---------------------------------------------------------------------------
# 1. install.ps1 must parse, and must no longer derive ports from .env
# ---------------------------------------------------------------------------
$parseErrors = $null
[System.Management.Automation.Language.Parser]::ParseFile($InstallerPath, [ref]$null, [ref]$parseErrors) | Out-Null
if ($parseErrors) {
$parseErrors | ForEach-Object { Write-Host " $($_.Message) (line $($_.Extent.StartLineNumber))" }
Fail-Test "install.ps1 has parse errors"
}
$installerSource = Get-Content -Raw -Path $InstallerPath
foreach ($banned in @("Get-SelfHostBackendPort", "Get-SelfHostFrontendPort", "Get-EnvFileValue")) {
if ($installerSource -match [regex]::Escape($banned)) {
Fail-Test "install.ps1 must not re-derive host ports from .env (found $banned)"
}
}
foreach ($required in @(
'Get-ComposePublishedPort -Service "backend" -ContainerPort 8080',
'Get-ComposePublishedPort -Service "frontend" -ContainerPort 3000',
'http://localhost:$($script:SelfHostBackendPort)/health')) {
if ($installerSource -notmatch [regex]::Escape($required)) {
Fail-Test "install.ps1 is missing the Compose-published-port wiring: $required"
}
}
# ---------------------------------------------------------------------------
# 2. Get-ComposePublishedPort parses what `docker compose port` returns
# ---------------------------------------------------------------------------
$definitions = Get-InstallerDefinitions
$portReaderCases = @(
@{ Label = "loopback binding"; Output = @("127.0.0.1:9500"); Expected = "9500" }
@{ Label = "wildcard binding"; Output = @("0.0.0.0:9600"); Expected = "9600" }
@{ Label = "trailing blank line"; Output = @("127.0.0.1:9700", ""); Expected = "9700" }
@{ Label = "no output"; Output = @(); Expected = $null }
@{ Label = "non-numeric"; Output = @("127.0.0.1:notaport"); Expected = $null }
)
foreach ($case in $portReaderCases) {
$script:stubOutput = $case.Output
$result = & {
Invoke-Expression $definitions
function docker {
$global:LASTEXITCODE = 0
return $script:stubOutput
}
Get-ComposePublishedPort -Service "backend" -ContainerPort 8080
}
if ($result -ne $case.Expected) {
Fail-Test "Get-ComposePublishedPort [$($case.Label)]: expected '$($case.Expected)' got '$result'"
}
}
# A failing `docker compose port` must produce no port, not a guess.
$failureResult = & {
Invoke-Expression $definitions
function docker {
$global:LASTEXITCODE = 1
return @()
}
Get-ComposePublishedPort -Service "backend" -ContainerPort 8080
}
if ($null -ne $failureResult) {
Fail-Test "Get-ComposePublishedPort must return null when docker compose port fails, got '$failureResult'"
}
# ---------------------------------------------------------------------------
# 3. End to end: the probed URL and the printed URLs are the published ports
# ---------------------------------------------------------------------------
$cases = @(
@{ Label = "defaults"; Env = @{}; Mutation = $null; Backend = "8080"; Frontend = "3000" }
@{ Label = "env-file PORT"; Env = @{}; Mutation = @{ PORT = "9100" }; Backend = "9100"; Frontend = "3000" }
@{ Label = "env-file BACKEND_PORT"; Env = @{}; Mutation = @{ BACKEND_PORT = "9200" }; Backend = "9200"; Frontend = "3000" }
@{ Label = "env-file API_PORT"; Env = @{}; Mutation = @{ API_PORT = "9300" }; Backend = "9300"; Frontend = "3000" }
@{ Label = "env-file SERVER_PORT"; Env = @{}; Mutation = @{ SERVER_PORT = "9400" }; Backend = "9400"; Frontend = "3000" }
@{ Label = "env-file FRONTEND_PORT"; Env = @{}; Mutation = @{ FRONTEND_PORT = "3100" }; Backend = "8080"; Frontend = "3100" }
@{ Label = "ambient PORT beats .env"; Env = @{ PORT = "9500" }; Mutation = @{ PORT = "9100" }; Backend = "9500"; Frontend = "3000" }
@{ Label = "ambient BACKEND_PORT beats .env"; Env = @{ BACKEND_PORT = "9600" }; Mutation = @{ PORT = "9100" }; Backend = "9600"; Frontend = "3000" }
@{ Label = "ambient API_PORT beats .env"; Env = @{ API_PORT = "9700" }; Mutation = @{ PORT = "9100" }; Backend = "9700"; Frontend = "3000" }
@{ Label = "ambient SERVER_PORT beats .env"; Env = @{ SERVER_PORT = "9800" }; Mutation = @{ PORT = "9100" }; Backend = "9800"; Frontend = "3000" }
@{ Label = "ambient FRONTEND_PORT beats .env"; Env = @{ FRONTEND_PORT = "3200" }; Mutation = @{ FRONTEND_PORT = "3100" }; Backend = "8080"; Frontend = "3200" }
# An explicitly empty higher-priority alias contributes no port, so the chain
# continues. Expressed through .env because whether an *ambient* empty value
# survives into a child process is platform-dependent; the Bash suite covers
# the ambient-empty case on POSIX.
@{ Label = "env-file empty BACKEND_PORT falls back"; Env = @{}; Mutation = @{ BACKEND_PORT = ""; PORT = "9100" }; Backend = "9100"; Frontend = "3000" }
)
$runnerScript = Join-Path ([System.IO.Path]::GetTempPath()) "multica-install-ps1-case.ps1"
# Each case runs in its own pwsh process: install.ps1 uses `exit` on failure, and
# a child process is also the only way to control the ambient environment
# cleanly rather than inheriting a CI runner's PORT.
@'
param(
[string]$InstallerPath,
[string]$WorkDir,
[string]$ProbeLog
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
if (-not $env:USERPROFILE) { $env:USERPROFILE = [System.IO.Path]::GetTempPath() }
$env:MULTICA_INSTALL_DIR = $WorkDir
$env:MULTICA_SELFHOST_REF = "main"
$source = Get-Content -Raw -Path $InstallerPath
$index = $source.IndexOf("# Entry point")
$banner = $source.LastIndexOf("# ---", $index)
if ($banner -ge 0) { $index = $banner }
Invoke-Expression $source.Substring(0, $index)
function Get-EnvFilePortValue {
param([string]$Key)
$line = Get-Content (Join-Path $WorkDir ".env") |
Where-Object { $_ -match "^$Key=" } |
Select-Object -Last 1
if (-not $line) { return $null }
return $line.Substring($Key.Length + 1)
}
# Compose resolution: the process environment wins over .env.
function Resolve-ComposeValue {
param([string]$Key)
$fromEnv = [System.Environment]::GetEnvironmentVariable($Key)
if ($null -ne $fromEnv) { return $fromEnv }
return Get-EnvFilePortValue -Key $Key
}
function Get-StubBackendPort {
foreach ($key in @("BACKEND_PORT", "API_PORT", "SERVER_PORT", "PORT")) {
$value = Resolve-ComposeValue -Key $key
if (-not [string]::IsNullOrEmpty($value)) { return $value }
}
return "8080"
}
function Get-StubFrontendPort {
$value = Resolve-ComposeValue -Key "FRONTEND_PORT"
if (-not [string]::IsNullOrEmpty($value)) { return $value }
return "3000"
}
# Stand in for the external commands the installer shells out to.
function docker {
$global:LASTEXITCODE = 0
$args_ = @($args)
if ($args_.Count -ge 1 -and $args_[0] -eq "info") { return }
if ($args_.Count -ge 1 -and $args_[0] -eq "compose") {
if ($args_ -contains "port") {
if ($args_ -contains "backend") { return "127.0.0.1:$(Get-StubBackendPort)" }
if ($args_ -contains "frontend") { return "127.0.0.1:$(Get-StubFrontendPort)" }
$global:LASTEXITCODE = 1
return
}
if ($args_ -contains "version") { return "2.30.0" }
}
return
}
function git { $global:LASTEXITCODE = 0 }
# Record every probed URL instead of making a request.
function Invoke-WebRequest {
param(
[Parameter(Position = 0)][string]$Uri,
[switch]$UseBasicParsing,
[int]$TimeoutSec
)
Add-Content -Path $ProbeLog -Value $Uri
return [pscustomobject]@{ StatusCode = 200 }
}
# The CLI install itself is out of scope for the port contract.
function Install-Cli { }
function Test-Docker { }
Start-LocalInstall
'@ | Set-Content -Path $runnerScript -Encoding UTF8
foreach ($case in $cases) {
$workDir = Join-Path ([System.IO.Path]::GetTempPath()) ("multica-ps1-" + [guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Path (Join-Path $workDir ".git") -Force | Out-Null
$envLines = @(
"PORT=8080"
"# BACKEND_PORT=8080"
"# API_PORT=8080"
"# SERVER_PORT=8080"
"FRONTEND_PORT=3000"
"JWT_SECRET=change-me-in-production"
"POSTGRES_PASSWORD=multica"
)
if ($case.Mutation) {
foreach ($key in $case.Mutation.Keys) {
$value = $case.Mutation[$key]
$envLines = $envLines | ForEach-Object {
if ($_ -match "^#?\s*$key=") { "$key=$value" } else { $_ }
}
}
}
$envLines | Set-Content -Path (Join-Path $workDir ".env") -Encoding UTF8
Copy-Item (Join-Path $workDir ".env") (Join-Path $workDir ".env.example")
New-Item -ItemType File -Path (Join-Path $workDir "docker-compose.selfhost.yml") -Force | Out-Null
$probeLog = Join-Path $workDir "probes.log"
New-Item -ItemType File -Path $probeLog -Force | Out-Null
# Control the ambient environment explicitly so a CI runner's own PORT
# cannot leak into the matrix. Absent must mean absent: an empty string is a
# different input, and the chain treats it as "set but no value".
$portVars = @("PORT", "BACKEND_PORT", "API_PORT", "SERVER_PORT", "FRONTEND_PORT")
$previous = @{}
foreach ($key in $portVars) {
$previous[$key] = [System.Environment]::GetEnvironmentVariable($key)
if ($case.Env.ContainsKey($key)) {
Set-Item -Path "Env:$key" -Value $case.Env[$key]
} else {
Remove-Item -Path "Env:$key" -ErrorAction SilentlyContinue
}
}
try {
$output = & pwsh -NoProfile -File $runnerScript -InstallerPath $InstallerPath -WorkDir $workDir -ProbeLog $probeLog 2>&1
$exitCode = $LASTEXITCODE
} finally {
foreach ($key in $portVars) {
if ($null -eq $previous[$key]) {
Remove-Item -Path "Env:$key" -ErrorAction SilentlyContinue
} else {
Set-Item -Path "Env:$key" -Value $previous[$key]
}
}
}
$rendered = ($output | Out-String)
if ($exitCode -ne 0) {
Write-Host $rendered
Fail-Test "[$($case.Label)] install.ps1 exited $exitCode"
}
$probed = (Get-Content $probeLog | Select-Object -First 1)
$probedPort = if ($probed -match "localhost:(\d+)/health") { $Matches[1] } else { $null }
$printedBackend = if ($rendered -match "Backend:\s+http://localhost:(\d+)") { $Matches[1] } else { $null }
$printedFrontend = if ($rendered -match "Frontend:\s+http://localhost:(\d+)") { $Matches[1] } else { $null }
if ($probedPort -ne $case.Backend -or $printedBackend -ne $case.Backend -or $printedFrontend -ne $case.Frontend) {
Write-Host $rendered
Write-Host " compose published: backend=$($case.Backend) frontend=$($case.Frontend)"
Write-Host " health check probed: $probedPort"
Write-Host " printed: backend=$printedBackend frontend=$printedFrontend"
Fail-Test "[$($case.Label)] installer ports disagree with the port Compose published"
}
Remove-Item $workDir -Recurse -Force -ErrorAction SilentlyContinue
}
Remove-Item $runnerScript -Force -ErrorAction SilentlyContinue
Write-Host "install.ps1 tests passed" -ForegroundColor Green
+37 -44
View File
@@ -19,6 +19,11 @@ REPO_WEB_URL="https://github.com/multica-ai/multica" # without .git, for GitHub
INSTALL_DIR="${MULTICA_INSTALL_DIR:-$HOME/.multica/server}"
BREW_PACKAGE="multica-ai/tap/multica"
# Host ports Compose reported after `up -d`; set by setup_server and reused by
# the summary so the health check and the printed URLs cannot diverge.
SELFHOST_BACKEND_PORT=""
SELFHOST_FRONTEND_PORT=""
# Colors (disabled when not a terminal)
if [ -t 1 ] || [ -t 2 ]; then
BOLD='\033[1m'
@@ -60,44 +65,26 @@ print_remote_server_token_hint() {
printf "\n"
}
env_file_value() {
local file="$1"
local key="$2"
local default="$3"
local line value
line="$(grep -E "^${key}=" "$file" 2>/dev/null | tail -n 1 || true)"
if [ -z "$line" ]; then
printf "%s" "$default"
return
fi
value="${line#*=}"
value="${value%$'\r'}"
value="${value%\"}"
value="${value#\"}"
value="${value%\'}"
value="${value#\'}"
if [ -z "$value" ]; then
printf "%s" "$default"
else
printf "%s" "$value"
fi
}
# Host port Docker Compose actually published for a service.
#
# This is the only authority. Compose's interpolation gives the calling process
# environment precedence over .env, so an ambient PORT / BACKEND_PORT / API_PORT
# / SERVER_PORT / FRONTEND_PORT moves the published port without touching the
# file. Re-deriving the port from .env alone made the installer probe and print
# a port the stack was never published on (#6145). Must be called from the
# installation directory, after `up -d`.
compose_published_port() {
local service=$1 container_port=$2 published
selfhost_backend_port() {
local file="${1:-.env}"
local value
for key in BACKEND_PORT API_PORT SERVER_PORT PORT; do
value="$(env_file_value "$file" "$key" "")"
if [ -n "$value" ]; then
printf "%s" "$value"
return
fi
done
printf "8080"
}
published="$(docker compose -f docker-compose.selfhost.yml port "$service" "$container_port" 2>/dev/null | tail -n 1)"
published="${published##*:}"
published="${published%$'\r'}"
selfhost_frontend_port() {
env_file_value "${1:-.env}" "FRONTEND_PORT" "3000"
case "$published" in
"" | *[!0-9]*) return 1 ;;
esac
printf "%s" "$published"
}
detect_os() {
@@ -401,13 +388,22 @@ setup_server() {
info "Starting Multica services (this may take a few minutes on first run)..."
docker compose -f docker-compose.selfhost.yml up -d
# Read the ports Compose actually published, once, and reuse them for both the
# health check and the summary so the two can never disagree.
if ! SELFHOST_BACKEND_PORT="$(compose_published_port backend 8080)"; then
fail "Started the stack but could not read the backend host port from Docker Compose.
Check it with: cd $INSTALL_DIR && docker compose -f docker-compose.selfhost.yml ps"
fi
if ! SELFHOST_FRONTEND_PORT="$(compose_published_port frontend 3000)"; then
fail "Started the stack but could not read the frontend host port from Docker Compose.
Check it with: cd $INSTALL_DIR && docker compose -f docker-compose.selfhost.yml ps"
fi
# Wait for health check
info "Waiting for backend to be ready..."
local backend_port
backend_port="$(selfhost_backend_port .env)"
local ready=false
for i in $(seq 1 45); do
if curl -sf "http://localhost:${backend_port}/health" >/dev/null 2>&1; then
if curl -sf "http://localhost:${SELFHOST_BACKEND_PORT}/health" >/dev/null 2>&1; then
ready=true
break
fi
@@ -470,11 +466,8 @@ run_with_server() {
printf "${BOLD}${GREEN} ✓ Multica server is running and CLI is ready!${RESET}\n"
printf "${BOLD}${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}\n"
printf "\n"
local frontend_port backend_port
frontend_port="$(selfhost_frontend_port "$INSTALL_DIR/.env")"
backend_port="$(selfhost_backend_port "$INSTALL_DIR/.env")"
printf " ${BOLD}Frontend:${RESET} http://localhost:%s\n" "$frontend_port"
printf " ${BOLD}Backend:${RESET} http://localhost:%s\n" "$backend_port"
printf " ${BOLD}Frontend:${RESET} http://localhost:%s\n" "$SELFHOST_FRONTEND_PORT"
printf " ${BOLD}Backend:${RESET} http://localhost:%s\n" "$SELFHOST_BACKEND_PORT"
printf " ${BOLD}Server at:${RESET} %s\n" "$INSTALL_DIR"
printf "\n"
printf " ${BOLD}Next: configure your CLI to connect${RESET}\n"
+275
View File
@@ -236,8 +236,283 @@ STUB
fi
}
# ---------------------------------------------------------------------------
# --with-server: the probed port and the printed port must both be the port
# Docker Compose reported (#6145)
#
# The installer used to derive the port from .env with its own copy of the alias
# chain. Compose gives the *calling environment* precedence over .env, so any
# ambient PORT / BACKEND_PORT / API_PORT / SERVER_PORT / FRONTEND_PORT moved the
# published port while the installer kept probing and printing the file value.
#
# Here the docker stub plays Compose: it answers `port` from the same resolution
# Compose performs, environment first, then .env. The installer must take that
# answer as given for both the health check and the summary — which is exactly
# what a .env-only derivation cannot do, because the two disagree in every case
# below. That real Compose resolves this way is proven separately, against real
# `docker compose config`, in scripts/selfhost-config.test.sh; that test needs a
# Docker CLI, which this job deliberately does not require.
# ---------------------------------------------------------------------------
_setup_server_sandbox() {
local tmp="$1"
local stub_bin="$tmp/stub-bin"
local server_dir="$tmp/server"
mkdir -p "$stub_bin" "$server_dir/.git"
# Minimal self-host assets: only the port mapping matters here.
cat >"$server_dir/.env.example" <<'ENVFILE'
PORT=8080
# BACKEND_PORT=8080
# API_PORT=8080
# SERVER_PORT=8080
FRONTEND_PORT=3000
JWT_SECRET=change-me-in-production
POSTGRES_PASSWORD=multica
DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
ENVFILE
touch "$server_dir/docker-compose.selfhost.yml"
# Compose stand-in. Resolves the published host port the way Compose does:
# the process environment wins over .env, then the alias chain decides.
cat >"$stub_bin/docker" <<'STUB'
#!/usr/bin/env bash
set -uo pipefail
_env_file_value() {
local key="$1" line
line="$(grep -E "^${key}=" .env 2>/dev/null | tail -n 1 || true)"
[ -n "$line" ] || return 1
printf '%s' "${line#*=}"
}
# Environment first (Compose interpolation), then the env file.
_resolve() {
local key="$1" from_env
eval "from_env=\${$key-__unset__}"
if [ "$from_env" != "__unset__" ]; then
printf '%s' "$from_env"
return 0
fi
_env_file_value "$key"
}
_published_backend_port() {
local value
for key in BACKEND_PORT API_PORT SERVER_PORT PORT; do
if value="$(_resolve "$key")" && [ -n "$value" ]; then
printf '%s' "$value"
return
fi
done
printf '8080'
}
_published_frontend_port() {
local value
if value="$(_resolve FRONTEND_PORT)" && [ -n "$value" ]; then
printf '%s' "$value"
return
fi
printf '3000'
}
case "${1:-}" in
info) exit 0 ;;
compose)
shift
subcommand=""
for arg in "$@"; do
case "$arg" in
pull | up | port | version | ps | logs | down) subcommand="$arg"; break ;;
esac
done
case "$subcommand" in
port)
service=""
for arg in "$@"; do
case "$arg" in
backend | frontend) service="$arg"; break ;;
esac
done
case "$service" in
backend) printf '127.0.0.1:%s\n' "$(_published_backend_port)" ;;
frontend) printf '127.0.0.1:%s\n' "$(_published_frontend_port)" ;;
*) exit 1 ;;
esac
;;
version) echo "2.30.0" ;;
esac
exit 0
;;
esac
exit 0
STUB
chmod +x "$stub_bin/docker"
# git: the installer takes the "existing installation" path, so only the
# fetch/checkout calls run and they are all tolerant of failure.
printf '#!/usr/bin/env bash\nexit 0\n' >"$stub_bin/git"
chmod +x "$stub_bin/git"
# brew: pretend the CLI installs cleanly so the run reaches the summary.
printf '#!/usr/bin/env bash\nexit 0\n' >"$stub_bin/brew"
chmod +x "$stub_bin/brew"
printf '#!/usr/bin/env bash\necho "multica v0.3.2 (commit: test)"\n' >"$stub_bin/multica"
chmod +x "$stub_bin/multica"
# curl records every probed URL so the health-check port can be asserted.
cat >"$stub_bin/curl" <<'STUB'
#!/usr/bin/env bash
set -uo pipefail
for arg in "$@"; do
case "$arg" in
http*) printf '%s\n' "$arg" >>"$MULTICA_TEST_CURL_LOG" ;;
esac
done
exit 0
STUB
chmod +x "$stub_bin/curl"
printf '#!/usr/bin/env bash\nhead -c 32 /dev/zero | od -An -tx1 | tr -d " \\n"\n' >"$stub_bin/openssl"
chmod +x "$stub_bin/openssl"
}
# Runs `install.sh --with-server` with the sandbox stubs. Remaining arguments are
# ambient environment assignments, so each case controls the environment
# explicitly instead of inheriting a CI runner's PORT.
_run_with_server() {
local tmp="$1"
shift
: >"$tmp/curl.log"
if ! env -i \
PATH="$tmp/stub-bin:/usr/bin:/bin" \
HOME="$tmp" \
MULTICA_INSTALL_DIR="$tmp/server" \
MULTICA_SELFHOST_REF="main" \
MULTICA_TEST_CURL_LOG="$tmp/curl.log" \
"$@" \
bash "$ROOT_DIR/scripts/install.sh" --with-server \
>"$tmp/install.out" 2>"$tmp/install.err"; then
echo "install.sh --with-server exited non-zero" >&2
cat "$tmp/install.out" >&2 || true
cat "$tmp/install.err" >&2 || true
return 1
fi
}
# Asserts the probed port and the printed ports all match the stub's answer.
_require_server_ports() {
local tmp="$1" label="$2" expected_backend="$3" expected_frontend="$4"
local probed printed_backend printed_frontend
probed="$(sed -n '1s#.*localhost:\([0-9]*\)/health#\1#p' "$tmp/curl.log")"
printed_backend="$(sed -n 's#.*Backend:[^0-9]*http://localhost:\([0-9]*\).*#\1#p' "$tmp/install.out" | head -n 1)"
printed_frontend="$(sed -n 's#.*Frontend:[^0-9]*http://localhost:\([0-9]*\).*#\1#p' "$tmp/install.out" | head -n 1)"
if [ "$probed" != "$expected_backend" ] ||
[ "$printed_backend" != "$expected_backend" ] ||
[ "$printed_frontend" != "$expected_frontend" ]; then
echo "[$label] installer ports disagree with the port Compose published" >&2
echo " compose published: backend=$expected_backend frontend=$expected_frontend" >&2
echo " health check probed: ${probed:-<none>}" >&2
echo " printed: backend=${printed_backend:-<none>} frontend=${printed_frontend:-<none>}" >&2
cat "$tmp/install.out" >&2 || true
return 1
fi
}
test_with_server_uses_compose_published_ports() {
local tmp
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' RETURN
# label | .env mutation (sed) | ambient env | expected backend | expected frontend
local cases='defaults|||8080|3000
env-file PORT|s/^PORT=8080/PORT=9100/||9100|3000
env-file BACKEND_PORT|s/^# BACKEND_PORT=8080/BACKEND_PORT=9200/||9200|3000
env-file API_PORT|s/^# API_PORT=8080/API_PORT=9300/||9300|3000
env-file SERVER_PORT|s/^# SERVER_PORT=8080/SERVER_PORT=9400/||9400|3000
env-file FRONTEND_PORT|s/^FRONTEND_PORT=3000/FRONTEND_PORT=3100/||8080|3100
ambient PORT beats .env|s/^PORT=8080/PORT=9100/|PORT=9500|9500|3000
ambient BACKEND_PORT beats .env|s/^PORT=8080/PORT=9100/|BACKEND_PORT=9600|9600|3000
ambient API_PORT beats .env|s/^PORT=8080/PORT=9100/|API_PORT=9700|9700|3000
ambient SERVER_PORT beats .env|s/^PORT=8080/PORT=9100/|SERVER_PORT=9800|9800|3000
ambient FRONTEND_PORT beats .env|s/^FRONTEND_PORT=3000/FRONTEND_PORT=3100/|FRONTEND_PORT=3200|8080|3200
empty ambient BACKEND_PORT falls through|s/^PORT=8080/PORT=9100/|BACKEND_PORT=|9100|3000
empty env-file BACKEND_PORT falls through|s/^PORT=8080/PORT=9100/;s/^# BACKEND_PORT=8080/BACKEND_PORT=/||9100|3000'
local label mutation ambient expect_backend expect_frontend
while IFS='|' read -r label mutation ambient expect_backend expect_frontend; do
[ -n "$label" ] || continue
rm -rf "$tmp/server" "$tmp/stub-bin"
_setup_server_sandbox "$tmp"
cp "$tmp/server/.env.example" "$tmp/server/.env"
if [ -n "$mutation" ]; then
sed "$mutation" "$tmp/server/.env" >"$tmp/server/.env.new"
mv "$tmp/server/.env.new" "$tmp/server/.env"
fi
if [ -n "$ambient" ]; then
_run_with_server "$tmp" "$ambient" || return 1
else
_run_with_server "$tmp" || return 1
fi
_require_server_ports "$tmp" "$label" "$expect_backend" "$expect_frontend" || return 1
done <<<"$cases"
}
test_with_server_fails_when_compose_port_is_unavailable() {
local tmp
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' RETURN
_setup_server_sandbox "$tmp"
cp "$tmp/server/.env.example" "$tmp/server/.env"
# Compose cannot report a port, e.g. the container never came up.
cat >"$tmp/stub-bin/docker" <<'STUB'
#!/usr/bin/env bash
set -uo pipefail
case "${1:-}" in
info) exit 0 ;;
compose)
for arg in "$@"; do
case "$arg" in
port) exit 1 ;;
version) echo "2.30.0"; exit 0 ;;
esac
done
exit 0
;;
esac
exit 0
STUB
chmod +x "$tmp/stub-bin/docker"
if _run_with_server "$tmp" >/dev/null 2>&1; then
echo "installer must not report success when Compose cannot report the port" >&2
cat "$tmp/install.out" >&2 || true
return 1
fi
if ! grep -q "could not read the backend host port" "$tmp/install.err"; then
echo "expected an explicit failure about the backend host port" >&2
cat "$tmp/install.err" >&2 || true
return 1
fi
if grep -q "server is running and CLI is ready" "$tmp/install.out"; then
echo "installer claimed success despite an unresolved port" >&2
cat "$tmp/install.out" >&2 || true
return 1
fi
}
test_brew_install_failure_falls_back_to_release_binary
test_brew_tap_failure_falls_back_to_release_binary
test_remote_ssh_install_prints_token_login_hint
test_local_install_does_not_print_token_login_hint
test_with_server_uses_compose_published_ports
test_with_server_fails_when_compose_port_is_unavailable
echo "install.sh tests passed"
+344
View File
@@ -140,4 +140,348 @@ require_env \
"$(resolve_make_public_url "$explicit_worktree_env")" \
"https://api.explicit.example"
# ---------------------------------------------------------------------------
# Host port consistency (regression for #6145)
#
# The health check and the success message must use the port Compose actually
# published. The recipes used to probe ${PORT:-8080} while Compose published
# ${BACKEND_PORT:-8080} — two sources of truth, so any config where they
# disagreed hammered the wrong port for 60s and then reported "Services are
# still starting" on a healthy stack.
#
# These cases drive the real `make selfhost` recipe. The docker stub does NOT
# get told what to publish: on `up` it asks the real `docker compose config` to
# interpolate the compose file with the environment the recipe actually handed
# it, records that, and answers `port` from the recording. So the assertion
# crosses environment -> make -> compose -> report for real, and a stub that
# agreed with a broken recipe could not make it pass.
# ---------------------------------------------------------------------------
stub_dir="$tmp_dir/bin"
mkdir -p "$stub_dir"
cat >"$stub_dir/docker" <<'STUB'
#!/usr/bin/env bash
# `docker compose` stub. Derives the published host ports from the real compose
# interpolation of the environment this invocation received, so the recorded
# answer is whatever the recipe truly asked Compose for.
set -uo pipefail
args=("$@")
sub=""
subidx=0
for ((i = 0; i < ${#args[@]}; i++)); do
case "${args[i]}" in
up | pull | port | version | logs | down | build)
sub=${args[i]}
subidx=$i
break
;;
esac
done
files=()
for ((i = 0; i < ${#args[@]}; i++)); do
if [ "${args[i]}" = "-f" ]; then files+=(-f "${args[i + 1]}"); fi
done
case "$sub" in
version) echo "2.30.0" ;;
up)
"$REAL_DOCKER" compose "${files[@]}" config --format json 2>/dev/null | node -e '
let raw = "";
process.stdin.on("data", (chunk) => (raw += chunk));
process.stdin.on("end", () => {
const config = JSON.parse(raw);
for (const service of ["backend", "frontend"]) {
console.log(service + "=" + config.services[service].ports[0].published);
}
});
' >>"$STUB_PUBLISHED_RECORD"
;;
port)
service=${args[subidx + 1]}
published=$(grep "^${service}=" "$STUB_PUBLISHED_RECORD" 2>/dev/null | tail -n 1 | cut -d= -f2)
if [ -z "$published" ]; then exit 1; fi
printf '127.0.0.1:%s\n' "$published"
;;
*) : ;;
esac
exit 0
STUB
cat >"$stub_dir/curl" <<'STUB'
#!/usr/bin/env bash
# Records probed URLs and always reports a healthy backend.
set -uo pipefail
for arg in "$@"; do
case "$arg" in
http*) printf '%s\n' "$arg" >>"$STUB_CURL_LOG" ;;
esac
done
exit 0
STUB
chmod +x "$stub_dir/docker" "$stub_dir/curl"
# Throwaway checkout so the recipe never touches this repo's own .env.
recipe_dir="$tmp_dir/recipe"
mkdir -p "$recipe_dir/scripts"
cp Makefile .env.example docker-compose.selfhost.yml docker-compose.selfhost.build.yml "$recipe_dir/"
cp scripts/selfhost-wait.sh "$recipe_dir/scripts/"
record="$tmp_dir/published"
curl_log="$tmp_dir/probed"
real_docker="$(command -v docker)"
# Runs `make <target>` against the stubs after applying a sed script to .env.
# Remaining args are passed through to make (environment assignments must be
# given as VAR=value before the target via `env`, make variables after it).
run_recipe() {
local target=$1 env_mutation=$2 shell_env=$3 make_args=$4
cp "$recipe_dir/.env.example" "$recipe_dir/.env"
if [ -n "$env_mutation" ]; then
sed "$env_mutation" "$recipe_dir/.env" >"$recipe_dir/.env.tmp"
mv "$recipe_dir/.env.tmp" "$recipe_dir/.env"
fi
: >"$record"
: >"$curl_log"
(
cd "$recipe_dir" || exit 1
eval "env PATH=\"$stub_dir:\$PATH\" \
REAL_DOCKER=\"$real_docker\" \
STUB_PUBLISHED_RECORD=\"$record\" \
STUB_CURL_LOG=\"$curl_log\" \
$shell_env make $target $make_args"
)
}
published_port() {
grep "^$1=" "$record" | tail -n 1 | cut -d= -f2
}
probed_port() {
sed -n '1s#http://localhost:\([0-9]*\)/health#\1#p' "$curl_log"
}
require_consistent() {
local label=$1 expected=$2
local published probed
published=$(published_port backend)
probed=$(probed_port)
if [ "$published" != "$expected" ] || [ "$probed" != "$expected" ]; then
echo "[$label] host port disagreement"
echo " expected published and probed port: $expected"
echo " compose published: ${published:-<none>}"
echo " health check probed: ${probed:-<none>}"
exit 1
fi
}
# PORT is the value to edit, so editing it must move the published port and the
# probe together. Fails on the old recipe, which probed 9100 while Compose
# published 8080.
run_recipe selfhost 's/^PORT=8080/PORT=9100/' '' '' >/dev/null
require_consistent 'PORT edited in .env' 9100
# BACKEND_PORT remains an alias that overrides PORT.
run_recipe selfhost 's/^# BACKEND_PORT=8080/BACKEND_PORT=9200/' '' '' >/dev/null
require_consistent 'BACKEND_PORT alias in .env' 9200
# A make command-line override must not desync the probe from Compose. Fails on
# the old recipe, which probed 8080 while Compose published 9100.
run_recipe selfhost 's/^# BACKEND_PORT=8080/BACKEND_PORT=9100/' '' 'PORT=8080' >/dev/null
require_consistent 'make PORT=8080 over BACKEND_PORT=9100' 9100
# With no alias pinned in .env, an alias from the environment survives make's
# include and takes effect end to end.
run_recipe selfhost '' 'BACKEND_PORT=9300' '' >/dev/null
require_consistent 'BACKEND_PORT from the environment' 9300
# The env file stays authoritative for values it does set, so an environment
# PORT loses to it — and the probe must follow whatever Compose then published.
run_recipe selfhost '' 'PORT=9500' '' >/dev/null
require_consistent 'PORT from the environment is overridden by .env' 8080
# Defaults stay 8080/3000.
run_recipe selfhost '' '' '' >/dev/null
require_consistent 'defaults' 8080
if [ "$(published_port frontend)" != "3000" ]; then
echo "default frontend host port should be 3000, got $(published_port frontend)"
exit 1
fi
# selfhost-build resolves the port the same way.
run_recipe selfhost-build 's/^PORT=8080/PORT=9400/' '' '' >/dev/null
require_consistent 'selfhost-build with PORT edited' 9400
# Every alias at once: BACKEND_PORT wins, and the probe follows it.
run_recipe selfhost \
's/^PORT=8080/PORT=9000/;s/^# BACKEND_PORT=8080/BACKEND_PORT=8000/;s/^# API_PORT=8080/API_PORT=7000/;s/^# SERVER_PORT=8080/SERVER_PORT=6000/' \
'' '' >/dev/null
require_consistent 'every alias set at once' 8000
# A higher-priority alias in the env file beats a lower-priority one from the
# shell.
run_recipe selfhost \
's/^PORT=8080/PORT=8000/;s/^# BACKEND_PORT=8080/BACKEND_PORT=8000/' \
'API_PORT=7000' '' >/dev/null
require_consistent 'env-file BACKEND_PORT over shell API_PORT' 8000
# ---------------------------------------------------------------------------
# Make command-line variables
#
# A command-line assignment outranks both the env file and the environment, and
# it is a third origin that no re-derivation of the port got right. Since the
# recipe now asks Compose, the probe follows whatever actually got published.
# ---------------------------------------------------------------------------
# Command-line high-priority alias over a low-priority alias in the env file.
run_recipe selfhost 's/^# API_PORT=8080/API_PORT=7000/' '' 'BACKEND_PORT=9000' >/dev/null
require_consistent 'command-line BACKEND_PORT over env-file API_PORT' 9000
# Env-file high-priority alias over a low-priority alias on the command line.
run_recipe selfhost 's/^# BACKEND_PORT=8080/BACKEND_PORT=9100/' '' 'SERVER_PORT=6000' >/dev/null
require_consistent 'env-file BACKEND_PORT over command-line SERVER_PORT' 9100
# An explicit empty value on the command line drops out of the chain.
run_recipe selfhost '' '' 'BACKEND_PORT=' >/dev/null
require_consistent 'command-line BACKEND_PORT= falls through to PORT' 8080
# ---------------------------------------------------------------------------
# Explicit empty assignments in the env file
#
# `BACKEND_PORT=` is a distinct input from an absent one: make lets it override
# the same variable from the environment, and it drops out of the alias chain
# instead of setting a port. Compose treats the exported empty value the same
# way, so the two agree.
# ---------------------------------------------------------------------------
run_recipe selfhost 's/^# BACKEND_PORT=8080/BACKEND_PORT=/' 'BACKEND_PORT=9000' '' >/dev/null
require_consistent 'empty BACKEND_PORT in .env over shell BACKEND_PORT' 8080
run_recipe selfhost 's/^PORT=8080/PORT=/;s/^# BACKEND_PORT=8080/BACKEND_PORT=/' 'PORT=9000' '' >/dev/null
require_consistent 'every chain variable emptied in .env' 8080
run_recipe selfhost 's/^FRONTEND_PORT=3000/FRONTEND_PORT=/' 'FRONTEND_PORT=3100' '' >/dev/null
if [ "$(published_port frontend)" != "3000" ]; then
echo "an empty FRONTEND_PORT in .env must fall back to 3000, got $(published_port frontend)"
exit 1
fi
# The recipes must delegate instead of re-deriving the port.
for expected_call in 'bash scripts/selfhost-wait.sh official' 'bash scripts/selfhost-wait.sh build'; do
if ! grep -Fq "$expected_call" Makefile; then
echo "Makefile must call the shared wait script: $expected_call"
exit 1
fi
done
if grep -n 'localhost:$${PORT' Makefile; then
echo "The self-host recipes must not re-derive the backend host port from \$PORT."
echo "Use scripts/selfhost-wait.sh, which reads the published port from Compose."
exit 1
fi
# ---------------------------------------------------------------------------
# The backend port alias chain, and Compose's own source precedence
#
# BACKEND_PORT -> API_PORT -> SERVER_PORT -> PORT -> 8080
#
# Exercised through the direct Compose path, because the Makefile normalises all
# four into PORT before a recipe ever runs — so a recipe-only test cannot see a
# gap here.
#
# This is also where Compose's precedence is pinned against the real binary: the
# calling environment outranks the env file. Both installers relied on their own
# .env-only derivation and so probed a port Compose never published (#6145);
# they now ask `docker compose port` instead, which is asserted end to end in
# scripts/install.test.sh and scripts/install.ps1.test.ps1. Those suites run on
# agents without a Docker CLI, so the ground truth for precedence lives here.
# ---------------------------------------------------------------------------
# Neither installer may reconstruct the port from the env file again.
for installer in scripts/install.sh scripts/install.ps1; do
if grep -nE '(selfhost_backend_port|selfhost_frontend_port|Get-SelfHostBackendPort|Get-SelfHostFrontendPort)' "$installer"; then
echo "$installer must not re-derive host ports from .env."
echo "Read the published port from Compose, as scripts/selfhost-wait.sh does."
exit 1
fi
done
for installer_call in \
'compose_published_port backend 8080' \
'compose_published_port frontend 3000'; do
if ! grep -Fq "$installer_call" scripts/install.sh; then
echo "scripts/install.sh must read the published port from Compose: $installer_call"
exit 1
fi
done
for installer_call in \
'Get-ComposePublishedPort -Service "backend" -ContainerPort 8080' \
'Get-ComposePublishedPort -Service "frontend" -ContainerPort 3000'; do
if ! grep -Fq "$installer_call" scripts/install.ps1; then
echo "scripts/install.ps1 must read the published port from Compose: $installer_call"
exit 1
fi
done
compose_published_ports() {
local env_file=$1
shift
env "$@" docker compose --env-file "$env_file" -f docker-compose.selfhost.yml config --format json |
node -e '
let raw = "";
process.stdin.on("data", (chunk) => (raw += chunk));
process.stdin.on("end", () => {
const config = JSON.parse(raw);
console.log(config.services.backend.ports[0].published + " " + config.services.frontend.ports[0].published);
});
'
}
# Each case: label, sed applied to .env.example, ambient env, expected backend,
# expected frontend. .env.example ships PORT=8080 with every alias commented out.
while IFS='|' read -r case_label case_mutation case_ambient case_backend case_frontend; do
[ -n "$case_label" ] || continue
case_env="$tmp_dir/.env.alias"
if [ -n "$case_mutation" ]; then
sed "$case_mutation" .env.example >"$case_env"
else
cp .env.example "$case_env"
fi
# Unset every port variable first so the agent's own environment cannot leak.
read -r observed_backend observed_frontend < <(
compose_published_ports "$case_env" \
-u PORT -u BACKEND_PORT -u API_PORT -u SERVER_PORT -u FRONTEND_PORT \
${case_ambient:+"$case_ambient"}
)
if [ "$observed_backend" != "$case_backend" ] || [ "$observed_frontend" != "$case_frontend" ]; then
echo "[$case_label] Compose published an unexpected host port"
echo " expected: backend=$case_backend frontend=$case_frontend"
echo " observed: backend=$observed_backend frontend=$observed_frontend"
exit 1
fi
done <<'CASES'
defaults|||8080|3000
PORT only|s/^PORT=8080/PORT=9100/||9100|3000
SERVER_PORT overrides PORT|s/^# SERVER_PORT=8080/SERVER_PORT=9200/||9200|3000
API_PORT overrides SERVER_PORT|s/^# API_PORT=8080/API_PORT=9300/;s/^# SERVER_PORT=8080/SERVER_PORT=9200/||9300|3000
BACKEND_PORT overrides all|s/^# BACKEND_PORT=8080/BACKEND_PORT=9400/;s/^# API_PORT=8080/API_PORT=9300/;s/^# SERVER_PORT=8080/SERVER_PORT=9200/||9400|3000
ambient PORT beats the env file|s/^PORT=8080/PORT=9100/|PORT=9500|9500|3000
ambient BACKEND_PORT beats the env file|s/^PORT=8080/PORT=9100/|BACKEND_PORT=9600|9600|3000
ambient API_PORT beats the env file|s/^PORT=8080/PORT=9100/|API_PORT=9700|9700|3000
ambient SERVER_PORT beats the env file|s/^PORT=8080/PORT=9100/|SERVER_PORT=9800|9800|3000
ambient FRONTEND_PORT beats the env file|s/^FRONTEND_PORT=3000/FRONTEND_PORT=3100/|FRONTEND_PORT=3200|8080|3200
CASES
# An env-file alias beats the same alias from the environment, and the probe
# follows whatever Compose published either way.
for shadowed_alias in BACKEND_PORT API_PORT SERVER_PORT; do
run_recipe selfhost "s/^# ${shadowed_alias}=8080/${shadowed_alias}=9700/" \
"${shadowed_alias}=9600" '' >/dev/null
require_consistent "env-file ${shadowed_alias} over the same shell variable" 9700
done
echo "self-host env derivation ok"
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Wait for the self-hosted stack to answer /health, then print connection info.
# Shared by `make selfhost` and `make selfhost-build`.
#
# Why the host port is read back from Compose instead of re-derived here: the
# recipes used to probe `${PORT:-8080}` while Compose published `${BACKEND_PORT:-8080}`.
# Two different variables as the source of truth means any config where they
# disagree breaks — `.env` setting PORT with BACKEND_PORT unset probed the custom
# port while the stack sat on 8080, and `make selfhost PORT=8080` over a .env
# with BACKEND_PORT=9100 probed 8080 while the stack sat on 9100, hammering the
# wrong port for 60s and then reporting "Services are still starting" on a
# healthy install. `docker compose port` is the only authority on what Compose
# actually published, so ask it rather than re-deriving the answer.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
mode=${1:-official}
case "$mode" in
official)
compose_files=(-f docker-compose.selfhost.yml)
;;
build)
compose_files=(-f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml)
;;
*)
echo "usage: ${BASH_SOURCE[0]} [official|build]" >&2
exit 2
;;
esac
# The Makefile exports COMPOSE; keep the same default when run standalone.
read -r -a compose_cmd <<<"${COMPOSE:-docker compose}"
# Published host port for a service, straight from Compose. Falls back to the
# same default chain as docker-compose.selfhost.yml when the container is not up
# (e.g. `up -d` failed) so the message degrades instead of breaking.
compose_host_port() {
local service=$1 container_port=$2 fallback=$3 published
published=$("${compose_cmd[@]}" "${compose_files[@]}" port "$service" "$container_port" 2>/dev/null | tail -n 1) || published=""
published=${published##*:}
published=${published%$'\r'}
case "$published" in
'' | *[!0-9]*) printf '%s\n' "$fallback" ;;
*) printf '%s\n' "$published" ;;
esac
}
backend_port=$(compose_host_port backend 8080 "${BACKEND_PORT:-${API_PORT:-${SERVER_PORT:-${PORT:-8080}}}}")
frontend_port=$(compose_host_port frontend 3000 "${FRONTEND_PORT:-3000}")
backend_url="http://localhost:${backend_port}"
frontend_url="http://localhost:${frontend_port}"
health_ok() {
curl -sf "${backend_url}/health" >/dev/null 2>&1
}
echo "==> Waiting for backend to be ready..."
for _ in $(seq 1 30); do
if health_ok; then
break
fi
sleep 2
done
if ! health_ok; then
echo ""
echo "Services are still starting. Check logs:"
echo " ${compose_cmd[*]} ${compose_files[*]} logs"
exit 0
fi
echo ""
echo "✓ Multica is running!"
echo " Frontend: ${frontend_url}"
echo " Backend: ${backend_url}"
echo ""
if [ "$mode" = "build" ]; then
echo "Built images locally via docker-compose.selfhost.build.yml."
echo "Local tags: multica-backend:dev and multica-web:dev."
else
echo "Images: ${MULTICA_BACKEND_IMAGE:-ghcr.io/multica-ai/multica-backend}:${MULTICA_IMAGE_TAG:-latest}"
echo " ${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:${MULTICA_IMAGE_TAG:-latest}"
fi
echo ""
echo "Log in: configure RESEND_API_KEY in .env for email codes,"
echo " or read the generated code from backend logs when Resend is unset."
echo ""
echo "Next — install the CLI and connect your machine:"
echo " brew install multica-ai/tap/multica"
echo " multica setup self-host"