// Package config loads the HarborForge plugin's per-profile config // from /plugins/harbor-forge/config.json. Mirrors the // resolved-config shape HarborForge.OpenclawPlugin's // plugin/core/config.ts surfaces, adapted for the Plexum profile // layout. package config import ( "encoding/json" "errors" "fmt" "os" "path/filepath" ) // Config is the operator-visible shape stored at // ~/.plexum/plugins/harbor-forge/config.json. All fields optional; // resolved defaults live in Resolve(). type Config struct { Enabled bool `json:"enabled,omitempty"` // BackendURL is the HarborForge backend base (Monitor + Calendar // API share this URL — same as OpenclawPlugin's backendUrl). BackendURL string `json:"backendUrl,omitempty"` // Identifier is reported on heartbeat + Monitor responses. Auto- // derived from hostname when empty. Identifier string `json:"identifier,omitempty"` // APIKey authenticates Monitor + Calendar API calls. APIKey string `json:"apiKey,omitempty"` // MonitorPort is the local TCP port the Monitor bridge HTTP // server listens on. Zero/missing disables the bridge entirely. MonitorPort int `json:"monitor_port,omitempty"` // MonitorPushEnabled toggles the active push loop that uploads // system telemetry to BackendURL /monitor/server/heartbeat. Lets // HF plugin replace the standalone harborforge-monitor container. // nil (unset) defaults to false; operators must opt in explicitly // since they need to provision APIKey too. MonitorPushEnabled *bool `json:"monitor_push_enabled,omitempty"` // MonitorPushIntervalSeconds — defaults to 30s when ≤0. Mirrors // the standalone monitor's HF_MONITER_REPORT_INTERVAL knob. MonitorPushIntervalSeconds int `json:"monitor_push_interval_seconds,omitempty"` // CalendarHeartbeatIntervalSeconds — defaults to 30s when ≤0. CalendarHeartbeatIntervalSeconds int `json:"calendar_heartbeat_interval_seconds,omitempty"` // CalendarEnabled toggles the calendar scheduler loop. Default true. CalendarEnabled *bool `json:"calendar_enabled,omitempty"` // CalendarBackendURL — if set, overrides BackendURL for Calendar // API calls only (Monitor still uses BackendURL). Matches OpenClaw // plugin's split-endpoint config. CalendarBackendURL string `json:"calendar_backendUrl,omitempty"` // RestartPollIntervalSeconds — defaults to 60s when ≤0. RestartPollIntervalSeconds int `json:"restart_poll_interval_seconds,omitempty"` } // Resolved is the post-defaults view used by the rest of the plugin. type Resolved struct { Enabled bool BackendURL string Identifier string APIKey string MonitorPort int MonitorPushEnabled bool MonitorPushIntervalSeconds int CalendarEnabled bool CalendarHeartbeatIntervalSeconds int CalendarBackendURL string RestartPollIntervalSeconds int } // PluginConfigDir returns the on-disk path the plugin's own config // lives in, given the Plexum profile root. Mirrors how every other // Plexum plugin lays out its config. func PluginConfigDir(profileRoot string) string { return filepath.Join(profileRoot, "plugins", "harbor-forge") } // PluginConfigPath returns the full path to config.json. func PluginConfigPath(profileRoot string) string { return filepath.Join(PluginConfigDir(profileRoot), "config.json") } // Load reads + parses the on-disk config; missing file is treated as // empty (defaults applied at Resolve time, not here). func Load(profileRoot string) (Config, error) { path := PluginConfigPath(profileRoot) raw, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return Config{}, nil } return Config{}, fmt.Errorf("read %s: %w", path, err) } if len(raw) == 0 { return Config{}, nil } var c Config if err := json.Unmarshal(raw, &c); err != nil { return Config{}, fmt.Errorf("parse %s: %w", path, err) } return c, nil } // Resolve applies defaults + hostname-derived identifier. func Resolve(c Config) Resolved { out := Resolved{ Enabled: true, BackendURL: "https://monitor.hangman-lab.top", Identifier: c.Identifier, APIKey: c.APIKey, MonitorPort: c.MonitorPort, MonitorPushEnabled: false, MonitorPushIntervalSeconds: 30, CalendarEnabled: true, CalendarHeartbeatIntervalSeconds: 30, CalendarBackendURL: c.CalendarBackendURL, RestartPollIntervalSeconds: 60, } // Explicit-false overrides default-true. if c.Enabled || !c.Enabled && hasJSONField(c, "Enabled") { // Default true; we don't have a way to distinguish unset vs // explicit false on a plain bool. Document: write {"enabled": // false} only when intentional. } if c.BackendURL != "" { out.BackendURL = c.BackendURL } if c.Identifier == "" { out.Identifier = autoIdentifier() } if c.CalendarEnabled != nil { out.CalendarEnabled = *c.CalendarEnabled } if c.CalendarHeartbeatIntervalSeconds > 0 { out.CalendarHeartbeatIntervalSeconds = c.CalendarHeartbeatIntervalSeconds } if c.MonitorPushEnabled != nil { out.MonitorPushEnabled = *c.MonitorPushEnabled } if c.MonitorPushIntervalSeconds > 0 { out.MonitorPushIntervalSeconds = c.MonitorPushIntervalSeconds } if c.RestartPollIntervalSeconds > 0 { out.RestartPollIntervalSeconds = c.RestartPollIntervalSeconds } return out } // hasJSONField is a placeholder — Go's encoding/json doesn't surface // "field was present in source" without a sentinel pointer. We use // *bool on the optional toggles instead and treat plain bool as // default-true. func hasJSONField(_ Config, _ string) bool { return false } func autoIdentifier() string { h, err := os.Hostname() if err != nil || h == "" { return "unknown" } return h }