From a7dd1b3f950aa838cd2de9b98e3c51c42e6d049b Mon Sep 17 00:00:00 2001 From: half-ogre Date: Mon, 25 May 2026 19:24:51 -0700 Subject: [PATCH 1/2] test: cover global and merged config in auto-init guard Adds failing tests for the cases described in #270: a complete global ~/.hx alone, and global+local merging to a complete config, should both satisfy the auto-init guard. Today both fall through to runInitApp. --- cmd/autoinit/autoinit_test.go | 80 +++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/cmd/autoinit/autoinit_test.go b/cmd/autoinit/autoinit_test.go index a32c7465..9dd7224f 100644 --- a/cmd/autoinit/autoinit_test.go +++ b/cmd/autoinit/autoinit_test.go @@ -61,6 +61,43 @@ 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 := HasCompleteLocalAppConfig() + + 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 := HasCompleteLocalAppConfig() + + 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) { @@ -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) @@ -366,3 +420,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) + } +} From 512ea2276254f283553689ef223960d1be5e40aa Mon Sep 17 00:00:00 2001 From: half-ogre Date: Mon, 25 May 2026 19:31:01 -0700 Subject: [PATCH 2/2] fix: auto-init guard consults merged global+local .hx config Closes #270. The guard added in #267 read only ./.hx via RestoreLocalConfig, so a complete global ~/.hx was treated as missing. That triggered runInitApp, which then failed when the runtime user didn't own CWD (the shipped hyphen-entrypoint.sh's exact scenario in non-root containers). Extract the merge that RestoreConfigFromFile already does into RestoreMergedConfig(file) (Config, bool, error) and have the guard call it. Rename HasCompleteLocalAppConfig -> HasCompleteAppConfig and NeedsLocalAppConfig -> NeedsAppConfig to reflect that the check is no longer local-only. Drop "local" from the guidance message for the same reason; hx init still creates a local .hx, so the suggested remediation is unchanged. Also tighten test isolation: withTempDir now also redirects $HOME to a separate temp dir, so the guard's global-config check doesn't pick up the developer's real ~/.hx during local test runs. --- cmd/autoinit/autoinit.go | 34 +++++++++++++++++++--------------- cmd/autoinit/autoinit_test.go | 25 ++++++++++++++++--------- internal/config/config.go | 27 +++++++++++++++++++-------- 3 files changed, 54 insertions(+), 32 deletions(-) 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 9dd7224f..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) @@ -67,7 +67,7 @@ func TestHasCompleteLocalAppConfig(t *testing.T) { home := withTempHome(t) writeCompleteGlobalConfig(t, home) - complete, err := HasCompleteLocalAppConfig() + complete, err := HasCompleteAppConfig() if err != nil { t.Fatalf("unexpected error: %v", err) @@ -89,7 +89,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) @@ -100,7 +100,7 @@ func TestHasCompleteLocalAppConfig(t *testing.T) { }) } -func TestNeedsLocalAppConfig(t *testing.T) { +func TestNeedsAppConfig(t *testing.T) { testCases := []struct { name string path []string @@ -151,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) @@ -161,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 { @@ -395,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) 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) {