Compare commits

...

2 Commits

Author SHA1 Message Date
Lambda
e9d0c392f8 test(cli): cover isLocalhostURL + warn on unparseable Compose version
Follow-up to review feedback on #1402:
- checkDockerComposeVersion now surfaces unparseable version output
  as a soft warning, matching the shell installers' behavior instead
  of silently continuing.
- Add table-driven tests for isLocalhostURL covering localhost,
  127.0.0.1, [::1] with ports, remote hosts, 0.0.0.0, and junk input.
2026-04-21 02:27:06 +08:00
Lambda
a236081c0e fix(installer): preflight Docker Compose v2 with clear error
Self-host installs on older Docker Compose fail with a cryptic
"Additional property name is not allowed" schema error because our
docker-compose.selfhost.yml uses the top-level `name:` key (Compose
spec v1.28+, V2 only).

- scripts/install.sh + install.ps1: after the Docker check, run
  `docker compose version --short`, parse it, and fail fast with an
  upgrade link when V2 is missing or the major version is < 2.
- multica setup self-host: when the configured server URL is
  localhost, surface the same check as a warning so users see it
  before hitting the compose error.
- SELF_HOSTING.md: document the minimum Docker Desktop 4.x /
  Compose v2.x requirement up front.
2026-04-21 01:38:40 +08:00
5 changed files with 178 additions and 0 deletions

View File

@@ -12,6 +12,17 @@ Deploy Multica on your own infrastructure in minutes.
Each user who runs AI agents locally also installs the **`multica` CLI** and runs the **agent daemon** on their own machine.
## Requirements
- **Docker Desktop 4.x or later** (macOS / Windows), or Docker Engine 20.10+ on Linux.
- **Docker Compose v2.x or later** — required by the bundled `docker-compose.selfhost.yml`. The legacy standalone `docker-compose` (V1) binary is **not** supported; on older installs you'll see a cryptic `Additional property name is not allowed` error. Upgrade Docker Desktop, or install the [Compose V2 plugin on Linux](https://docs.docker.com/compose/install/linux/).
Check your version with:
```bash
docker compose version
```
## Quick Install (Recommended)
Two commands to set up everything — server, CLI, and configuration:

View File

@@ -187,6 +187,56 @@ After installing Docker, re-run this script with `$env:MULTICA_MODE="local"`.
}
Write-Ok "Docker is available"
Test-DockerComposeVersion
}
# Multica's docker-compose.selfhost.yml uses the top-level `name:` key,
# which requires Compose spec v1.28+ (shipped in Docker Compose V2).
# Running this on Compose V1 produces a cryptic schema error
# ("Additional property name is not allowed"). Fail fast with guidance.
function Test-DockerComposeVersion {
$versionOutput = $null
try {
$versionOutput = (& docker compose version --short 2>$null) | Select-Object -First 1
} catch {
$versionOutput = $null
}
if ([string]::IsNullOrWhiteSpace($versionOutput)) {
Write-Fail @"
Docker Compose V2 is not available.
Multica self-hosting requires Docker Compose v2.x or later
(bundled with Docker Desktop 4.x+).
The legacy 'docker-compose' (V1) binary is not supported.
Upgrade Docker Desktop for Windows:
https://docs.docker.com/desktop/install/windows-install/
"@
}
$normalized = $versionOutput.Trim().TrimStart('v')
$major = ($normalized -split '\.')[0]
$majorInt = 0
if (-not [int]::TryParse($major, [ref]$majorInt)) {
Write-Warn "Could not parse Docker Compose version: $versionOutput (continuing)"
return
}
if ($majorInt -lt 2) {
Write-Fail @"
Docker Compose $versionOutput is too old.
Multica self-hosting requires Docker Compose v2.x or later.
Please upgrade Docker Desktop (4.x+):
https://docs.docker.com/desktop/install/windows-install/
"@
}
Write-Ok "Docker Compose $versionOutput"
}
# ---------------------------------------------------------------------------

View File

