From f30a47dfaee257f3c0dd34e615ec864450dc1ed4 Mon Sep 17 00:00:00 2001 From: Majd Alfhaily Date: Sun, 13 Sep 2026 23:28:20 +0200 Subject: [PATCH] feat: prompt for email during interactive login --- cmd/auth.go | 14 ++++++- cmd/auth_test.go | 101 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 cmd/auth_test.go diff --git a/cmd/auth.go b/cmd/auth.go index 0824a36c..800f04f8 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -32,6 +32,18 @@ func loginCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { interactive := cmd.Context().Value(interactiveKey).(bool) + if email == "" && !interactive { + return errors.New("email is required when not running in interactive mode; use the \"--email\" flag") + } + + if email == "" && interactive { + value, err := readPrompt("enter email: ", false) + if err != nil { + return fmt.Errorf("failed to read email: %w", err) + } + email = value + } + if password == "" && !interactive { return errors.New("password is required when not running in interactive mode; use the \"--password\" flag") } @@ -103,8 +115,6 @@ func loginCmd() *cobra.Command { cmd.Flags().StringVarP(&password, "password", "p", "", "password for the Apple ID (required)") cmd.Flags().StringVar(&authCode, "auth-code", "", "2FA code for the Apple ID") - _ = cmd.MarkFlagRequired("email") - return cmd } diff --git a/cmd/auth_test.go b/cmd/auth_test.go new file mode 100644 index 00000000..b3fbbf0d --- /dev/null +++ b/cmd/auth_test.go @@ -0,0 +1,101 @@ +package cmd + +import ( + "context" + "os" + "path/filepath" + + "github.com/majd/ipatool/v2/pkg/appstore" + "github.com/majd/ipatool/v2/pkg/log" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Login command", func() { + var store *fakeLoginAppStore + + BeforeEach(func() { + previousDependencies := dependencies + DeferCleanup(func() { dependencies = previousDependencies }) + store = &fakeLoginAppStore{} + dependencies.AppStore = store + dependencies.Logger = log.NewLogger(log.Args{}) + }) + + DescribeTable("requires credentials in non-interactive mode", func(args []string, message string) { + cmd := loginCmd() + cmd.SetContext(context.WithValue(context.Background(), interactiveKey, false)) + cmd.SetArgs(args) + + Expect(cmd.Execute()).To(MatchError(message)) + Expect(store.loginCalls).To(BeZero()) + }, + Entry("missing email", []string{"--password", "secret"}, "email is required when not running in interactive mode; use the \"--email\" flag"), + Entry("missing password", []string{"--email", "user@example.com"}, "password is required when not running in interactive mode; use the \"--password\" flag"), + ) + + DescribeTable("uses supplied credentials", func(interactive bool) { + cmd := loginCmd() + cmd.SetContext(context.WithValue(context.Background(), interactiveKey, interactive)) + cmd.SetArgs([]string{"--email", "user@example.com", "--password", "secret"}) + + Expect(cmd.Execute()).To(Succeed()) + Expect(store.loginCalls).To(Equal(1)) + Expect(store.input).To(Equal(appstore.LoginInput{Email: "user@example.com", Password: "secret"})) + }, + Entry("interactive", true), + Entry("non-interactive", false), + ) + + DescribeTable("prompts for email", func(input, message string) { + dir := GinkgoT().TempDir() + inputPath := filepath.Join(dir, "stdin") + Expect(os.WriteFile(inputPath, []byte(input), 0o600)).To(Succeed()) + stdin, err := os.Open(inputPath) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(stdin.Close) + stderr, err := os.Create(filepath.Join(dir, "stderr")) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(stderr.Close) + + previousStdin, previousStderr := os.Stdin, os.Stderr + os.Stdin, os.Stderr = stdin, stderr + DeferCleanup(func() { os.Stdin, os.Stderr = previousStdin, previousStderr }) + + cmd := loginCmd() + cmd.SetContext(context.WithValue(context.Background(), interactiveKey, true)) + cmd.SetArgs([]string{"--password", "secret"}) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + + err = cmd.Execute() + if message == "" { + Expect(err).NotTo(HaveOccurred()) + Expect(store.loginCalls).To(Equal(1)) + Expect(store.input).To(Equal(appstore.LoginInput{Email: "user@example.com", Password: "secret"})) + } else { + Expect(err).To(MatchError(message)) + Expect(store.loginCalls).To(BeZero()) + } + + prompt, err := os.ReadFile(stderr.Name()) + Expect(err).NotTo(HaveOccurred()) + Expect(string(prompt)).To(Equal("enter email: ")) + }, + Entry("reads unmasked input without requiring a terminal", "user@example.com\n", ""), + Entry("reports input errors", "", "failed to read email: failed to read input: EOF"), + ) +}) + +type fakeLoginAppStore struct { + appstore.AppStore + input appstore.LoginInput + loginCalls int +} + +func (f *fakeLoginAppStore) Login(input appstore.LoginInput) (appstore.LoginOutput, error) { + f.input = input + f.loginCalls++ + + return appstore.LoginOutput{}, nil +}