From ee7ba83f53cf50a00b0cbbfd79c08cfdb49ed774 Mon Sep 17 00:00:00 2001 From: YYClaw <197375+yyclaw@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:10:40 +0800 Subject: [PATCH] fix(self-host): apply setup config to daemon (MUL-5269) (#5880) Fixes two connected self-host setup failures in local worktree development. Generated worktree environments now expose the backend HTTP origin through MULTICA_PUBLIC_URL, and existing generated worktrees derive the missing value at startup through both scripts/local-env.sh and the Makefile. Explicit values, including an intentionally empty same-origin setting, are preserved. setup and setup self-host now reconcile an existing same-profile daemon after authentication so it loads the newly written server URL and token. An idle daemon is restarted behind the existing restart preflight; when active tasks exist, setup leaves the daemon running to avoid cancelling work and prints an actionable profile-aware restart command instead. Closes #5879 --- Makefile | 3 ++ scripts/init-worktree-env.sh | 1 + scripts/local-env.sh | 8 ++- scripts/selfhost-config.test.sh | 58 +++++++++++++++++++- server/cmd/multica/cmd_setup.go | 79 +++++++++++++++++++++++++--- server/cmd/multica/cmd_setup_test.go | 67 +++++++++++++++++++++++ 6 files changed, 208 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 4ad610ce07..a97402c579 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,9 @@ POSTGRES_USER ?= multica POSTGRES_PASSWORD ?= multica POSTGRES_PORT ?= 5432 PORT := $(or $(BACKEND_PORT),$(API_PORT),$(SERVER_PORT),$(PORT),8080) +ifeq ($(origin MULTICA_PUBLIC_URL), undefined) +MULTICA_PUBLIC_URL := http://localhost:$(PORT) +endif FRONTEND_PORT ?= 3000 FRONTEND_ORIGIN ?= http://localhost:$(FRONTEND_PORT) MULTICA_APP_URL ?= $(FRONTEND_ORIGIN) diff --git a/scripts/init-worktree-env.sh b/scripts/init-worktree-env.sh index f63a8e554d..db7ad2eb68 100644 --- a/scripts/init-worktree-env.sh +++ b/scripts/init-worktree-env.sh @@ -34,6 +34,7 @@ PORT=${backend_port} JWT_SECRET=change-me-in-production MULTICA_DEV_VERIFICATION_CODE=888888 MULTICA_SERVER_URL=ws://localhost:${backend_port}/ws +MULTICA_PUBLIC_URL=http://localhost:${backend_port} MULTICA_APP_URL=${frontend_origin} GOOGLE_CLIENT_ID= diff --git a/scripts/local-env.sh b/scripts/local-env.sh index c4100f7c4f..59c2a085ef 100644 --- a/scripts/local-env.sh +++ b/scripts/local-env.sh @@ -8,6 +8,12 @@ PORT="${BACKEND_PORT:-${API_PORT:-${SERVER_PORT:-${PORT:-8080}}}}" FRONTEND_PORT="${FRONTEND_PORT:-3000}" FRONTEND_ORIGIN="${FRONTEND_ORIGIN:-http://localhost:${FRONTEND_PORT}}" +# Older generated worktree env files predate MULTICA_PUBLIC_URL. Derive it +# only when the variable is absent; an explicitly configured value, including +# an intentionally empty one for same-origin proxying, must be preserved. +if [ "${MULTICA_PUBLIC_URL+x}" != "x" ]; then + MULTICA_PUBLIC_URL="http://localhost:${PORT}" +fi MULTICA_APP_URL="${MULTICA_APP_URL:-${FRONTEND_ORIGIN}}" GOOGLE_REDIRECT_URI="${GOOGLE_REDIRECT_URI:-${FRONTEND_ORIGIN}/auth/callback}" MULTICA_SERVER_URL="${MULTICA_SERVER_URL:-ws://localhost:${PORT}/ws}" @@ -16,5 +22,5 @@ PLAYWRIGHT_BASE_URL="${PLAYWRIGHT_BASE_URL:-${FRONTEND_ORIGIN}}" export POSTGRES_DB POSTGRES_USER POSTGRES_PORT export PORT FRONTEND_PORT FRONTEND_ORIGIN -export MULTICA_APP_URL GOOGLE_REDIRECT_URI MULTICA_SERVER_URL LOCAL_UPLOAD_BASE_URL +export MULTICA_PUBLIC_URL MULTICA_APP_URL GOOGLE_REDIRECT_URI MULTICA_SERVER_URL LOCAL_UPLOAD_BASE_URL export PLAYWRIGHT_BASE_URL diff --git a/scripts/selfhost-config.test.sh b/scripts/selfhost-config.test.sh index 77323a0b97..36c9daf418 100755 --- a/scripts/selfhost-config.test.sh +++ b/scripts/selfhost-config.test.sh @@ -29,7 +29,8 @@ require_env() { } tmp_env="$(mktemp)" -trap 'rm -f "$tmp_env"' EXIT +tmp_dir="$(mktemp -d)" +trap 'rm -f "$tmp_env"; rm -rf "$tmp_dir"' EXIT sed 's/^FRONTEND_PORT=.*/FRONTEND_PORT=3100/' .env.example >"$tmp_env" printf '\nBACKEND_PORT=9100\n' >>"$tmp_env" @@ -84,4 +85,59 @@ require_env "$local_env" 'MULTICA_SERVER_URL=ws://localhost:9100/ws' require_env "$local_env" 'LOCAL_UPLOAD_BASE_URL=http://localhost:9100' require_env "$local_env" 'PLAYWRIGHT_BASE_URL=http://localhost:3100' +worktree_env="$tmp_dir/.env.worktree" +WORKTREE_NAME=selfhost-config-test bash scripts/init-worktree-env.sh "$worktree_env" >/dev/null +worktree_backend_port="$(sed -n 's/^PORT=//p' "$worktree_env")" +require_env "$(cat "$worktree_env")" "MULTICA_PUBLIC_URL=http://localhost:${worktree_backend_port}" + +resolve_local_public_url() { + env -i PATH="$PATH" bash -c ' + set -euo pipefail + env_file=$1 + set -a + # shellcheck disable=SC1090 + . "$env_file" + set +a + # shellcheck disable=SC1091 + . scripts/local-env.sh + printf "%s\n" "$MULTICA_PUBLIC_URL" + ' _ "$1" +} + +make_env_probe="$tmp_dir/print-public-url.mk" +printf '%s\n' \ + '.PHONY: print-public-url' \ + 'print-public-url:' \ + ' @printf "%s\n" "$$MULTICA_PUBLIC_URL"' \ + >"$make_env_probe" + +resolve_make_public_url() { + make \ + --no-print-directory \ + -s \ + -f Makefile \ + -f "$make_env_probe" \ + ENV_FILE="$1" \ + print-public-url +} + +old_worktree_env="$tmp_dir/.env.worktree.old" +grep -v '^MULTICA_PUBLIC_URL=' "$worktree_env" >"$old_worktree_env" +require_env \ + "$(resolve_local_public_url "$old_worktree_env")" \ + "http://localhost:${worktree_backend_port}" +require_env \ + "$(resolve_make_public_url "$old_worktree_env")" \ + "http://localhost:${worktree_backend_port}" + +explicit_worktree_env="$tmp_dir/.env.worktree.explicit" +cp "$old_worktree_env" "$explicit_worktree_env" +printf '\nMULTICA_PUBLIC_URL=https://api.explicit.example\n' >>"$explicit_worktree_env" +require_env \ + "$(resolve_local_public_url "$explicit_worktree_env")" \ + "https://api.explicit.example" +require_env \ + "$(resolve_make_public_url "$explicit_worktree_env")" \ + "https://api.explicit.example" + echo "self-host env derivation ok" diff --git a/server/cmd/multica/cmd_setup.go b/server/cmd/multica/cmd_setup.go index 33907bfd40..0e0e656c8b 100644 --- a/server/cmd/multica/cmd_setup.go +++ b/server/cmd/multica/cmd_setup.go @@ -169,9 +169,8 @@ func runSetupCloud(cmd *cobra.Command, args []string) error { return err } - fmt.Fprintln(os.Stderr, "\nStarting daemon...") - if err := runDaemonBackground(cmd); err != nil { - return fmt.Errorf("start daemon: %w", err) + if err := runDaemonAfterSetup(cmd, args); err != nil { + return fmt.Errorf("start or restart daemon: %w", err) } fmt.Fprintln(os.Stderr, "\nāœ“ Setup complete! Your machine is now connected to Multica.") @@ -248,15 +247,83 @@ func runSetupSelfHost(cmd *cobra.Command, args []string) error { return err } - fmt.Fprintln(os.Stderr, "\nStarting daemon...") - if err := runDaemonBackground(cmd); err != nil { - return fmt.Errorf("start daemon: %w", err) + if err := runDaemonAfterSetup(cmd, args); err != nil { + return fmt.Errorf("start or restart daemon: %w", err) } fmt.Fprintln(os.Stderr, "\nāœ“ Setup complete! Your machine is now connected to Multica.") return nil } +// runDaemonAfterSetup makes the freshly authenticated profile effective in +// the daemon process. A daemon reads its server URL and token only at startup, +// so merely overwriting config.json while one is already running leaves it +// connected to the previous deployment. Restart that profile when needed; +// otherwise perform the normal first start. +func runDaemonAfterSetup(cmd *cobra.Command, args []string) error { + profile := resolveProfile(cmd) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + health := checkDaemonHealthOnPort(ctx, healthPortForProfile(profile)) + + return dispatchDaemonAfterSetup( + cmd, + args, + health, + func(cmd *cobra.Command, _ []string) error { + return runDaemonBackground(cmd) + }, + runDaemonRestart, + ) +} + +type setupDaemonRunner func(*cobra.Command, []string) error + +func dispatchDaemonAfterSetup( + cmd *cobra.Command, + args []string, + health map[string]any, + start setupDaemonRunner, + restart setupDaemonRunner, +) error { + if daemonAlive(health) { + if activeTasks := daemonActiveTaskCount(health); activeTasks > 0 { + taskLabel := "tasks" + if activeTasks == 1 { + taskLabel = "task" + } + restartCmd := "multica daemon restart" + if profile := resolveProfile(cmd); profile != "" { + restartCmd += " --profile " + profile + } + return fmt.Errorf( + "daemon has %d active %s; setup saved the new configuration but left the running daemon unchanged to avoid cancelling work. Wait for the active work to finish, then run '%s' to apply the new configuration", + activeTasks, + taskLabel, + restartCmd, + ) + } + fmt.Fprintln(os.Stderr, "\nRestarting daemon to apply the new configuration...") + return restart(cmd, args) + } + + fmt.Fprintln(os.Stderr, "\nStarting daemon...") + return start(cmd, args) +} + +func daemonActiveTaskCount(health map[string]any) int64 { + switch count := health["active_task_count"].(type) { + case float64: + return int64(count) + case int: + return int64(count) + case int64: + return count + default: + return 0 + } +} + // persistSelfHostConfigIfReachable probes serverURL and, only when it answers, // overwrites the profile config with the given self-host URLs. When the server // is unreachable it leaves any existing config — and its auth token — untouched diff --git a/server/cmd/multica/cmd_setup_test.go b/server/cmd/multica/cmd_setup_test.go index d5092aa688..8ff430de10 100644 --- a/server/cmd/multica/cmd_setup_test.go +++ b/server/cmd/multica/cmd_setup_test.go @@ -74,6 +74,73 @@ func TestPersistSelfHostConfigIfReachable(t *testing.T) { }) } +func TestDispatchDaemonAfterSetup(t *testing.T) { + newRunner := func(calls *[]string, name string) func(*cobra.Command, []string) error { + return func(*cobra.Command, []string) error { + *calls = append(*calls, name) + return nil + } + } + + t.Run("restarts an existing daemon so new config takes effect", func(t *testing.T) { + var calls []string + err := dispatchDaemonAfterSetup( + &cobra.Command{}, + nil, + map[string]any{"status": "running"}, + newRunner(&calls, "start"), + newRunner(&calls, "restart"), + ) + if err != nil { + t.Fatalf("dispatchDaemonAfterSetup: %v", err) + } + if got := strings.Join(calls, ","); got != "restart" { + t.Fatalf("calls = %q, want restart", got) + } + }) + + t.Run("starts when no daemon is running", func(t *testing.T) { + var calls []string + err := dispatchDaemonAfterSetup( + &cobra.Command{}, + nil, + map[string]any{"status": "stopped"}, + newRunner(&calls, "start"), + newRunner(&calls, "restart"), + ) + if err != nil { + t.Fatalf("dispatchDaemonAfterSetup: %v", err) + } + if got := strings.Join(calls, ","); got != "start" { + t.Fatalf("calls = %q, want start", got) + } + }) + + t.Run("does not restart while tasks are active", func(t *testing.T) { + var calls []string + err := dispatchDaemonAfterSetup( + &cobra.Command{}, + nil, + map[string]any{ + "status": "running", + "active_task_count": float64(2), + }, + newRunner(&calls, "start"), + newRunner(&calls, "restart"), + ) + if err == nil { + t.Fatal("dispatchDaemonAfterSetup: want active-task error") + } + if !strings.Contains(err.Error(), "2 active tasks") || + !strings.Contains(err.Error(), "multica daemon restart") { + t.Fatalf("error = %q, want active count and restart guidance", err) + } + if len(calls) != 0 { + t.Fatalf("calls = %v, want neither start nor restart", calls) + } + }) +} + // TestResolveSelfHostServerURL covers GitHub #3912: `setup self-host` must // honor MULTICA_SERVER_URL when --server-url is not passed, instead of always // defaulting to localhost (which left self-hosters stuck on an "unreachable"