From 8fa1b163a6b4624a29844509cb9cb1b7cdffadd1 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Mon, 30 Mar 2026 20:21:23 +0800 Subject: [PATCH] feat(daemon): add --profile flag for multi-environment isolation Allow running multiple daemon instances against different servers (e.g. production and local dev) simultaneously. Each profile gets isolated config, PID file, log file, health port, and workspaces root. Usage: multica login --profile dev --server-url http://localhost:8080 multica daemon start --profile dev Default profile (no --profile flag) behavior is unchanged. Closes MUL-42 --- server/cmd/multica/cmd_agent.go | 14 +++- server/cmd/multica/cmd_auth.go | 31 ++++--- server/cmd/multica/cmd_auth_test.go | 23 +++++- server/cmd/multica/cmd_compat_test.go | 5 +- server/cmd/multica/cmd_config.go | 17 ++-- server/cmd/multica/cmd_daemon.go | 113 ++++++++++++++++++-------- server/cmd/multica/cmd_login.go | 7 +- server/cmd/multica/cmd_workspace.go | 19 +++-- server/cmd/multica/main.go | 1 + server/internal/cli/config.go | 44 ++++++++-- server/internal/daemon/config.go | 28 ++++++- server/internal/daemon/daemon.go | 28 ++++--- 12 files changed, 240 insertions(+), 90 deletions(-) diff --git a/server/cmd/multica/cmd_agent.go b/server/cmd/multica/cmd_agent.go index 8606cc43..2daa4648 100644 --- a/server/cmd/multica/cmd_agent.go +++ b/server/cmd/multica/cmd_agent.go @@ -30,10 +30,16 @@ func init() { agentListCmd.Flags().String("output", "table", "Output format: table or json") } +// resolveProfile returns the --profile flag value (empty string means default profile). +func resolveProfile(cmd *cobra.Command) string { + val, _ := cmd.Flags().GetString("profile") + return val +} + func newAPIClient(cmd *cobra.Command) (*cli.APIClient, error) { serverURL := resolveServerURL(cmd) workspaceID := resolveWorkspaceID(cmd) - token := resolveToken() + token := resolveToken(cmd) if serverURL == "" { return nil, fmt.Errorf("server URL not set: use --server-url flag, MULTICA_SERVER_URL env, or 'multica config set server_url '") @@ -55,7 +61,8 @@ func resolveServerURL(cmd *cobra.Command) string { if val != "" { return normalizeAPIBaseURL(val) } - cfg, err := cli.LoadCLIConfig() + profile := resolveProfile(cmd) + cfg, err := cli.LoadCLIConfigForProfile(profile) if err != nil { return "http://localhost:8080" } @@ -78,7 +85,8 @@ func resolveWorkspaceID(cmd *cobra.Command) string { if val != "" { return val } - cfg, _ := cli.LoadCLIConfig() + profile := resolveProfile(cmd) + cfg, _ := cli.LoadCLIConfigForProfile(profile) return cfg.WorkspaceID } diff --git a/server/cmd/multica/cmd_auth.go b/server/cmd/multica/cmd_auth.go index cd4c3545..e20c9af8 100644 --- a/server/cmd/multica/cmd_auth.go +++ b/server/cmd/multica/cmd_auth.go @@ -52,21 +52,23 @@ func init() { authCmd.AddCommand(authLogoutCmd) } -func resolveToken() string { +func resolveToken(cmd *cobra.Command) string { if v := strings.TrimSpace(os.Getenv("MULTICA_TOKEN")); v != "" { return v } - cfg, _ := cli.LoadCLIConfig() + profile := resolveProfile(cmd) + cfg, _ := cli.LoadCLIConfigForProfile(profile) return cfg.Token } -func resolveAppURL() string { +func resolveAppURL(cmd *cobra.Command) string { for _, key := range []string{"MULTICA_APP_URL", "FRONTEND_ORIGIN"} { if val := strings.TrimSpace(os.Getenv(key)); val != "" { return strings.TrimRight(val, "/") } } - cfg, err := cli.LoadCLIConfig() + profile := resolveProfile(cmd) + cfg, err := cli.LoadCLIConfigForProfile(profile) if err == nil && cfg.AppURL != "" { return strings.TrimRight(cfg.AppURL, "/") } @@ -102,7 +104,7 @@ func runAuthLogin(cmd *cobra.Command, _ []string) error { func runAuthLoginBrowser(cmd *cobra.Command) error { serverURL := resolveServerURL(cmd) - appURL := resolveAppURL() + appURL := resolveAppURL(cmd) // Start a local HTTP server on a random port to receive the callback. listener, err := net.Listen("tcp", "127.0.0.1:0") @@ -205,13 +207,14 @@ func runAuthLoginBrowser(cmd *cobra.Command) error { // Save to config. Reset workspace data on every login — the user or // server may have changed, so stale workspaces must not persist. - cfg, _ := cli.LoadCLIConfig() + profile := resolveProfile(cmd) + cfg, _ := cli.LoadCLIConfigForProfile(profile) cfg.WorkspaceID = "" cfg.WatchedWorkspaces = nil cfg.Token = patResp.Token cfg.ServerURL = serverURL cfg.AppURL = appURL - if err := cli.SaveCLIConfig(cfg); err != nil { + if err := cli.SaveCLIConfigForProfile(cfg, profile); err != nil { return fmt.Errorf("failed to save config: %w", err) } @@ -247,12 +250,13 @@ func runAuthLoginToken(cmd *cobra.Command) error { return fmt.Errorf("invalid token: %w", err) } - cfg, _ := cli.LoadCLIConfig() + profile := resolveProfile(cmd) + cfg, _ := cli.LoadCLIConfigForProfile(profile) cfg.WorkspaceID = "" cfg.WatchedWorkspaces = nil cfg.Token = token cfg.ServerURL = serverURL - if err := cli.SaveCLIConfig(cfg); err != nil { + if err := cli.SaveCLIConfigForProfile(cfg, profile); err != nil { return fmt.Errorf("failed to save config: %w", err) } @@ -261,7 +265,7 @@ func runAuthLoginToken(cmd *cobra.Command) error { } func runAuthStatus(cmd *cobra.Command, _ []string) error { - token := resolveToken() + token := resolveToken(cmd) serverURL := resolveServerURL(cmd) if token == "" { @@ -331,15 +335,16 @@ const callbackSuccessHTML = ` ` -func runAuthLogout(_ *cobra.Command, _ []string) error { - cfg, _ := cli.LoadCLIConfig() +func runAuthLogout(cmd *cobra.Command, _ []string) error { + profile := resolveProfile(cmd) + cfg, _ := cli.LoadCLIConfigForProfile(profile) if cfg.Token == "" { fmt.Fprintln(os.Stderr, "Not authenticated.") return nil } cfg.Token = "" - if err := cli.SaveCLIConfig(cfg); err != nil { + if err := cli.SaveCLIConfigForProfile(cfg, profile); err != nil { return fmt.Errorf("failed to save config: %w", err) } diff --git a/server/cmd/multica/cmd_auth_test.go b/server/cmd/multica/cmd_auth_test.go index df9b6753..de881b44 100644 --- a/server/cmd/multica/cmd_auth_test.go +++ b/server/cmd/multica/cmd_auth_test.go @@ -1,13 +1,27 @@ package main -import "testing" +import ( + "testing" + + "github.com/spf13/cobra" +) + +// testCmd returns a minimal cobra.Command with the --profile persistent flag +// registered, matching the rootCmd setup used in production. +func testCmd() *cobra.Command { + cmd := &cobra.Command{} + cmd.PersistentFlags().String("profile", "", "") + return cmd +} func TestResolveAppURL(t *testing.T) { + cmd := testCmd() + t.Run("prefers MULTICA_APP_URL", func(t *testing.T) { t.Setenv("MULTICA_APP_URL", "http://localhost:14000") t.Setenv("FRONTEND_ORIGIN", "http://localhost:13000") - if got := resolveAppURL(); got != "http://localhost:14000" { + if got := resolveAppURL(cmd); got != "http://localhost:14000" { t.Fatalf("resolveAppURL() = %q, want %q", got, "http://localhost:14000") } }) @@ -16,7 +30,7 @@ func TestResolveAppURL(t *testing.T) { t.Setenv("MULTICA_APP_URL", "") t.Setenv("FRONTEND_ORIGIN", "http://localhost:13026") - if got := resolveAppURL(); got != "http://localhost:13026" { + if got := resolveAppURL(cmd); got != "http://localhost:13026" { t.Fatalf("resolveAppURL() = %q, want %q", got, "http://localhost:13026") } }) @@ -24,8 +38,9 @@ func TestResolveAppURL(t *testing.T) { t.Run("defaults to localhost 3000", func(t *testing.T) { t.Setenv("MULTICA_APP_URL", "") t.Setenv("FRONTEND_ORIGIN", "") + t.Setenv("HOME", t.TempDir()) // avoid reading real config - if got := resolveAppURL(); got != "http://localhost:3000" { + if got := resolveAppURL(cmd); got != "http://localhost:3000" { t.Fatalf("resolveAppURL() = %q, want %q", got, "http://localhost:3000") } }) diff --git a/server/cmd/multica/cmd_compat_test.go b/server/cmd/multica/cmd_compat_test.go index a3c20956..687aed9f 100644 --- a/server/cmd/multica/cmd_compat_test.go +++ b/server/cmd/multica/cmd_compat_test.go @@ -37,11 +37,12 @@ func TestLegacyCompatibilityCommandsRemainAvailable(t *testing.T) { func TestRunConfigSetPersistsValues(t *testing.T) { t.Setenv("HOME", t.TempDir()) + cmd := testCmd() - if err := runConfigSet(nil, []string{"server_url", "http://example.com"}); err != nil { + if err := runConfigSet(cmd, []string{"server_url", "http://example.com"}); err != nil { t.Fatalf("runConfigSet(server_url) error = %v", err) } - if err := runConfigSet(nil, []string{"workspace_id", "ws-123"}); err != nil { + if err := runConfigSet(cmd, []string{"workspace_id", "ws-123"}); err != nil { t.Fatalf("runConfigSet(workspace_id) error = %v", err) } diff --git a/server/cmd/multica/cmd_config.go b/server/cmd/multica/cmd_config.go index 96e5b3e5..a6cea730 100644 --- a/server/cmd/multica/cmd_config.go +++ b/server/cmd/multica/cmd_config.go @@ -34,24 +34,29 @@ func init() { configCmd.AddCommand(configSetCmd) } -func runConfigShow(_ *cobra.Command, _ []string) error { - cfg, err := cli.LoadCLIConfig() +func runConfigShow(cmd *cobra.Command, _ []string) error { + profile := resolveProfile(cmd) + cfg, err := cli.LoadCLIConfigForProfile(profile) if err != nil { return err } - path, _ := cli.CLIConfigPath() + path, _ := cli.CLIConfigPathForProfile(profile) fmt.Fprintf(os.Stdout, "Config file: %s\n", path) + if profile != "" { + fmt.Fprintf(os.Stdout, "Profile: %s\n", profile) + } fmt.Fprintf(os.Stdout, "server_url: %s\n", valueOrDefault(cfg.ServerURL, "(not set)")) fmt.Fprintf(os.Stdout, "app_url: %s\n", valueOrDefault(cfg.AppURL, "(not set)")) fmt.Fprintf(os.Stdout, "workspace_id: %s\n", valueOrDefault(cfg.WorkspaceID, "(not set)")) return nil } -func runConfigSet(_ *cobra.Command, args []string) error { +func runConfigSet(cmd *cobra.Command, args []string) error { key, value := args[0], args[1] - cfg, err := cli.LoadCLIConfig() + profile := resolveProfile(cmd) + cfg, err := cli.LoadCLIConfigForProfile(profile) if err != nil { return err } @@ -67,7 +72,7 @@ func runConfigSet(_ *cobra.Command, args []string) error { return fmt.Errorf("unknown config key %q (supported: server_url, app_url, workspace_id)", key) } - if err := cli.SaveCLIConfig(cfg); err != nil { + if err := cli.SaveCLIConfigForProfile(cfg, profile); err != nil { return err } diff --git a/server/cmd/multica/cmd_daemon.go b/server/cmd/multica/cmd_daemon.go index 49a4f056..6c70f40c 100644 --- a/server/cmd/multica/cmd_daemon.go +++ b/server/cmd/multica/cmd_daemon.go @@ -9,7 +9,6 @@ import ( "os" "os/exec" "os/signal" - "path/filepath" "strconv" "strings" "syscall" @@ -74,21 +73,37 @@ func init() { daemonCmd.AddCommand(daemonLogsCmd) } -// daemonDir returns the path to ~/.multica/. -func daemonDir() string { - home, err := os.UserHomeDir() +// daemonDirForProfile returns the state directory for the given profile. +// Empty profile → ~/.multica/, named profile → ~/.multica/profiles//. +func daemonDirForProfile(profile string) string { + dir, err := cli.ProfileDir(profile) if err != nil { return "" } - return filepath.Join(home, ".multica") + return dir } -func daemonPIDPath() string { - return filepath.Join(daemonDir(), "daemon.pid") +func daemonPIDPathForProfile(profile string) string { + return daemonDirForProfile(profile) + "/daemon.pid" } -func daemonLogPath() string { - return filepath.Join(daemonDir(), "daemon.log") +func daemonLogPathForProfile(profile string) string { + return daemonDirForProfile(profile) + "/daemon.log" +} + +// healthPortForProfile returns the health check port for the given profile. +// Default profile uses the standard port (19514). Named profiles get a +// deterministic offset derived from the profile name. +func healthPortForProfile(profile string) int { + if profile == "" { + return daemon.DefaultHealthPort + } + // Simple hash: sum of bytes mod 1000, offset from base+1. + var h int + for _, b := range []byte(profile) { + h += int(b) + } + return daemon.DefaultHealthPort + 1 + (h % 1000) } // --- daemon start --- @@ -102,12 +117,19 @@ func runDaemonStart(cmd *cobra.Command, _ []string) error { } func runDaemonBackground(cmd *cobra.Command) error { + profile := resolveProfile(cmd) + healthPort := healthPortForProfile(profile) + // Check if daemon is already running. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - health := checkDaemonHealth(ctx) + health := checkDaemonHealthOnPort(ctx, healthPort) if health["status"] == "running" { - return fmt.Errorf("daemon is already running (pid %v)", health["pid"]) + label := "daemon" + if profile != "" { + label = fmt.Sprintf("daemon [%s]", profile) + } + return fmt.Errorf("%s is already running (pid %v)", label, health["pid"]) } // Resolve current executable. @@ -120,12 +142,12 @@ func runDaemonBackground(cmd *cobra.Command) error { args := buildDaemonStartArgs(cmd) // Ensure daemon directory exists. - dir := daemonDir() + dir := daemonDirForProfile(profile) if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("create daemon directory: %w", err) } - logPath := daemonLogPath() + logPath := daemonLogPathForProfile(profile) logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return fmt.Errorf("open log file %s: %w", logPath, err) @@ -146,7 +168,7 @@ func runDaemonBackground(cmd *cobra.Command) error { child.Process.Release() // Write PID file. - pidPath := daemonPIDPath() + pidPath := daemonPIDPathForProfile(profile) if err := os.WriteFile(pidPath, []byte(strconv.Itoa(child.Process.Pid)), 0o644); err != nil { fmt.Fprintf(os.Stderr, "Warning: could not write PID file: %v\n", err) } @@ -155,13 +177,17 @@ func runDaemonBackground(cmd *cobra.Command) error { time.Sleep(2 * time.Second) ctx2, cancel2 := context.WithTimeout(context.Background(), 3*time.Second) defer cancel2() - health = checkDaemonHealth(ctx2) + health = checkDaemonHealthOnPort(ctx2, healthPort) if health["status"] != "running" { fmt.Fprintf(os.Stderr, "Daemon may not have started successfully. Check logs:\n %s\n", logPath) return nil } - fmt.Fprintf(os.Stderr, "Daemon started (pid %d)\n", child.Process.Pid) + if profile != "" { + fmt.Fprintf(os.Stderr, "Daemon [%s] started (pid %d)\n", profile, child.Process.Pid) + } else { + fmt.Fprintf(os.Stderr, "Daemon started (pid %d)\n", child.Process.Pid) + } fmt.Fprintf(os.Stderr, "Logs: %s\n", logPath) return nil } @@ -196,14 +222,19 @@ func buildDaemonStartArgs(cmd *cobra.Command) []string { if v, _ := cmd.Flags().GetString("server-url"); v != "" { args = append(args, "--server-url", v) } + if v := resolveProfile(cmd); v != "" { + args = append(args, "--profile", v) + } return args } func runDaemonForeground(cmd *cobra.Command) error { + profile := resolveProfile(cmd) + serverURL := cli.FlagOrEnv(cmd, "server-url", "MULTICA_SERVER_URL", "") if serverURL == "" { - if c, err := cli.LoadCLIConfig(); err == nil && c.ServerURL != "" { + if c, err := cli.LoadCLIConfigForProfile(profile); err == nil && c.ServerURL != "" { serverURL = c.ServerURL } } @@ -212,6 +243,8 @@ func runDaemonForeground(cmd *cobra.Command) error { DaemonID: flagString(cmd, "daemon-id"), DeviceName: flagString(cmd, "device-name"), RuntimeName: flagString(cmd, "runtime-name"), + Profile: profile, + HealthPort: healthPortForProfile(profile), } if d, _ := cmd.Flags().GetDuration("poll-interval"); d > 0 { overrides.PollInterval = d @@ -238,11 +271,11 @@ func runDaemonForeground(cmd *cobra.Command) error { d := daemon.New(cfg, logger) // Write PID file so "daemon stop" can find us. - if dir := daemonDir(); dir != "" { + if dir := daemonDirForProfile(profile); dir != "" { os.MkdirAll(dir, 0o755) - os.WriteFile(daemonPIDPath(), []byte(strconv.Itoa(os.Getpid())), 0o644) + os.WriteFile(daemonPIDPathForProfile(profile), []byte(strconv.Itoa(os.Getpid())), 0o644) } - defer os.Remove(daemonPIDPath()) + defer os.Remove(daemonPIDPathForProfile(profile)) if err := d.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { return err @@ -252,13 +285,20 @@ func runDaemonForeground(cmd *cobra.Command) error { // --- daemon stop --- -func runDaemonStop(_ *cobra.Command, _ []string) error { +func runDaemonStop(cmd *cobra.Command, _ []string) error { + profile := resolveProfile(cmd) + healthPort := healthPortForProfile(profile) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - health := checkDaemonHealth(ctx) + health := checkDaemonHealthOnPort(ctx, healthPort) if health["status"] != "running" { - fmt.Fprintln(os.Stderr, "Daemon is not running.") + label := "Daemon" + if profile != "" { + label = fmt.Sprintf("Daemon [%s]", profile) + } + fmt.Fprintf(os.Stderr, "%s is not running.\n", label) return nil } @@ -282,10 +322,10 @@ func runDaemonStop(_ *cobra.Command, _ []string) error { for i := 0; i < 10; i++ { time.Sleep(500 * time.Millisecond) ctx2, cancel2 := context.WithTimeout(context.Background(), 1*time.Second) - h := checkDaemonHealth(ctx2) + h := checkDaemonHealthOnPort(ctx2, healthPort) cancel2() if h["status"] != "running" { - os.Remove(daemonPIDPath()) + os.Remove(daemonPIDPathForProfile(profile)) fmt.Fprintln(os.Stderr, "Daemon stopped.") return nil } @@ -298,22 +338,30 @@ func runDaemonStop(_ *cobra.Command, _ []string) error { // --- daemon status --- func runDaemonStatus(cmd *cobra.Command, _ []string) error { + profile := resolveProfile(cmd) + healthPort := healthPortForProfile(profile) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - health := checkDaemonHealth(ctx) + health := checkDaemonHealthOnPort(ctx, healthPort) output, _ := cmd.Flags().GetString("output") if output == "json" { return cli.PrintJSON(os.Stdout, health) } + label := "Daemon" + if profile != "" { + label = fmt.Sprintf("Daemon [%s]", profile) + } + if health["status"] != "running" { - fmt.Fprintln(os.Stdout, "Daemon: stopped") + fmt.Fprintf(os.Stdout, "%s: stopped\n", label) return nil } - fmt.Fprintf(os.Stdout, "Daemon: running (pid %v, uptime %v)\n", health["pid"], health["uptime"]) + fmt.Fprintf(os.Stdout, "%s: running (pid %v, uptime %v)\n", label, health["pid"], health["uptime"]) if agents, ok := health["agents"].([]any); ok && len(agents) > 0 { parts := make([]string, len(agents)) for i, a := range agents { @@ -330,7 +378,8 @@ func runDaemonStatus(cmd *cobra.Command, _ []string) error { // --- daemon logs --- func runDaemonLogs(cmd *cobra.Command, _ []string) error { - logPath := daemonLogPath() + profile := resolveProfile(cmd) + logPath := daemonLogPathForProfile(profile) if _, err := os.Stat(logPath); os.IsNotExist(err) { return fmt.Errorf("no log file found at %s\nThe daemon may not have been started in background mode", logPath) } @@ -350,9 +399,9 @@ func runDaemonLogs(cmd *cobra.Command, _ []string) error { return tail.Run() } -// checkDaemonHealth calls the daemon's local health endpoint. -func checkDaemonHealth(ctx context.Context) map[string]any { - addr := fmt.Sprintf("http://127.0.0.1:%d/health", daemon.DefaultHealthPort) +// checkDaemonHealthOnPort calls the daemon's local health endpoint on the given port. +func checkDaemonHealthOnPort(ctx context.Context, port int) map[string]any { + addr := fmt.Sprintf("http://127.0.0.1:%d/health", port) req, err := http.NewRequestWithContext(ctx, http.MethodGet, addr, nil) if err != nil { return map[string]any{"status": "stopped"} diff --git a/server/cmd/multica/cmd_login.go b/server/cmd/multica/cmd_login.go index 0ab2691a..d1f47bf5 100644 --- a/server/cmd/multica/cmd_login.go +++ b/server/cmd/multica/cmd_login.go @@ -41,7 +41,7 @@ func runLogin(cmd *cobra.Command, args []string) error { func autoWatchWorkspaces(cmd *cobra.Command) error { serverURL := resolveServerURL(cmd) - token := resolveToken() + token := resolveToken(cmd) if token == "" { return fmt.Errorf("not authenticated") } @@ -63,7 +63,8 @@ func autoWatchWorkspaces(cmd *cobra.Command) error { return nil } - cfg, err := cli.LoadCLIConfig() + profile := resolveProfile(cmd) + cfg, err := cli.LoadCLIConfigForProfile(profile) if err != nil { return err } @@ -80,7 +81,7 @@ func autoWatchWorkspaces(cmd *cobra.Command) error { cfg.WorkspaceID = workspaces[0].ID } - if err := cli.SaveCLIConfig(cfg); err != nil { + if err := cli.SaveCLIConfigForProfile(cfg, profile); err != nil { return err } diff --git a/server/cmd/multica/cmd_workspace.go b/server/cmd/multica/cmd_workspace.go index da1ce556..4461ffa4 100644 --- a/server/cmd/multica/cmd_workspace.go +++ b/server/cmd/multica/cmd_workspace.go @@ -65,7 +65,7 @@ func init() { func runWorkspaceList(cmd *cobra.Command, _ []string) error { serverURL := resolveServerURL(cmd) - token := resolveToken() + token := resolveToken(cmd) if token == "" { return fmt.Errorf("not authenticated: run 'multica login' first") } @@ -88,7 +88,8 @@ func runWorkspaceList(cmd *cobra.Command, _ []string) error { } // Load watched set for marking. - cfg, _ := cli.LoadCLIConfig() + profile := resolveProfile(cmd) + cfg, _ := cli.LoadCLIConfigForProfile(profile) watched := make(map[string]bool) for _, w := range cfg.WatchedWorkspaces { watched[w.ID] = true @@ -201,7 +202,7 @@ func runWatch(cmd *cobra.Command, args []string) error { workspaceID := args[0] serverURL := resolveServerURL(cmd) - token := resolveToken() + token := resolveToken(cmd) if token == "" { return fmt.Errorf("not authenticated: run 'multica login' first") } @@ -218,7 +219,8 @@ func runWatch(cmd *cobra.Command, args []string) error { return fmt.Errorf("workspace not found: %w", err) } - cfg, err := cli.LoadCLIConfig() + profile := resolveProfile(cmd) + cfg, err := cli.LoadCLIConfigForProfile(profile) if err != nil { return err } @@ -233,7 +235,7 @@ func runWatch(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "Set default workspace to %s (%s)\n", ws.ID, ws.Name) } - if err := cli.SaveCLIConfig(cfg); err != nil { + if err := cli.SaveCLIConfigForProfile(cfg, profile); err != nil { return err } @@ -241,10 +243,11 @@ func runWatch(cmd *cobra.Command, args []string) error { return nil } -func runUnwatch(_ *cobra.Command, args []string) error { +func runUnwatch(cmd *cobra.Command, args []string) error { workspaceID := args[0] - cfg, err := cli.LoadCLIConfig() + profile := resolveProfile(cmd) + cfg, err := cli.LoadCLIConfigForProfile(profile) if err != nil { return err } @@ -253,7 +256,7 @@ func runUnwatch(_ *cobra.Command, args []string) error { return fmt.Errorf("workspace %s is not being watched", workspaceID) } - if err := cli.SaveCLIConfig(cfg); err != nil { + if err := cli.SaveCLIConfigForProfile(cfg, profile); err != nil { return err } diff --git a/server/cmd/multica/main.go b/server/cmd/multica/main.go index f44882e0..e8007c49 100644 --- a/server/cmd/multica/main.go +++ b/server/cmd/multica/main.go @@ -23,6 +23,7 @@ var rootCmd = &cobra.Command{ func init() { rootCmd.PersistentFlags().String("server-url", "", "Multica server URL (env: MULTICA_SERVER_URL)") rootCmd.PersistentFlags().String("workspace-id", "", "Workspace ID (env: MULTICA_WORKSPACE_ID)") + rootCmd.PersistentFlags().String("profile", "", "Configuration profile name (e.g. dev) — isolates config, daemon state, and workspaces") rootCmd.AddCommand(loginCmd) rootCmd.AddCommand(authCmd) diff --git a/server/internal/cli/config.go b/server/internal/cli/config.go index 31bf6240..d8d74c01 100644 --- a/server/internal/cli/config.go +++ b/server/internal/cli/config.go @@ -47,19 +47,46 @@ func (c *CLIConfig) RemoveWatchedWorkspace(id string) bool { return false } - // CLIConfigPath returns the default path for the CLI config file. func CLIConfigPath() (string, error) { + return CLIConfigPathForProfile("") +} + +// CLIConfigPathForProfile returns the config file path for the given profile. +// An empty profile returns the default path (~/.multica/config.json). +// A named profile returns ~/.multica/profiles//config.json. +func CLIConfigPathForProfile(profile string) (string, error) { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("resolve CLI config path: %w", err) } - return filepath.Join(home, defaultCLIConfigPath), nil + if profile == "" { + return filepath.Join(home, defaultCLIConfigPath), nil + } + return filepath.Join(home, ".multica", "profiles", profile, "config.json"), nil } -// LoadCLIConfig reads the CLI config from disk. +// ProfileDir returns the base directory for a profile's state files (pid, log). +// An empty profile returns ~/.multica/. A named profile returns ~/.multica/profiles//. +func ProfileDir(profile string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve profile dir: %w", err) + } + if profile == "" { + return filepath.Join(home, ".multica"), nil + } + return filepath.Join(home, ".multica", "profiles", profile), nil +} + +// LoadCLIConfig reads the CLI config from disk (default profile). func LoadCLIConfig() (CLIConfig, error) { - path, err := CLIConfigPath() + return LoadCLIConfigForProfile("") +} + +// LoadCLIConfigForProfile reads the CLI config for the given profile. +func LoadCLIConfigForProfile(profile string) (CLIConfig, error) { + path, err := CLIConfigPathForProfile(profile) if err != nil { return CLIConfig{}, err } @@ -77,9 +104,14 @@ func LoadCLIConfig() (CLIConfig, error) { return cfg, nil } -// SaveCLIConfig writes the CLI config to disk atomically (write to temp, then rename). +// SaveCLIConfig writes the CLI config to disk atomically (default profile). func SaveCLIConfig(cfg CLIConfig) error { - path, err := CLIConfigPath() + return SaveCLIConfigForProfile(cfg, "") +} + +// SaveCLIConfigForProfile writes the CLI config for the given profile. +func SaveCLIConfigForProfile(cfg CLIConfig, profile string) error { + path, err := CLIConfigPathForProfile(profile) if err != nil { return err } diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go index 756560da..cd2bf7e7 100644 --- a/server/internal/daemon/config.go +++ b/server/internal/daemon/config.go @@ -28,6 +28,7 @@ type Config struct { DaemonID string DeviceName string RuntimeName string + Profile string // profile name (empty = default) Agents map[string]AgentEntry // "claude" -> entry, "codex" -> entry WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces) KeepEnvAfterTask bool // preserve env after task for debugging @@ -50,6 +51,8 @@ type Overrides struct { DaemonID string DeviceName string RuntimeName string + Profile string // profile name (empty = default) + HealthPort int // health check port (0 = use default) } // LoadConfig builds the daemon configuration from environment variables @@ -124,11 +127,19 @@ func LoadConfig(overrides Overrides) (Config, error) { maxConcurrentTasks = overrides.MaxConcurrentTasks } + // Profile + profile := overrides.Profile + // String overrides daemonID := envOrDefault("MULTICA_DAEMON_ID", host) if overrides.DaemonID != "" { daemonID = overrides.DaemonID } + // Suffix daemon ID with profile name to avoid collisions when multiple + // daemons register against the same server. + if profile != "" && !strings.HasSuffix(daemonID, "-"+profile) { + daemonID = daemonID + "-" + profile + } deviceName := envOrDefault("MULTICA_DAEMON_DEVICE_NAME", host) if overrides.DeviceName != "" { @@ -140,7 +151,7 @@ func LoadConfig(overrides Overrides) (Config, error) { runtimeName = overrides.RuntimeName } - // Workspaces root: override > env > default (~/multica_workspaces) + // Workspaces root: override > env > default (~/multica_workspaces or ~/multica_workspaces_) workspacesRoot := strings.TrimSpace(os.Getenv("MULTICA_WORKSPACES_ROOT")) if overrides.WorkspacesRoot != "" { workspacesRoot = overrides.WorkspacesRoot @@ -150,13 +161,23 @@ func LoadConfig(overrides Overrides) (Config, error) { if err != nil { return Config{}, fmt.Errorf("resolve home directory: %w (set MULTICA_WORKSPACES_ROOT to override)", err) } - workspacesRoot = filepath.Join(home, "multica_workspaces") + if profile != "" { + workspacesRoot = filepath.Join(home, "multica_workspaces_"+profile) + } else { + workspacesRoot = filepath.Join(home, "multica_workspaces") + } } workspacesRoot, err = filepath.Abs(workspacesRoot) if err != nil { return Config{}, fmt.Errorf("resolve absolute workspaces root: %w", err) } + // Health port: override > default + healthPort := DefaultHealthPort + if overrides.HealthPort > 0 { + healthPort = overrides.HealthPort + } + // Keep env after task: env > default (false) keepEnv := os.Getenv("MULTICA_KEEP_ENV_AFTER_TASK") == "true" || os.Getenv("MULTICA_KEEP_ENV_AFTER_TASK") == "1" @@ -165,10 +186,11 @@ func LoadConfig(overrides Overrides) (Config, error) { DaemonID: daemonID, DeviceName: deviceName, RuntimeName: runtimeName, + Profile: profile, Agents: agents, WorkspacesRoot: workspacesRoot, KeepEnvAfterTask: keepEnv, - HealthPort: DefaultHealthPort, + HealthPort: healthPort, MaxConcurrentTasks: maxConcurrentTasks, PollInterval: pollInterval, HeartbeatInterval: heartbeatInterval, diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index cc782df1..a84fd370 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -62,7 +62,11 @@ func (d *Daemon) Run(ctx context.Context) error { for name := range d.cfg.Agents { agentNames = append(agentNames, name) } - d.logger.Info("starting daemon", "agents", agentNames, "server", d.cfg.ServerBaseURL) + logFields := []any{"agents", agentNames, "server", d.cfg.ServerBaseURL} + if d.cfg.Profile != "" { + logFields = append(logFields, "profile", d.cfg.Profile) + } + d.logger.Info("starting daemon", logFields...) // Load auth token from CLI config. if err := d.resolveAuth(); err != nil { @@ -111,15 +115,19 @@ func (d *Daemon) deregisterRuntimes() { } } -// resolveAuth loads the auth token from the CLI config. +// resolveAuth loads the auth token from the CLI config for the active profile. func (d *Daemon) resolveAuth() error { - cfg, err := cli.LoadCLIConfig() + cfg, err := cli.LoadCLIConfigForProfile(d.cfg.Profile) if err != nil { return fmt.Errorf("load CLI config: %w", err) } if cfg.Token == "" { - d.logger.Warn("not authenticated — run 'multica login' to authenticate, then restart the daemon") - return fmt.Errorf("not authenticated: run 'multica login' first") + loginHint := "'multica login'" + if d.cfg.Profile != "" { + loginHint = fmt.Sprintf("'multica login --profile %s'", d.cfg.Profile) + } + d.logger.Warn("not authenticated — run " + loginHint + " to authenticate, then restart the daemon") + return fmt.Errorf("not authenticated: run %s first", loginHint) } d.client.SetToken(cfg.Token) d.logger.Info("authenticated") @@ -128,7 +136,7 @@ func (d *Daemon) resolveAuth() error { // loadWatchedWorkspaces reads watched workspaces from CLI config and registers runtimes. func (d *Daemon) loadWatchedWorkspaces(ctx context.Context) error { - cfg, err := cli.LoadCLIConfig() + cfg, err := cli.LoadCLIConfigForProfile(d.cfg.Profile) if err != nil { return fmt.Errorf("load CLI config: %w", err) } @@ -247,7 +255,7 @@ func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID s // configWatchLoop periodically checks for config file changes and reloads workspaces. func (d *Daemon) configWatchLoop(ctx context.Context) { - configPath, err := cli.CLIConfigPath() + configPath, err := cli.CLIConfigPathForProfile(d.cfg.Profile) if err != nil { d.logger.Warn("cannot watch config file", "error", err) return @@ -311,7 +319,7 @@ func (d *Daemon) syncWorkspacesFromAPI(ctx context.Context) { return } - cfg, err := cli.LoadCLIConfig() + cfg, err := cli.LoadCLIConfigForProfile(d.cfg.Profile) if err != nil { d.logger.Warn("workspace sync: failed to load config", "error", err) return @@ -329,7 +337,7 @@ func (d *Daemon) syncWorkspacesFromAPI(ctx context.Context) { return } - if err := cli.SaveCLIConfig(cfg); err != nil { + if err := cli.SaveCLIConfigForProfile(cfg, d.cfg.Profile); err != nil { d.logger.Warn("workspace sync: failed to save config", "error", err) return } @@ -343,7 +351,7 @@ func (d *Daemon) reloadWorkspaces(ctx context.Context) { d.reloading.Lock() defer d.reloading.Unlock() - cfg, err := cli.LoadCLIConfig() + cfg, err := cli.LoadCLIConfigForProfile(d.cfg.Profile) if err != nil { d.logger.Warn("reload config failed", "error", err) return