mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 22:17:48 +02:00
* fix(cli): make browser-login work from a machine that isn't the server The #923 callback host fix only worked when the CLI and the self-hosted server ran on the same box. In a cross-machine setup — `multica login` from a laptop against a self-hosted server on a NAS — the flow silently wedged on two issues: 1. The callback host was derived from `--app-url`, so the `cli_callback` URL pointed at the server's IP and the browser could never reach the CLI's local listener on the laptop. The OAuth token never came back and subsequent `/api/workspaces` calls 401'd on stale state. 2. `net.Listen("tcp", ...)` on macOS can produce an IPv6-only socket. Browsers and `curl` resolve `localhost`/`127.0.0.1` to IPv4 first and get "connection refused" even when the URL is otherwise correct. Changes: - Derive the callback host from the CLI's own outbound interface by dialing the server (UDP, no packets sent — just asks the kernel which source IP it would use). Falls back to loopback for public app URLs and to the app IP for offline detection. - Add `--callback-host` flag on `login` and `setup self-host` so reverse-proxy / FQDN users can override auto-detection — this is the follow-up @hassaanz asked for on #923. - Pin the callback listener to `tcp4` so macOS never lands on an IPv6-only socket. - `multica setup self-host`: when the user explicitly passes a remote `--server-url` but omits `--app-url`, infer app URL from the server host and warn instead of silently defaulting to `localhost:3000`. Unit tests cover the binding-decision matrix (public, localhost, same- machine LAN, cross-machine LAN, outbound-detect failure, flag override) and the new setup helpers. Reported by @RafeRoberts in #1494 with very clear repro details. * fix(cli): prompt for app_url instead of guessing on remote server_url Per GPT-Boy's review on MUL-1260: deriving app_url as http://<server-host>:3000 breaks for the common api.example.com + app.example.com split and for https-fronted deploys — the setup flow would still open a broken login URL, just slightly later. Replace the guess with an interactive prompt. If the user hits enter (or stdin is unavailable), fail loudly with a clear usage hint instead of proceeding with bad data.
279 lines
8.7 KiB
Go
279 lines
8.7 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/multica-ai/multica/server/internal/cli"
|
|
)
|
|
|
|
var setupCmd = &cobra.Command{
|
|
Use: "setup",
|
|
Short: "Configure the CLI, authenticate, and start the daemon",
|
|
Long: `Configures the CLI to connect to Multica Cloud (multica.ai), then
|
|
authenticates via browser and starts the agent daemon.
|
|
|
|
If a configuration already exists, you will be prompted before overwriting.
|
|
|
|
Use 'multica setup self-host' to connect to a self-hosted server instead.
|
|
|
|
Use --profile to create an isolated configuration for a separate environment:
|
|
multica setup self-host --profile staging --server-url https://api-staging.co`,
|
|
RunE: runSetupCloud,
|
|
}
|
|
|
|
var setupCloudCmd = &cobra.Command{
|
|
Use: "cloud",
|
|
Short: "Configure the CLI for Multica Cloud (multica.ai)",
|
|
Long: `Explicitly configures the CLI to connect to Multica Cloud (multica.ai).
|
|
|
|
This is equivalent to running 'multica setup' without a subcommand.`,
|
|
RunE: runSetupCloud,
|
|
}
|
|
|
|
var setupSelfHostCmd = &cobra.Command{
|
|
Use: "self-host",
|
|
Short: "Configure the CLI for a self-hosted Multica server",
|
|
Long: `Configures the CLI to connect to a self-hosted Multica server.
|
|
|
|
By default, connects to http://localhost:8080 (backend) and http://localhost:3000 (frontend).
|
|
Use --server-url and --app-url to specify a custom server (e.g. an on-premise deployment).
|
|
|
|
If you run this command from a different machine than the server, also pass
|
|
--callback-host <FQDN-or-IP-the-browser-can-reach-back-to-this-machine-on> so
|
|
the OAuth login flow can return the token to the CLI.
|
|
|
|
Examples:
|
|
multica setup self-host
|
|
multica setup self-host --server-url https://api.internal.co --app-url https://app.internal.co
|
|
multica setup self-host --port 9090 --frontend-port 4000`,
|
|
RunE: runSetupSelfHost,
|
|
}
|
|
|
|
func init() {
|
|
setupSelfHostCmd.Flags().String("server-url", "", "Backend server URL (e.g. https://api.internal.co)")
|
|
setupSelfHostCmd.Flags().String("app-url", "", "Frontend app URL (e.g. https://app.internal.co)")
|
|
setupSelfHostCmd.Flags().Int("port", 8080, "Backend server port (used when --server-url is not set)")
|
|
setupSelfHostCmd.Flags().Int("frontend-port", 3000, "Frontend port (used when --app-url is not set)")
|
|
setupSelfHostCmd.Flags().String(callbackHostFlag, "", "Host the OAuth callback URL points at (auto-detected when empty). Use this for reverse-proxy / FQDN setups.")
|
|
|
|
setupCmd.AddCommand(setupCloudCmd)
|
|
setupCmd.AddCommand(setupSelfHostCmd)
|
|
}
|
|
|
|
// printConfigLocation prints the config file path and profile name.
|
|
func printConfigLocation(profile string) {
|
|
path, err := cli.CLIConfigPathForProfile(profile)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if profile != "" {
|
|
fmt.Fprintf(os.Stderr, " profile: %s\n", profile)
|
|
}
|
|
fmt.Fprintf(os.Stderr, " config: %s\n", path)
|
|
}
|
|
|
|
// confirmOverwrite checks for an existing config and prompts the user.
|
|
// Returns true if we should proceed, false if the user declined.
|
|
func confirmOverwrite(profile string) (bool, error) {
|
|
cfg, err := cli.LoadCLIConfigForProfile(profile)
|
|
if err != nil {
|
|
return true, nil // can't load → treat as no config
|
|
}
|
|
if cfg.ServerURL == "" {
|
|
return true, nil // no server configured → fresh config
|
|
}
|
|
|
|
fmt.Fprintln(os.Stderr, "Current configuration:")
|
|
fmt.Fprintf(os.Stderr, " server_url: %s\n", cfg.ServerURL)
|
|
fmt.Fprintf(os.Stderr, " app_url: %s\n", cfg.AppURL)
|
|
if cfg.WorkspaceID != "" {
|
|
fmt.Fprintf(os.Stderr, " workspace: %s\n", cfg.WorkspaceID)
|
|
}
|
|
fmt.Fprintln(os.Stderr, "")
|
|
fmt.Fprint(os.Stderr, "This will reset your configuration. Continue? [y/N] ")
|
|
|
|
reader := bufio.NewReader(os.Stdin)
|
|
answer, _ := reader.ReadString('\n')
|
|
answer = strings.TrimSpace(strings.ToLower(answer))
|
|
if answer != "y" && answer != "yes" {
|
|
fmt.Fprintln(os.Stderr, "Aborted.")
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func runSetupCloud(cmd *cobra.Command, args []string) error {
|
|
profile := resolveProfile(cmd)
|
|
|
|
ok, err := confirmOverwrite(profile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
cfg := cli.CLIConfig{
|
|
ServerURL: "https://api.multica.ai",
|
|
AppURL: "https://multica.ai",
|
|
}
|
|
if err := cli.SaveCLIConfigForProfile(cfg, profile); err != nil {
|
|
return fmt.Errorf("save config: %w", err)
|
|
}
|
|
|
|
fmt.Fprintln(os.Stderr, "Configured for Multica Cloud (https://multica.ai).")
|
|
fmt.Fprintf(os.Stderr, " server_url: %s\n", cfg.ServerURL)
|
|
fmt.Fprintf(os.Stderr, " app_url: %s\n", cfg.AppURL)
|
|
printConfigLocation(profile)
|
|
|
|
// Authenticate.
|
|
fmt.Fprintln(os.Stderr, "")
|
|
if err := runLogin(cmd, args); err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Fprintln(os.Stderr, "\nStarting daemon...")
|
|
if err := runDaemonBackground(cmd); err != nil {
|
|
return fmt.Errorf("start daemon: %w", err)
|
|
}
|
|
fmt.Fprintln(os.Stderr, "\n✓ Setup complete! Your machine is now connected to Multica.")
|
|
|
|
return nil
|
|
}
|
|
|
|
func runSetupSelfHost(cmd *cobra.Command, args []string) error {
|
|
profile := resolveProfile(cmd)
|
|
|
|
ok, err := confirmOverwrite(profile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
serverURL, _ := cmd.Flags().GetString("server-url")
|
|
appURL, _ := cmd.Flags().GetString("app-url")
|
|
port, _ := cmd.Flags().GetInt("port")
|
|
frontendPort, _ := cmd.Flags().GetInt("frontend-port")
|
|
userProvidedServerURL := serverURL != ""
|
|
|
|
// If custom URLs provided, use them; otherwise default to localhost with ports.
|
|
if serverURL == "" {
|
|
serverURL = fmt.Sprintf("http://localhost:%d", port)
|
|
}
|
|
if appURL == "" {
|
|
if userProvidedServerURL && !serverHostIsLocal(serverURL) {
|
|
// We can't guess the frontend URL for a remote server: api.x.co
|
|
// and app.x.co, or an https-fronted deployment, would silently
|
|
// produce a broken login URL. Ask the user instead.
|
|
entered, err := promptAppURL(serverURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if entered == "" {
|
|
return fmt.Errorf("--app-url is required when --server-url points at a remote host (e.g. --app-url https://app.internal.co)")
|
|
}
|
|
appURL = entered
|
|
} else {
|
|
appURL = fmt.Sprintf("http://localhost:%d", frontendPort)
|
|
}
|
|
}
|
|
|
|
cfg := cli.CLIConfig{
|
|
ServerURL: serverURL,
|
|
AppURL: appURL,
|
|
}
|
|
if err := cli.SaveCLIConfigForProfile(cfg, profile); err != nil {
|
|
return fmt.Errorf("save config: %w", err)
|
|
}
|
|
|
|
fmt.Fprintln(os.Stderr, "Configured for self-hosted server.")
|
|
fmt.Fprintf(os.Stderr, " server_url: %s\n", cfg.ServerURL)
|
|
fmt.Fprintf(os.Stderr, " app_url: %s\n", cfg.AppURL)
|
|
printConfigLocation(profile)
|
|
|
|
// Check if the server is reachable.
|
|
if !probeServer(serverURL) {
|
|
fmt.Fprintf(os.Stderr, "\n⚠ Server at %s is not reachable.\n", serverURL)
|
|
fmt.Fprintln(os.Stderr, " Make sure the server is running, then run 'multica login'.")
|
|
return nil
|
|
}
|
|
|
|
// Authenticate.
|
|
fmt.Fprintln(os.Stderr, "")
|
|
if err := runLogin(cmd, args); err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Fprintln(os.Stderr, "\nStarting daemon...")
|
|
if err := runDaemonBackground(cmd); err != nil {
|
|
return fmt.Errorf("start daemon: %w", err)
|
|
}
|
|
fmt.Fprintln(os.Stderr, "\n✓ Setup complete! Your machine is now connected to Multica.")
|
|
|
|
return nil
|
|
}
|
|
|
|
// serverHostIsLocal reports whether serverURL points at the same machine as
|
|
// the CLI (loopback literal or "localhost"). Used to decide whether to infer
|
|
// app_url from server_url or fall back to the local-dev default.
|
|
func serverHostIsLocal(serverURL string) bool {
|
|
parsed, err := url.Parse(serverURL)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
h := parsed.Hostname()
|
|
if h == "localhost" {
|
|
return true
|
|
}
|
|
if ip := net.ParseIP(h); ip != nil {
|
|
return ip.IsLoopback()
|
|
}
|
|
return false
|
|
}
|
|
|
|
// promptAppURL asks the user for the frontend URL interactively. We can't
|
|
// derive it from a remote server_url — api.example.com ≠ app.example.com in
|
|
// most production setups — so guessing would just defer the failure to the
|
|
// browser login step. Returns an empty string if the user hits enter.
|
|
func promptAppURL(serverURL string) (string, error) {
|
|
fmt.Fprintf(os.Stderr, "No --app-url provided, and --server-url (%s) is remote.\n", serverURL)
|
|
fmt.Fprint(os.Stderr, "Enter the frontend app URL (e.g. https://app.internal.co): ")
|
|
reader := bufio.NewReader(os.Stdin)
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil && line == "" {
|
|
return "", nil
|
|
}
|
|
return strings.TrimRight(strings.TrimSpace(line), "/"), nil
|
|
}
|
|
|
|
// probeServer checks whether a Multica backend is reachable at the given URL.
|
|
func probeServer(baseURL string) bool {
|
|
url := strings.TrimRight(baseURL, "/") + "/health"
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
resp, err := (&http.Client{Timeout: 2 * time.Second}).Do(req)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer resp.Body.Close()
|
|
return resp.StatusCode == http.StatusOK
|
|
}
|