@@ -213,6 +213,51 @@ After installing Docker, re-run this script with --with-server."
fi
ok "Docker is available"
check_docker_compose_version
}
# Multica's docker-compose.selfhost.yml uses the top-level `name:` key,
# which requires Compose spec v1.28+ (shipped in Docker Compose V2).
# Running this on Compose V1 produces a cryptic schema error
# ("Additional property name is not allowed"). Fail fast with guidance.
check_docker_compose_version() {
local version_output
if ! version_output=$(docker compose version --short 2>/dev/null); then
fail "Docker Compose V2 is not available.
Multica self-hosting requires Docker Compose v2.x or later
(bundled with Docker Desktop 4.x+ or installable as the
'docker compose' plugin on Linux).
The legacy 'docker-compose' (V1) binary is not supported.
Upgrade Docker Desktop:
macOS: https://docs.docker.com/desktop/install/mac-install/
Windows: https://docs.docker.com/desktop/install/windows-install/
Install the Compose V2 plugin on Linux:
https://docs.docker.com/compose/install/linux/"
fi
local version="${version_output#v}"
local major="${version%%.*}"
case "$major" in
''|*[!0-9]*)
warn "Could not parse Docker Compose version: $version_output (continuing)"
return
;;
esac
if [ "$major" -lt 2 ]; then
fail "Docker Compose $version_output is too old.
Multica self-hosting requires Docker Compose v2.x or later.
Please upgrade Docker Desktop (4.x+) or install the Compose V2 plugin:
https://docs.docker.com/compose/install/"
fi
ok "Docker Compose $version_output"
}
# ---------------------------------------------------------------------------

View File

@@ -5,7 +5,10 @@ import (
"context"
"fmt"
"net/http"
"net/url"
"os"
"os/exec"
"strconv"
"strings"
"time"
@@ -181,6 +184,15 @@ func runSetupSelfHost(cmd *cobra.Command, args []string) error {
fmt.Fprintf(os.Stderr, " app_url: %s\n", cfg.AppURL)
printConfigLocation(profile)
// If we're pointing at localhost, the user is likely running the bundled
// docker-compose stack. Verify Compose V2 is available so the cryptic
// "Additional property name is not allowed" error can't surprise them.
if isLocalhostURL(serverURL) {
if err := checkDockerComposeVersion(); err != nil {
fmt.Fprintf(os.Stderr, "\n⚠ %s\n", err)
}
}
// Check if the server is reachable.
if !probeServer(serverURL) {
fmt.Fprintf(os.Stderr, "\n⚠ Server at %s is not reachable.\n", serverURL)
@@ -203,6 +215,41 @@ func runSetupSelfHost(cmd *cobra.Command, args []string) error {
return nil
}
// isLocalhostURL reports whether the URL's host resolves to the local machine.
func isLocalhostURL(raw string) bool {
u, err := url.Parse(raw)
if err != nil {
return false
}
host := u.Hostname()
return host == "localhost" || host == "127.0.0.1" || host == "::1"
}
// checkDockerComposeVersion ensures Docker Compose V2 (>= 2.x) is available.
// Multica's docker-compose.selfhost.yml uses the top-level `name:` key, which
// requires Compose spec v1.28+ — shipped in V2. V1 produces a cryptic
// "Additional property name is not allowed" schema error.
func checkDockerComposeVersion() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "docker", "compose", "version", "--short").Output()
if err != nil {
return fmt.Errorf("Docker Compose V2 not detected. Self-hosting requires Docker Desktop 4.x+ (Compose v2.x). Upgrade: https://docs.docker.com/compose/install/")
}
version := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(string(out)), "v"))
majorStr, _, _ := strings.Cut(version, ".")
major, parseErr := strconv.Atoi(majorStr)
if parseErr != nil {
return fmt.Errorf("Could not parse Docker Compose version %q — please ensure Compose v2.x or later is installed.", version)
}
if major < 2 {
return fmt.Errorf("Docker Compose %s is too old. Self-hosting requires v2.x or later. Upgrade Docker Desktop: https://docs.docker.com/compose/install/", version)
}
return nil
}
// probeServer checks whether a Multica backend is reachable at the given URL.
func probeServer(baseURL string) bool {
url := strings.TrimRight(baseURL, "/") + "/health"

View File

@@ -0,0 +1,25 @@
package main
import "testing"
func TestIsLocalhostURL(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"http://localhost:8080", true},
{"http://localhost", true},
{"https://127.0.0.1:8080", true},
{"http://[::1]:8080", true},
{"http://api.example.com", false},
{"https://app.internal.co:8080", false},
{"http://0.0.0.0:8080", false},
{"", false},
{"not a url", false},
}
for _, c := range cases {
if got := isLocalhostURL(c.in); got != c.want {
t.Errorf("isLocalhostURL(%q) = %v, want %v", c.in, got, c.want)
}
}
}