diff --git a/cmd/autoinit/autoinit.go b/cmd/autoinit/autoinit.go index 3cb0d55b..8c4ccafa 100644 --- a/cmd/autoinit/autoinit.go +++ b/cmd/autoinit/autoinit.go @@ -16,7 +16,7 @@ import ( "golang.org/x/term" ) -const guidance = "local Hyphen app config is missing or incomplete. Run `hx init` in this directory, then rerun this command." +const guidance = "Hyphen app config is missing or incomplete. Run `hx init` in this directory, then rerun this command." var ( isStdinTerminal = func() bool { return term.IsTerminal(int(os.Stdin.Fd())) } @@ -25,13 +25,13 @@ var ( ) func Ensure(cmd *cobra.Command, args []string) error { - if !NeedsLocalAppConfig(cmd, args) { + if !NeedsAppConfig(cmd, args) { return nil } - complete, err := HasCompleteLocalAppConfig() + complete, err := HasCompleteAppConfig() if err != nil { - return fmt.Errorf("failed to read local .hx config: %w", err) + return fmt.Errorf("failed to read .hx config: %w", err) } if complete { return nil @@ -49,26 +49,30 @@ func Ensure(cmd *cobra.Command, args []string) error { return fmt.Errorf("auto-init failed: %w", err) } - complete, err = HasCompleteLocalAppConfig() + complete, err = HasCompleteAppConfig() if err != nil { - return fmt.Errorf("auto-init did not create a readable local .hx config: %w", err) + return fmt.Errorf("auto-init did not create a readable .hx config: %w", err) } if !complete { - return fmt.Errorf("auto-init did not create complete local app config. %s", guidance) + return fmt.Errorf("auto-init did not produce a complete app config. %s", guidance) } cprint.NewCPrinter(flags.VerboseFlag).Info(fmt.Sprintf("Local Hyphen app initialized. Continuing with `%s`.", cmd.CommandPath())) return nil } -func HasCompleteLocalAppConfig() (bool, error) { - cfg, err := config.RestoreLocalConfig() +// HasCompleteAppConfig reports whether the merged global+local .hx config +// contains every field required by the guarded commands. This mirrors what +// those commands themselves see via RestoreConfigFromFile, so the guard does +// not reject setups the command would accept. +func HasCompleteAppConfig() (bool, error) { + cfg, found, err := config.RestoreMergedConfig(config.ManifestConfigFile) if err != nil { - if os.IsNotExist(err) { - return false, nil - } return false, err } + if !found { + return false, nil + } return strings.TrimSpace(cfg.OrganizationId) != "" && nonEmptyStringPtr(cfg.ProjectId) && @@ -76,7 +80,7 @@ func HasCompleteLocalAppConfig() (bool, error) { nonEmptyStringPtr(cfg.AppAlternateId), nil } -func NeedsLocalAppConfig(cmd *cobra.Command, args []string) bool { +func NeedsAppConfig(cmd *cobra.Command, args []string) bool { names := commandNames(cmd) if len(names) == 0 { return false @@ -96,13 +100,13 @@ func NeedsLocalAppConfig(cmd *cobra.Command, args []string) bool { } if matches(names, "deploy") { - return deployNeedsLocalAppConfig(cmd, args) + return deployNeedsAppConfig(cmd, args) } return false } -func deployNeedsLocalAppConfig(cmd *cobra.Command, args []string) bool { +func deployNeedsAppConfig(cmd *cobra.Command, args []string) bool { if len(args) == 0 { return true } diff --git a/cmd/autoinit/autoinit_test.go b/cmd/autoinit/autoinit_test.go index a32c7465..7fe719dd 100644 --- a/cmd/autoinit/autoinit_test.go +++ b/cmd/autoinit/autoinit_test.go @@ -11,11 +11,11 @@ import ( "github.com/spf13/cobra" ) -func TestHasCompleteLocalAppConfig(t *testing.T) { +func TestHasCompleteAppConfig(t *testing.T) { t.Run("returns_false_when_local_config_is_missing", func(t *testing.T) { withTempDir(t) - complete, err := HasCompleteLocalAppConfig() + complete, err := HasCompleteAppConfig() if err != nil { t.Fatalf("unexpected error: %v", err) @@ -33,7 +33,7 @@ func TestHasCompleteLocalAppConfig(t *testing.T) { "app_id": "app_test" }`) - complete, err := HasCompleteLocalAppConfig() + complete, err := HasCompleteAppConfig() if err != nil { t.Fatalf("unexpected error: %v", err) @@ -52,7 +52,7 @@ func TestHasCompleteLocalAppConfig(t *testing.T) { "app_alternate_id": "app-test" }`) - complete, err := HasCompleteLocalAppConfig() + complete, err := HasCompleteAppConfig() if err != nil { t.Fatalf("unexpected error: %v", err) @@ -61,9 +61,46 @@ func TestHasCompleteLocalAppConfig(t *testing.T) { t.Fatalf("expected complete local app config") } }) + + t.Run("returns_true_when_global_app_config_is_complete_and_local_is_missing", func(t *testing.T) { + withTempDir(t) + home := withTempHome(t) + writeCompleteGlobalConfig(t, home) + + complete, err := HasCompleteAppConfig() + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !complete { + t.Fatalf("expected complete global app config to satisfy the guard") + } + }) + + t.Run("returns_true_when_global_and_local_merge_to_complete", func(t *testing.T) { + dir := withTempDir(t) + home := withTempHome(t) + writeGlobalConfig(t, home, `{ + "organization_id": "org_test", + "project_id": "proj_test" +}`) + writeLocalConfig(t, dir, `{ + "app_id": "app_test", + "app_alternate_id": "app-test" +}`) + + complete, err := HasCompleteAppConfig() + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !complete { + t.Fatalf("expected merged global+local config to satisfy the guard") + } + }) } -func TestNeedsLocalAppConfig(t *testing.T) { +func TestNeedsAppConfig(t *testing.T) { testCases := []struct { name string path []string @@ -114,7 +151,7 @@ func TestNeedsLocalAppConfig(t *testing.T) { tc.config(cmd) } - actual := NeedsLocalAppConfig(cmd, tc.args) + actual := NeedsAppConfig(cmd, tc.args) if actual != tc.expected { t.Fatalf("expected %v, got %v", tc.expected, actual) @@ -124,7 +161,7 @@ func TestNeedsLocalAppConfig(t *testing.T) { } func TestEnsure(t *testing.T) { - t.Run("does_nothing_when_command_does_not_need_local_app_config", func(t *testing.T) { + t.Run("does_nothing_when_command_does_not_need_app_config", func(t *testing.T) { withTempDir(t) restore := stubAutoInit(t) restore.runInitApp = func(cmd *cobra.Command, args []string) error { @@ -155,6 +192,23 @@ func TestEnsure(t *testing.T) { } }) + t.Run("does_nothing_when_global_app_config_is_complete_and_local_is_missing", func(t *testing.T) { + withTempDir(t) + home := withTempHome(t) + writeCompleteGlobalConfig(t, home) + restore := stubAutoInit(t) + restore.runInitApp = func(cmd *cobra.Command, args []string) error { + t.Fatalf("runInitApp should not be called") + return nil + } + + err := Ensure(commandForPath("build"), nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + t.Run("fails_with_guidance_when_noninteractive", func(t *testing.T) { withTempDir(t) restore := stubAutoInit(t) @@ -341,6 +395,13 @@ func withTempDir(t *testing.T) string { t.Fatalf("failed to chdir to temp dir: %v", err) } + // Isolate $HOME so tests don't pick up the developer's real ~/.hx when + // the guard reads merged global+local config. Tests that need to write + // a global config should call withTempHome to overlay a known location. + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Cleanup(func() { if err := os.Chdir(originalDir); err != nil { t.Fatalf("failed to restore current directory: %v", err) @@ -366,3 +427,29 @@ func writeLocalConfig(t *testing.T, dir, contents string) { t.Fatalf("failed to write local config: %v", err) } } + +func withTempHome(t *testing.T) string { + t.Helper() + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + return home +} + +func writeCompleteGlobalConfig(t *testing.T, home string) { + t.Helper() + writeGlobalConfig(t, home, `{ + "organization_id": "org_test", + "project_id": "proj_test", + "app_id": "app_test", + "app_alternate_id": "app-test" +}`) +} + +func writeGlobalConfig(t *testing.T, home, contents string) { + t.Helper() + if err := os.WriteFile(filepath.Join(home, config.ManifestConfigFile), []byte(contents), 0644); err != nil { + t.Fatalf("failed to write global config: %v", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 195e5af9..bd660c0f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -160,6 +160,21 @@ func InitializeConfig(mc Config, configFile string) error { } func RestoreConfigFromFile(manifestConfigFile string) (Config, error) { + mconfig, hasConfig, err := RestoreMergedConfig(manifestConfigFile) + if err != nil { + return Config{}, err + } + if !hasConfig { + return Config{}, errors.New("No valid .hx found (neither global nor local). Please authenticate using `hx auth` or `hx init`") + } + return mconfig, nil +} + +// RestoreMergedConfig reads the global (~/.hx) and local (./.hx) configs and +// returns their merge, with local fields overriding global. The bool reports +// whether at least one of the two files was found; callers that need to treat +// "neither file present" as an error should check it (see RestoreConfigFromFile). +func RestoreMergedConfig(manifestConfigFile string) (Config, bool, error) { var mconfig Config var hasConfig bool @@ -170,25 +185,21 @@ func RestoreConfigFromFile(manifestConfigFile string) (Config, error) { mconfig = globalConfig hasConfig = true } else if !os.IsNotExist(err) { - return Config{}, err + return Config{}, false, err } localConfig, localConfigErr := readAndUnmarshalConfigJSON[Config](manifestConfigFile) if localConfigErr == nil { mergeErr := mergo.Merge(&mconfig, localConfig, mergo.WithOverride) if mergeErr != nil { - return Config{}, errors.Wrap(mergeErr, "Error merging your .hx config(s)") + return Config{}, false, errors.Wrap(mergeErr, "Error merging your .hx config(s)") } hasConfig = true } else if !os.IsNotExist(localConfigErr) { - return Config{}, localConfigErr + return Config{}, false, localConfigErr } - if !hasConfig { - return Config{}, errors.New("No valid .hx found (neither global nor local). Please authenticate using `hx auth` or `hx init`") - } - - return mconfig, nil + return mconfig, hasConfig, nil } func RestoreGlobalConfig() (Config, error) {