Files
multica/scripts/install.test.sh
Multica Eve cac453fe01 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 <github@multica.ai>

* 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 <github@multica.ai>

* 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 <github@multica.ai>

* 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 <github@multica.ai>

* 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 <github@multica.ai>

* 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 <github@multica.ai>

* 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 <github@multica.ai>

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

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-31 14:46:13 +08:00

519 lines
15 KiB
Bash

#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Build a self-contained sandbox with stub `curl` and a tarball that the
# release-binary fallback path will download. Each test supplies its own
# `brew` stub to model a specific Homebrew failure mode.
_setup_sandbox() {
local tmp="$1"
local stub_bin="$tmp/stub-bin"
local install_bin="$tmp/install-bin"
local payload_dir="$tmp/payload"
mkdir -p "$stub_bin" "$install_bin" "$payload_dir"
cat >"$payload_dir/multica" <<'STUB'
#!/usr/bin/env bash
echo "multica v0.3.2 (commit: test)"
STUB
chmod +x "$payload_dir/multica"
tar -czf "$tmp/multica.tar.gz" -C "$payload_dir" multica
cat >"$stub_bin/curl" <<'STUB'
#!/usr/bin/env bash
if [[ "$*" == *"-sI"* ]]; then
printf 'HTTP/2 302\r\nlocation: https://github.com/multica-ai/multica/releases/tag/v0.3.2\r\n'
exit 0
fi
out=""
while [[ $# -gt 0 ]]; do
case "$1" in
-o)
out="$2"
shift 2
;;
*)
shift
;;
esac
done
if [[ -z "$out" ]]; then
echo "stub curl expected -o" >&2
exit 2
fi
cp "$MULTICA_TEST_ARCHIVE" "$out"
STUB
chmod +x "$stub_bin/curl"
}
_run_installer() {
local tmp="$1"
local out="$tmp/install.out"
local err="$tmp/install.err"
if ! PATH="$tmp/stub-bin:$tmp/install-bin:/usr/bin:/bin" \
MULTICA_BIN_DIR="$tmp/install-bin" \
MULTICA_TEST_ARCHIVE="$tmp/multica.tar.gz" \
bash "$ROOT_DIR/scripts/install.sh" >"$out" 2>"$err"; then
echo "install.sh exited non-zero" >&2
cat "$out" >&2 || true
cat "$err" >&2 || true
return 1
fi
if [[ ! -x "$tmp/install-bin/multica" ]]; then
echo "expected fallback binary at $tmp/install-bin/multica" >&2
cat "$out" >&2 || true
cat "$err" >&2 || true
return 1
fi
if ! grep -q "Homebrew output (last 80 lines):" "$err"; then
echo "expected diagnostic tail in stderr" >&2
cat "$err" >&2 || true
return 1
fi
}
test_brew_install_failure_falls_back_to_release_binary() {
local tmp
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' RETURN
_setup_sandbox "$tmp"
cat >"$tmp/stub-bin/brew" <<'STUB'
#!/usr/bin/env bash
case "${1:-}" in
tap)
exit 0
;;
install)
echo "simulated brew install failure" >&2
exit 42
;;
list)
exit 1
;;
*)
exit 0
;;
esac
STUB
chmod +x "$tmp/stub-bin/brew"
_run_installer "$tmp"
}
test_brew_tap_failure_falls_back_to_release_binary() {
local tmp
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' RETURN
_setup_sandbox "$tmp"
cat >"$tmp/stub-bin/brew" <<'STUB'
#!/usr/bin/env bash
case "${1:-}" in
tap)
echo "simulated brew tap failure" >&2
exit 17
;;
*)
echo "brew $* should not be reached after tap failure" >&2
exit 99
;;
esac
STUB
chmod +x "$tmp/stub-bin/brew"
_run_installer "$tmp"
}
test_remote_ssh_install_prints_token_login_hint() {
local tmp
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' RETURN
_setup_sandbox "$tmp"
cat >"$tmp/stub-bin/brew" <<'STUB'
#!/usr/bin/env bash
case "${1:-}" in
tap)
exit 0
;;
install)
echo "simulated brew install failure" >&2
exit 42
;;
list)
exit 1
;;
*)
exit 0
;;
esac
STUB
chmod +x "$tmp/stub-bin/brew"
(
export SSH_CONNECTION="192.0.2.10 54321 198.51.100.20 22"
_run_installer "$tmp"
)
if ! grep -q "Looks like a remote/SSH session" "$tmp/install.out"; then
echo "expected remote/SSH token-login hint in installer output" >&2
cat "$tmp/install.out" >&2 || true
return 1
fi
if ! grep -q "https://multica.ai/settings?tab=tokens" "$tmp/install.out"; then
echo "expected direct API Tokens settings URL in installer output" >&2
cat "$tmp/install.out" >&2 || true
return 1
fi
if ! grep -q "Settings > API Tokens" "$tmp/install.out"; then
echo "expected API Tokens tab name in installer output" >&2
cat "$tmp/install.out" >&2 || true
return 1
fi
if ! grep -q "multica login --token <YOUR_TOKEN>" "$tmp/install.out"; then
echo "expected token login command in installer output" >&2
cat "$tmp/install.out" >&2 || true
return 1
fi
if grep -q "multica config set server_url" "$tmp/install.out"; then
echo "did not expect default cloud server config command in installer output" >&2
cat "$tmp/install.out" >&2 || true
return 1
fi
if grep -q "multica config set app_url" "$tmp/install.out"; then
echo "did not expect default cloud app config command in installer output" >&2
cat "$tmp/install.out" >&2 || true
return 1
fi
}
test_local_install_does_not_print_token_login_hint() {
local tmp
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' RETURN
_setup_sandbox "$tmp"
cat >"$tmp/stub-bin/brew" <<'STUB'
#!/usr/bin/env bash
case "${1:-}" in
tap)
exit 0
;;
install)
echo "simulated brew install failure" >&2
exit 42
;;
list)
exit 1
;;
*)
exit 0
;;
esac
STUB
chmod +x "$tmp/stub-bin/brew"
(
unset SSH_CONNECTION SSH_CLIENT SSH_TTY
_run_installer "$tmp"
)
if grep -q "Looks like a remote/SSH session" "$tmp/install.out"; then
echo "did not expect remote/SSH token-login hint in local installer output" >&2
cat "$tmp/install.out" >&2 || true
return 1
fi
if grep -q "multica login --token <YOUR_TOKEN>" "$tmp/install.out"; then
echo "did not expect token login command in local installer output" >&2
cat "$tmp/install.out" >&2 || true
return 1
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"