Merge pull request #197 from multica-ai/forrestchang/daemon-profile
feat(daemon): add --profile flag for multi-environment isolation
This commit is contained in:
commit
72e3ccfe33
12 changed files with 240 additions and 90 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue