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
This commit is contained in:
Jiayuan 2026-03-30 20:21:23 +08:00
parent 9c3ff52363
commit 8fa1b163a6
12 changed files with 240 additions and 90 deletions

View file

@ -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 <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
}

View file

@ -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 = `<!DOCTYPE html>
</body>
</html>`
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)
}

View file

@ -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")
}
})

View file

@ -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)
}

View file

@ -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
}

View file

@ -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/<name>/.
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"}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)

View file

@ -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/<name>/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/<name>/.
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
}

View file

@ -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_<profile>)
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,

View file

@ -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