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 5be74208..6ee0e0f6 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