diff --git a/README.md b/README.md index 61918a0..088b039 100644 --- a/README.md +++ b/README.md @@ -46,3 +46,45 @@ Behavior: - argument auto-resolves against site ID/domain/server/user when possible - stores only file path mappings in config (`sync.files.items`) - database details are requested interactively for each run (not persisted) + +### Context-aware defaults + +The CLI resolves defaults using context detection and a fixed precedence. + +Context detection order: +1. `--context` / `INTERCUBE_CONTEXT` (`pipeline`, `server`, `repository`, `global`) +2. `CI=true` -> pipeline mode +3. server config at `/etc/intercube.yaml` -> server mode +4. nearest repo containing `.intercube.yaml` -> repository mode +5. fallback -> global mode + +Resolution precedence: +1. command flags +2. environment variables +3. active context config +4. user defaults + +Supported default keys: + +```yaml +context: + org_id: org_xxx + site_id: "58" + server_id: "42" + +behavior: + non_interactive: false +``` + +Config scopes: +- user: `~/.intercube.yaml` +- repository: `/.intercube.yaml` +- server: `~/.intercube.yaml` (same user-level config, used when `--context server` is selected) + +Environment overrides: +- `INTERCUBE_ORG_ID` (preferred) and `INTERCUBE_ORGANIZATION_ID` (legacy) +- `INTERCUBE_SITE_ID` +- `INTERCUBE_SERVER_ID` +- `INTERCUBE_NON_INTERACTIVE` + +In non-interactive mode, commands fail instead of prompting when required values are missing. diff --git a/cmd/auth-org-select.go b/cmd/auth-org-select.go index 29a02e5..f6e5664 100644 --- a/cmd/auth-org-select.go +++ b/cmd/auth-org-select.go @@ -54,7 +54,7 @@ func runAuthOrgSelect(cmd *cobra.Command, args []string, forcePrompt bool) error } if forcePrompt || orgID == "" { - orgID, err = selectOrPromptOrgID(session.OrganizationID, appconfig.OrganizationID, session.KnownOrgIDs, organizations, forcePrompt) + orgID, err = selectOrPromptOrgID(session.OrganizationID, contextOrgDefault(), session.KnownOrgIDs, organizations, forcePrompt) if err != nil { return err } diff --git a/cmd/context_runtime.go b/cmd/context_runtime.go new file mode 100644 index 0000000..dbb5bde --- /dev/null +++ b/cmd/context_runtime.go @@ -0,0 +1,79 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/intercube/cli/util/appconfig" + authutil "github.com/intercube/cli/util/auth" + "github.com/intercube/cli/util/contextconfig" + "github.com/spf13/cobra" +) + +var runtimeContext contextconfig.Runtime +var contextOverride string + +func isNonInteractiveMode() bool { + if config.Behavior.NonInteractive { + return true + } + + return runtimeContext.NonInteractive +} + +func ensureInteractiveMode(action string) error { + if isNonInteractiveMode() { + return fmt.Errorf("%s requires interactive input; pass explicit flags or set defaults in context config", action) + } + + if !stdinIsTerminal() { + return fmt.Errorf("%s requires a terminal", action) + } + + return nil +} + +func resolveOrganizationID(cmd *cobra.Command, organizationOverride string) (string, string, error) { + store, err := authutil.NewSessionStore("intercube-cli") + if err != nil { + return "", "", err + } + + sessionOrg := "" + if session, sessionErr := store.Load(cmd.Context()); sessionErr == nil { + sessionOrg = strings.TrimSpace(session.OrganizationID) + } + + resolved := contextconfig.ResolveValue(contextconfig.Inputs{ + FlagValue: organizationOverride, + PreferredEnvValue: strings.TrimSpace(os.Getenv(appconfig.EnvOrganizationIDAlt)), + LegacyEnvValue: strings.TrimSpace(os.Getenv(appconfig.EnvOrganizationID)), + ContextConfigValue: strings.TrimSpace(config.Context.OrgID), + SessionDefaultValue: sessionOrg, + UserDefaultValue: strings.TrimSpace(appconfig.OrganizationID), + }) + + return resolved.Value, resolved.Source, nil +} + +func resolveSiteID(siteOverride string) string { + resolved := contextconfig.ResolveValue(contextconfig.Inputs{ + FlagValue: siteOverride, + PreferredEnvValue: strings.TrimSpace(os.Getenv(appconfig.EnvSiteID)), + ContextConfigValue: strings.TrimSpace(config.Context.SiteID), + }) + + return resolved.Value +} + +func contextOrgDefault() string { + resolved := contextconfig.ResolveValue(contextconfig.Inputs{ + PreferredEnvValue: strings.TrimSpace(os.Getenv(appconfig.EnvOrganizationIDAlt)), + LegacyEnvValue: strings.TrimSpace(os.Getenv(appconfig.EnvOrganizationID)), + ContextConfigValue: strings.TrimSpace(config.Context.OrgID), + UserDefaultValue: strings.TrimSpace(appconfig.OrganizationID), + }) + + return resolved.Value +} diff --git a/cmd/inventory-helper.go b/cmd/inventory-helper.go index 8085725..32d38ee 100644 --- a/cmd/inventory-helper.go +++ b/cmd/inventory-helper.go @@ -26,16 +26,9 @@ func newInventoryClient(cmd *cobra.Command, organizationOverride string) (*inven return nil, "", err } - organizationID := strings.TrimSpace(organizationOverride) - if organizationID == "" { - session, sessionErr := store.Load(cmd.Context()) - if sessionErr == nil { - organizationID = strings.TrimSpace(session.OrganizationID) - } - } - - if organizationID == "" { - organizationID = strings.TrimSpace(appconfig.OrganizationID) + organizationID, _, err := resolveOrganizationID(cmd, organizationOverride) + if err != nil { + return nil, "", err } clerkClient := &authutil.ClerkClient{ diff --git a/cmd/map.go b/cmd/map.go index b47ccbc..8856953 100644 --- a/cmd/map.go +++ b/cmd/map.go @@ -32,6 +32,10 @@ var mapCmd = &cobra.Command{ Use: "map", Short: "Maps files based on config yaml file", RunE: func(cmd *cobra.Command, args []string) error { + if interactiveMapSetup && isNonInteractiveMode() { + return fmt.Errorf("--interactive is not allowed in non-interactive context") + } + if len(config.Mappings) == 0 { if !interactiveMapSetup { return fmt.Errorf("no mappings configured. set `mappings` in config or run `intercube onboarding` interactively") diff --git a/cmd/onboarding.go b/cmd/onboarding.go index 77908cd..131662d 100644 --- a/cmd/onboarding.go +++ b/cmd/onboarding.go @@ -152,12 +152,53 @@ func runOnboarding() { } } + configureContextDefaults, err := chooseYesNo("Configure context defaults too?") + if err != nil { + fmt.Printf("Onboarding cancelled: %v\n", err) + return + } + + contextOrgID := strings.TrimSpace(config.Context.OrgID) + contextSiteID := strings.TrimSpace(config.Context.SiteID) + contextServerID := strings.TrimSpace(config.Context.ServerID) + contextNonInteractive := config.Behavior.NonInteractive + + if configureContextDefaults { + contextOrgID, err = promptText("Default organization ID (optional)", contextOrgID, optionalValue, 0) + if err != nil { + fmt.Printf("Onboarding cancelled: %v\n", err) + return + } + + contextSiteID, err = promptText("Default site ID (optional)", contextSiteID, optionalValue, 0) + if err != nil { + fmt.Printf("Onboarding cancelled: %v\n", err) + return + } + + contextServerID, err = promptText("Default server ID (optional)", contextServerID, optionalValue, 0) + if err != nil { + fmt.Printf("Onboarding cancelled: %v\n", err) + return + } + + contextNonInteractive, err = chooseYesNo("Force non-interactive mode by default?") + if err != nil { + fmt.Printf("Onboarding cancelled: %v\n", err) + return + } + } + viper.Set("login.username", username) viper.Set("login.password", password) viper.Set("login.scope", scope) viper.Set("login.auth_method", authMethod) viper.Set("login.instance_url", instanceURL) viper.Set("sync.files.items", toMapSlice(syncItems)) + viper.Set("context.org_id", contextOrgID) + viper.Set("context.site_id", contextSiteID) + viper.Set("context.server_id", contextServerID) + viper.Set("behavior.non_interactive", contextNonInteractive) if err := writeOnboardingConfig(configPath); err != nil { panic(err) @@ -414,10 +455,6 @@ func resolveOnboardingConfigPath() (string, error) { return cfgFile, nil } - if used := viper.ConfigFileUsed(); used != "" { - return used, nil - } - home, err := homedir.Dir() if err != nil { return "", err diff --git a/cmd/root.go b/cmd/root.go index 4148f28..d5a78b3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -18,10 +18,11 @@ package cmd import ( "fmt" "github.com/intercube/cli/util" + "github.com/intercube/cli/util/contextconfig" "github.com/spf13/cobra" "os" + "strings" - "github.com/mitchellh/go-homedir" "github.com/spf13/viper" ) @@ -48,6 +49,7 @@ func Execute() { func init() { rootCmd.PersistentFlags().BoolVarP(&Verbose, "verbose", "v", false, "verbose output") rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.intercube.yaml)") + rootCmd.PersistentFlags().StringVar(&contextOverride, "context", "", "execution context override (pipeline,server,repository,global)") cobra.OnInitialize(initConfig) } @@ -55,34 +57,44 @@ func init() { var config util.Configuration func initConfig() { - viper.SetConfigName("config") - if cfgFile != "" { - viper.SetConfigFile(cfgFile) - } else { - home, err := homedir.Dir() - if err != nil { - fmt.Println(err) - os.Exit(1) - } + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + viper.BindEnv("context", "INTERCUBE_CONTEXT") + viper.BindEnv("behavior.non_interactive", "INTERCUBE_NON_INTERACTIVE") + viper.BindEnv("context.org_id", "INTERCUBE_ORG_ID", "INTERCUBE_ORGANIZATION_ID") + viper.BindEnv("context.site_id", "INTERCUBE_SITE_ID") + viper.BindEnv("context.server_id", "INTERCUBE_SERVER_ID") + viper.AutomaticEnv() - viper.AddConfigPath(home) - viper.SetConfigName(".intercube") + workingDir, _ := os.Getwd() + explicitContext := strings.TrimSpace(contextOverride) + if explicitContext == "" { + explicitContext = strings.TrimSpace(viper.GetString("context")) } + runtimeContext = contextconfig.DetectRuntime(explicitContext, workingDir) - viper.AutomaticEnv() - + viper.SetDefault("behavior.non_interactive", false) viper.SetDefault("sync", map[string]interface{}{}) + loadResult, err := contextconfig.LoadLayeredConfig(viper.GetViper(), runtimeContext, cfgFile) + if err != nil { + fmt.Println(err) + os.Exit(1) + } - if err := viper.ReadInConfig(); err == nil { - if Verbose { - fmt.Println("Using config file:", viper.ConfigFileUsed()) + if Verbose { + for _, path := range loadResult.LoadedPaths { + fmt.Println("Using config file:", path) } + fmt.Printf("Using context: %s\n", runtimeContext.Kind) } - err := viper.Unmarshal(&config) + err = viper.Unmarshal(&config) if err != nil { panic(fmt.Errorf("Unable to decode Config: %s \n", err)) } + + if config.Behavior.NonInteractive { + runtimeContext.NonInteractive = true + } } func displaySubCommands(commands []*cobra.Command) { diff --git a/cmd/site-add-ssh-key.go b/cmd/site-add-ssh-key.go index 53de961..f55cbd9 100644 --- a/cmd/site-add-ssh-key.go +++ b/cmd/site-add-ssh-key.go @@ -1,15 +1,12 @@ package cmd import ( - "errors" "fmt" "os" "path/filepath" "sort" "strings" - "github.com/intercube/cli/util/appconfig" - authutil "github.com/intercube/cli/util/auth" "github.com/intercube/cli/util/inventory" "github.com/manifoldco/promptui" "github.com/spf13/cobra" @@ -23,60 +20,18 @@ type localPublicKey struct { } var siteOrgID string +var siteAddSSHKeySiteID string var siteAddSSHKeyCmd = &cobra.Command{ Use: "add-ssh-key", Short: "Add one of your local SSH public keys to a site", RunE: func(cmd *cobra.Command, args []string) error { - if err := appconfig.ValidateClerk(); err != nil { - return fmt.Errorf("%w (set via env/.env or build-time)", err) - } - - if err := appconfig.ValidateInventory(); err != nil { - return fmt.Errorf("%w (set via env/.env or build-time)", err) - } - - store, err := authutil.NewSessionStore("intercube-cli") - if err != nil { - return err - } - - clerkClient := &authutil.ClerkClient{ - Issuer: appconfig.ClerkIssuer, - ClientID: appconfig.ClerkClientID, - Audience: appconfig.ClerkAudience, - Scopes: appconfig.ClerkScopes, - CallbackPort: appconfig.ParsedCallbackPort(), - } - - organizationID := strings.TrimSpace(siteOrgID) - if organizationID == "" { - session, sessionErr := store.Load(cmd.Context()) - if sessionErr == nil { - organizationID = strings.TrimSpace(session.OrganizationID) - } - } - - if organizationID == "" { - organizationID = strings.TrimSpace(appconfig.OrganizationID) - } - - inventoryClient := inventory.NewClient(appconfig.InventoryAPIBaseURL, organizationID, store, clerkClient) - - sites, err := inventoryClient.ListSites(cmd.Context()) + inventoryClient, _, err := newInventoryClient(cmd, siteOrgID) if err != nil { - if shouldPromptForOrganization(err) { - return errors.New("organization context is required. Run `intercube auth org` (or pass --org-id)") - } - return err } - if len(sites) == 0 { - return fmt.Errorf("no sites available for your account") - } - - selectedSite, err := selectSite(sites) + selectedSite, err := resolveSiteSelection(cmd, inventoryClient, siteAddSSHKeySiteID) if err != nil { return err } @@ -145,6 +100,7 @@ var siteAddSSHKeyCmd = &cobra.Command{ func init() { siteCmd.AddCommand(siteAddSSHKeyCmd) siteAddSSHKeyCmd.Flags().StringVar(&siteOrgID, "org-id", "", "organization id (sets X-Organization-Id for inventory requests)") + siteAddSSHKeyCmd.Flags().StringVar(&siteAddSSHKeySiteID, "site-id", "", "site id") } func selectSite(sites []inventory.SiteServer) (*inventory.SiteServer, error) { diff --git a/cmd/site-env.go b/cmd/site-env.go index 4968bb8..906d16a 100644 --- a/cmd/site-env.go +++ b/cmd/site-env.go @@ -317,6 +317,10 @@ func selectEnvironmentVariable(variables []inventory.EnvironmentVariable) (*inve } func promptRequiredText(label, defaultValue string) (string, error) { + if err := ensureInteractiveMode(label); err != nil { + return "", err + } + prompt := promptui.Prompt{ Label: label, Default: defaultValue, @@ -339,6 +343,10 @@ func promptRequiredText(label, defaultValue string) (string, error) { } func promptYesNo(label string) (bool, error) { + if err := ensureInteractiveMode(label); err != nil { + return false, err + } + prompt := promptui.Select{ Label: label, Items: []string{"Yes", "No"}, diff --git a/cmd/site-selection.go b/cmd/site-selection.go index e2d4ebe..1c88f00 100644 --- a/cmd/site-selection.go +++ b/cmd/site-selection.go @@ -21,14 +21,19 @@ func resolveSiteSelection(cmd *cobra.Command, inventoryClient *inventory.Client, return nil, fmt.Errorf("no sites available for your account") } - if siteID != "" { - selected, found := findSiteByID(sites, siteID) + resolvedSiteID := resolveSiteID(siteID) + if resolvedSiteID != "" { + selected, found := findSiteByID(sites, resolvedSiteID) if !found { - return nil, fmt.Errorf("site %q not found", siteID) + return nil, fmt.Errorf("site %q not found", resolvedSiteID) } return selected, nil } + if isNonInteractiveMode() { + return nil, fmt.Errorf("site selection requires --site-id, %s env var, or context.site_id", "INTERCUBE_SITE_ID") + } + return selectSite(sites) } diff --git a/cmd/sync-database.go b/cmd/sync-database.go index 67fdcf1..393c055 100644 --- a/cmd/sync-database.go +++ b/cmd/sync-database.go @@ -25,6 +25,10 @@ type mysqlSyncConfig struct { } func runDatabaseSync(cmd *cobra.Command, target ResolvedSyncTarget, _ *SyncSettings, dryRun bool, autoApprove bool) error { + if isNonInteractiveMode() { + return fmt.Errorf("database sync requires interactive prompts in current implementation; run with an interactive terminal") + } + if err := ensureCommandAvailable("mysqldump"); err != nil { return err } diff --git a/cmd/sync-files.go b/cmd/sync-files.go index a1541cf..11e73c1 100644 --- a/cmd/sync-files.go +++ b/cmd/sync-files.go @@ -68,6 +68,14 @@ func ensureFileSyncItems(settings *SyncSettings) ([]util.SyncFileItem, error) { return settings.Files.Items, nil } + if isNonInteractiveMode() { + return nil, fmt.Errorf("sync.files.items is required in non-interactive mode") + } + + if err := ensureInteractiveMode("sync file mapping setup"); err != nil { + return nil, err + } + fmt.Println("No file sync paths configured yet. Let's add them now.") items := make([]util.SyncFileItem, 0, 1) diff --git a/cmd/sync-resolve.go b/cmd/sync-resolve.go index 2ba881a..daa7f40 100644 --- a/cmd/sync-resolve.go +++ b/cmd/sync-resolve.go @@ -55,7 +55,7 @@ func resolveSyncTarget(cmd *cobra.Command, inventoryClient *inventory.Client, qu return ResolvedSyncTarget{}, source, err } - target, err := promptTargetAccessDetails(selectedSite) + target, err := resolveTargetAccessDetails(selectedSite) if err != nil { return ResolvedSyncTarget{}, source, err } @@ -91,10 +91,11 @@ func resolveSourceSite(sites []inventory.SiteServer) *inventory.SiteServer { return nil } func selectTargetSite(candidates []inventory.SiteServer, allSites []inventory.SiteServer, query string, explicitSiteID string) (*inventory.SiteServer, error) { - if strings.TrimSpace(explicitSiteID) != "" { - selected, found := findSiteByID(allSites, explicitSiteID) + resolvedSiteID := resolveSiteID(explicitSiteID) + if strings.TrimSpace(resolvedSiteID) != "" { + selected, found := findSiteByID(allSites, resolvedSiteID) if !found { - return nil, fmt.Errorf("site %q not found", explicitSiteID) + return nil, fmt.Errorf("site %q not found", resolvedSiteID) } return selected, nil @@ -107,6 +108,10 @@ func selectTargetSite(candidates []inventory.SiteServer, allSites []inventory.Si } if len(matches) > 1 { + if isNonInteractiveMode() { + return nil, fmt.Errorf("query %q matched multiple sites; pass --site-id to run non-interactively", query) + } + matchedSites := make([]inventory.SiteServer, 0, len(matches)) for _, match := range matches { matchedSites = append(matchedSites, *match) @@ -124,6 +129,14 @@ func selectTargetSite(candidates []inventory.SiteServer, allSites []inventory.Si return nil, fmt.Errorf("no target site matched %q", query) } + if isNonInteractiveMode() { + if len(candidates) == 1 { + return &candidates[0], nil + } + + return nil, fmt.Errorf("target site is required in non-interactive mode; pass --site-id or an unambiguous [env-or-host]") + } + selected, err := selectSiteFromList(candidates) if err != nil { return nil, err @@ -141,6 +154,10 @@ func selectSiteFromList(sites []inventory.SiteServer) (*inventory.SiteServer, er return &sites[0], nil } + if err := ensureInteractiveMode("target site selection"); err != nil { + return nil, err + } + labels := make([]string, 0, len(sites)) for _, site := range sites { labels = append(labels, syncSiteDisplayName(site)) @@ -267,7 +284,27 @@ func excludeSourceSite(sites []inventory.SiteServer, sourceSite *inventory.SiteS return result } -func promptTargetAccessDetails(site *inventory.SiteServer) (ResolvedSyncTarget, error) { +func resolveTargetAccessDetails(site *inventory.SiteServer) (ResolvedSyncTarget, error) { + if isNonInteractiveMode() { + host := strings.TrimSpace(site.MainDomain) + username := strings.TrimSpace(site.Username) + if host == "" || username == "" { + return ResolvedSyncTarget{}, fmt.Errorf("missing target access defaults; provide --site-id and ensure site has domain/user") + } + + return ResolvedSyncTarget{ + SiteID: strings.TrimSpace(site.ID), + DisplayName: syncSiteDisplayName(*site), + Host: host, + Username: username, + Port: 22, + }, nil + } + + if err := ensureInteractiveMode("target access prompts"); err != nil { + return ResolvedSyncTarget{}, err + } + hostDefault := strings.TrimSpace(site.MainDomain) userDefault := strings.TrimSpace(site.Username) portDefault := "22" diff --git a/util/appconfig/config.go b/util/appconfig/config.go index 0212bfa..f13e3bd 100644 --- a/util/appconfig/config.go +++ b/util/appconfig/config.go @@ -14,6 +14,8 @@ var ClerkScopes = "openid profile email offline_access public_metadata" var ClerkCallbackPort = "8976" var InventoryAPIBaseURL = "https://inventory-nexus.dev-c8s.intercube.dev/" var OrganizationID = "" +var SiteID = "" +var ServerID = "" const ( EnvClerkIssuer = "INTERCUBE_AUTH_CLERK_ISSUER" @@ -23,6 +25,9 @@ const ( EnvClerkCallbackPort = "INTERCUBE_AUTH_CLERK_CALLBACK_PORT" EnvInventoryAPIURL = "INTERCUBE_INVENTORY_API_BASE_URL" EnvOrganizationID = "INTERCUBE_ORGANIZATION_ID" + EnvOrganizationIDAlt = "INTERCUBE_ORG_ID" + EnvSiteID = "INTERCUBE_SITE_ID" + EnvServerID = "INTERCUBE_SERVER_ID" ) func LoadFromEnv() { @@ -50,8 +55,18 @@ func LoadFromEnv() { InventoryAPIBaseURL = value } - if value := strings.TrimSpace(os.Getenv(EnvOrganizationID)); value != "" { + if value := strings.TrimSpace(os.Getenv(EnvOrganizationIDAlt)); value != "" { OrganizationID = value + } else if value := strings.TrimSpace(os.Getenv(EnvOrganizationID)); value != "" { + OrganizationID = value + } + + if value := strings.TrimSpace(os.Getenv(EnvSiteID)); value != "" { + SiteID = value + } + + if value := strings.TrimSpace(os.Getenv(EnvServerID)); value != "" { + ServerID = value } } diff --git a/util/configuration.go b/util/configuration.go index 18e508c..989e04f 100644 --- a/util/configuration.go +++ b/util/configuration.go @@ -5,6 +5,18 @@ type Configuration struct { MagentoBaseUrls []MagentoBaseUrl `mapstructure:"magento_base_urls"` Login Login `mapstructure:"login"` Sync Sync `mapstructure:"sync"` + Context ContextDefaults `mapstructure:"context"` + Behavior Behavior `mapstructure:"behavior"` +} + +type ContextDefaults struct { + OrgID string `mapstructure:"org_id"` + SiteID string `mapstructure:"site_id"` + ServerID string `mapstructure:"server_id"` +} + +type Behavior struct { + NonInteractive bool `mapstructure:"non_interactive"` } type Sync struct { diff --git a/util/contextconfig/detect.go b/util/contextconfig/detect.go new file mode 100644 index 0000000..5458985 --- /dev/null +++ b/util/contextconfig/detect.go @@ -0,0 +1,153 @@ +package contextconfig + +import ( + "os" + "path/filepath" + "strings" +) + +type Kind string + +const ( + ContextPipeline Kind = "pipeline" + ContextServer Kind = "server" + ContextRepository Kind = "repository" + ContextGlobal Kind = "global" + + defaultUserConfigName = ".intercube.yaml" +) + +type Runtime struct { + Kind Kind + Explicit bool + WorkingDir string + UserConfigPath string + ActiveConfigPath string + RepositoryRoot string + NonInteractive bool +} + +func DetectRuntime(explicitContext string, workingDir string) Runtime { + trimmedExplicit := strings.ToLower(strings.TrimSpace(explicitContext)) + kind, explicit := parseKind(trimmedExplicit) + + home, _ := os.UserHomeDir() + runtime := Runtime{ + Kind: ContextGlobal, + Explicit: explicit, + WorkingDir: strings.TrimSpace(workingDir), + UserConfigPath: filepath.Join(home, defaultUserConfigName), + } + + if explicit { + runtime.Kind = kind + populatePaths(&runtime) + runtime.NonInteractive = computeNonInteractive(runtime.Kind) + return runtime + } + + if isTruthy(os.Getenv("CI")) { + runtime.Kind = ContextPipeline + populatePaths(&runtime) + runtime.NonInteractive = computeNonInteractive(runtime.Kind) + return runtime + } + + repoRoot := findRepositoryConfigRoot(runtime.WorkingDir) + if repoRoot != "" { + runtime.Kind = ContextRepository + runtime.RepositoryRoot = repoRoot + populatePaths(&runtime) + runtime.NonInteractive = computeNonInteractive(runtime.Kind) + return runtime + } + + populatePaths(&runtime) + runtime.NonInteractive = computeNonInteractive(runtime.Kind) + return runtime +} + +func parseKind(value string) (Kind, bool) { + switch value { + case string(ContextPipeline): + return ContextPipeline, true + case string(ContextServer): + return ContextServer, true + case string(ContextRepository): + return ContextRepository, true + case string(ContextGlobal): + return ContextGlobal, true + default: + return ContextGlobal, false + } +} + +func populatePaths(runtime *Runtime) { + switch runtime.Kind { + case ContextServer: + runtime.ActiveConfigPath = runtime.UserConfigPath + case ContextRepository: + repoRoot := runtime.RepositoryRoot + if repoRoot == "" { + repoRoot = findRepositoryConfigRoot(runtime.WorkingDir) + runtime.RepositoryRoot = repoRoot + } + if repoRoot != "" { + runtime.ActiveConfigPath = filepath.Join(repoRoot, defaultUserConfigName) + } + default: + runtime.ActiveConfigPath = "" + } +} + +func computeNonInteractive(kind Kind) bool { + if kind == ContextPipeline { + return true + } + + if isTruthy(os.Getenv("INTERCUBE_NON_INTERACTIVE")) { + return true + } + + return false +} + +func findRepositoryConfigRoot(start string) string { + start = strings.TrimSpace(start) + if start == "" { + return "" + } + + current := start + for { + candidate := filepath.Join(current, defaultUserConfigName) + if fileExists(candidate) { + return current + } + + parent := filepath.Dir(current) + if parent == current { + return "" + } + + current = parent + } +} + +func isTruthy(value string) bool { + normalized := strings.ToLower(strings.TrimSpace(value)) + return normalized == "1" || normalized == "true" || normalized == "yes" || normalized == "on" +} + +func fileExists(path string) bool { + if strings.TrimSpace(path) == "" { + return false + } + + info, err := os.Stat(path) + if err != nil { + return false + } + + return !info.IsDir() +} diff --git a/util/contextconfig/detect_test.go b/util/contextconfig/detect_test.go new file mode 100644 index 0000000..9cc0801 --- /dev/null +++ b/util/contextconfig/detect_test.go @@ -0,0 +1,56 @@ +package contextconfig + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetectRuntimeExplicitContext(t *testing.T) { + t.Setenv("CI", "") + t.Setenv("INTERCUBE_NON_INTERACTIVE", "") + + runtime := DetectRuntime("repository", t.TempDir()) + if runtime.Kind != ContextRepository { + t.Fatalf("expected repository context, got %s", runtime.Kind) + } + if !runtime.Explicit { + t.Fatalf("expected explicit context to be true") + } +} + +func TestDetectRuntimeCIPipeline(t *testing.T) { + t.Setenv("CI", "true") + t.Setenv("INTERCUBE_NON_INTERACTIVE", "") + + runtime := DetectRuntime("", t.TempDir()) + if runtime.Kind != ContextPipeline { + t.Fatalf("expected pipeline context, got %s", runtime.Kind) + } + if !runtime.NonInteractive { + t.Fatalf("expected pipeline context to be non-interactive") + } +} + +func TestDetectRuntimeRepository(t *testing.T) { + t.Setenv("CI", "") + t.Setenv("INTERCUBE_NON_INTERACTIVE", "") + + repoRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(repoRoot, defaultUserConfigName), []byte("context:\n site_id: '1'\n"), 0644); err != nil { + t.Fatalf("unable to write temp config: %v", err) + } + + nested := filepath.Join(repoRoot, "sub", "dir") + if err := os.MkdirAll(nested, 0755); err != nil { + t.Fatalf("unable to create nested dir: %v", err) + } + + runtime := DetectRuntime("", nested) + if runtime.Kind != ContextRepository { + t.Fatalf("expected repository context, got %s", runtime.Kind) + } + if runtime.RepositoryRoot != repoRoot { + t.Fatalf("expected repository root %s, got %s", repoRoot, runtime.RepositoryRoot) + } +} diff --git a/util/contextconfig/load.go b/util/contextconfig/load.go new file mode 100644 index 0000000..2707d11 --- /dev/null +++ b/util/contextconfig/load.go @@ -0,0 +1,69 @@ +package contextconfig + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/viper" +) + +type LoadResult struct { + LoadedPaths []string +} + +func LoadLayeredConfig(v *viper.Viper, runtime Runtime, explicitConfigPath string) (LoadResult, error) { + loaded := make([]string, 0, 2) + + if settings, path, err := readSettings(runtime.UserConfigPath); err != nil { + return LoadResult{}, err + } else if settings != nil { + if err := v.MergeConfigMap(settings); err != nil { + return LoadResult{}, fmt.Errorf("unable to merge config from %s: %w", path, err) + } + loaded = append(loaded, path) + } + + activePath := strings.TrimSpace(explicitConfigPath) + if activePath == "" { + activePath = strings.TrimSpace(runtime.ActiveConfigPath) + } + + if activePath != "" && activePath == strings.TrimSpace(runtime.UserConfigPath) { + return LoadResult{LoadedPaths: loaded}, nil + } + + if settings, path, err := readSettings(activePath); err != nil { + return LoadResult{}, err + } else if settings != nil { + if err := v.MergeConfigMap(settings); err != nil { + return LoadResult{}, fmt.Errorf("unable to merge config from %s: %w", path, err) + } + loaded = append(loaded, path) + } + + return LoadResult{LoadedPaths: loaded}, nil +} + +func readSettings(path string) (map[string]interface{}, string, error) { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return nil, "", nil + } + + if _, err := os.Stat(trimmed); err != nil { + if os.IsNotExist(err) { + return nil, "", nil + } + + return nil, "", err + } + + temp := viper.New() + temp.SetConfigFile(trimmed) + if err := temp.ReadInConfig(); err != nil { + return nil, "", fmt.Errorf("unable to read config file %s: %w", trimmed, err) + } + + return temp.AllSettings(), trimmed, nil +} diff --git a/util/contextconfig/resolve.go b/util/contextconfig/resolve.go new file mode 100644 index 0000000..f2737da --- /dev/null +++ b/util/contextconfig/resolve.go @@ -0,0 +1,50 @@ +package contextconfig + +import "strings" + +type Inputs struct { + FlagValue string + LegacyEnvValue string + PreferredEnvValue string + ContextConfigValue string + BehaviorDefault string + SessionDefaultValue string + UserDefaultValue string +} + +type Output struct { + Value string + Source string +} + +func ResolveValue(input Inputs) Output { + if value := strings.TrimSpace(input.FlagValue); value != "" { + return Output{Value: value, Source: "flag"} + } + + if value := strings.TrimSpace(input.PreferredEnvValue); value != "" { + return Output{Value: value, Source: "env"} + } + + if value := strings.TrimSpace(input.LegacyEnvValue); value != "" { + return Output{Value: value, Source: "env"} + } + + if value := strings.TrimSpace(input.ContextConfigValue); value != "" { + return Output{Value: value, Source: "context"} + } + + if value := strings.TrimSpace(input.BehaviorDefault); value != "" { + return Output{Value: value, Source: "context"} + } + + if value := strings.TrimSpace(input.SessionDefaultValue); value != "" { + return Output{Value: value, Source: "session"} + } + + if value := strings.TrimSpace(input.UserDefaultValue); value != "" { + return Output{Value: value, Source: "user"} + } + + return Output{} +} diff --git a/util/contextconfig/resolve_test.go b/util/contextconfig/resolve_test.go new file mode 100644 index 0000000..916b029 --- /dev/null +++ b/util/contextconfig/resolve_test.go @@ -0,0 +1,62 @@ +package contextconfig + +import "testing" + +func TestResolveValuePrecedence(t *testing.T) { + tests := []struct { + name string + input Inputs + expected Output + }{ + { + name: "flag wins", + input: Inputs{ + FlagValue: "from-flag", + PreferredEnvValue: "from-env", + ContextConfigValue: "from-context", + }, + expected: Output{Value: "from-flag", Source: "flag"}, + }, + { + name: "preferred env wins over legacy env", + input: Inputs{ + PreferredEnvValue: "preferred", + LegacyEnvValue: "legacy", + }, + expected: Output{Value: "preferred", Source: "env"}, + }, + { + name: "legacy env used when preferred empty", + input: Inputs{ + LegacyEnvValue: "legacy", + ContextConfigValue: "context", + }, + expected: Output{Value: "legacy", Source: "env"}, + }, + { + name: "context used when no flag or env", + input: Inputs{ + ContextConfigValue: "context", + UserDefaultValue: "user", + }, + expected: Output{Value: "context", Source: "context"}, + }, + { + name: "session wins over user", + input: Inputs{ + SessionDefaultValue: "session", + UserDefaultValue: "user", + }, + expected: Output{Value: "session", Source: "session"}, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + result := ResolveValue(testCase.input) + if result != testCase.expected { + t.Fatalf("expected %+v, got %+v", testCase.expected, result) + } + }) + } +}