Merge pull request #197 from multica-ai/forrestchang/daemon-profile

feat(daemon): add --profile flag for multi-environment isolation
This commit is contained in:
Jiayuan Zhang 2026-03-30 20:24:03 +08:00 committed by GitHub
commit 72e3ccfe33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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