Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 19 additions & 15 deletions cmd/autoinit/autoinit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())) }
Expand All @@ -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
Expand All @@ -49,34 +49,38 @@ 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) &&
nonEmptyStringPtr(cfg.AppId) &&
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
Expand All @@ -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
}
Expand Down
101 changes: 94 additions & 7 deletions cmd/autoinit/autoinit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
}
27 changes: 19 additions & 8 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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) {
Expand Down
Loading