Skip to content
Merged
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
75 changes: 57 additions & 18 deletions internal/cmd/initcmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,23 +44,26 @@ func NewCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "init",
Short: "Set up Google API authentication",
Long: `Guided OAuth setup. Walks you through:
Long: fmt.Sprintf(`Guided OAuth setup. Walks you through:

1. Reading your downloaded OAuth client JSON (clipboard, paste, or file path).
2. Opening the consent URL in your browser.
3. Pasting the redirect URL back to complete authentication.

After setup, run 'gro me' to see who you're authenticated as.

Prerequisites — at https://console.cloud.google.com:
- Create a project and enable: Gmail API, Google Calendar API,
Google Drive API, and People API (used for both 'gro contacts'
and 'gro me').
- Create OAuth 2.0 Desktop-app credentials and download the JSON.
The wizard first asks how you're getting your credentials.json:
- Admin-provided (e.g. via 1Password): paste or point to the file.
- DIY: walks you through creating a Google Cloud project yourself,
enabling the Gmail API, Google Calendar API, Google Drive API, and
People API, and downloading OAuth 2.0 Desktop-app credentials.

You can copy that JSON to your clipboard and run 'gro init' — it will read,
validate, and write it to the config directory for you. No need to download
the file separately.`,
If you're a Google Workspace admin and want to set up one Internal OAuth app
for your whole org, see:
%s

You can also copy your credentials.json to the clipboard and run 'gro init' —
it will read, validate, and write it to the config directory for you.`, workspaceAdminsURL),
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return runWith(cmd.Context(), defaultDeps(), opts)
Expand Down Expand Up @@ -119,6 +122,7 @@ type initDeps struct {
// prompter abstracts huh's interactive prompts so tests can drive the
// wizard with deterministic inputs.
type prompter interface {
SelectAudience() (string, error) // returns "admin" | "diy"
SelectCredSource(clipboardSupported bool) (string, error) // returns "clipboard" | "paste" | "file"
PasteJSON() (string, error)
FilePath() (string, error)
Expand Down Expand Up @@ -424,16 +428,33 @@ func ensureCredentials(d initDeps, opts *initOptions, credPath string) error {
return nil
}

// Otherwise drive the wizard.
d.View.Println("")
d.View.Println("Set up Google OAuth credentials at https://console.cloud.google.com:")
d.View.Println(" 1. Create or select a project.")
d.View.Println(" 2. Enable APIs: Gmail, Google Calendar, Google Drive, and People")
d.View.Println(" (the People API powers both 'gro contacts' and 'gro me').")
d.View.Println(" 3. Create OAuth 2.0 Desktop-app credentials.")
d.View.Println(" 4. Copy the JSON to your clipboard, OR download the JSON file.")
// Otherwise drive the wizard. Ask once whether the user is admin-provisioned
// or doing their own Google Cloud setup, so we only show the steps they need.
audience, err := d.Prompter.SelectAudience()
if err != nil {
return err
}

d.View.Println("")
d.View.Println("Optional: publish your OAuth app to avoid 7-day token expiry.")
switch audience {
case "admin":
d.View.Println("Your admin should have shared credentials.json (e.g. via 1Password).")
d.View.Println("Paste the JSON, or point to the file when prompted.")
case "diy":
d.View.Println("Set up Google OAuth credentials at https://console.cloud.google.com:")
d.View.Println(" 1. Create or select a project.")
d.View.Println(" 2. Enable APIs: Gmail, Google Calendar, Google Drive, and People")
d.View.Println(" (the People API powers both 'gro contacts' and 'gro me').")
d.View.Println(" 3. Create OAuth 2.0 Desktop-app credentials.")
d.View.Println(" 4. Copy the JSON to your clipboard, OR download the JSON file.")
d.View.Println("")
d.View.Println("Optional: publish your OAuth app to avoid 7-day token expiry.")
d.View.Println("")
d.View.Println("Workspace admin? Set up an Internal OAuth app once for your whole org:")
d.View.Println(" " + workspaceAdminsURL)
default:
return fmt.Errorf("unknown audience: %s", audience)
}
d.View.Println("")

// Up to 3 attempts to recover from *content* errors (unreadable
Expand Down Expand Up @@ -565,9 +586,27 @@ func isAuthError(err error) bool {

var errorAs = errors.As

// workspaceAdminsURL points to the repo's Workspace-admin walkthrough.
// Referenced from both cmd.Long and the runtime wizard, so installed-CLI
// users (Homebrew/Chocolatey/Winget) reach it without a local checkout.
const workspaceAdminsURL = "https://github.com/open-cli-collective/google-readonly/blob/main/WORKSPACE_ADMINS.md"

// huhPrompter is the production prompter — wraps huh.
type huhPrompter struct{}

func (huhPrompter) SelectAudience() (string, error) {
var choice string
err := huh.NewSelect[string]().
Title("How are you getting your OAuth credentials?").
Options(
huh.NewOption("My admin gave me a credentials.json (e.g. via 1Password)", "admin"),
huh.NewOption("I'll set up my own Google Cloud project", "diy"),
).
Value(&choice).
Run()
return choice, err
}

func (huhPrompter) SelectCredSource(clipboardSupported bool) (string, error) {
options := []huh.Option[string]{}
if clipboardSupported {
Expand Down
105 changes: 104 additions & 1 deletion internal/cmd/initcmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ func TestValidateOAuthJSONRejectsGarbage(t *testing.T) {

// stubPrompter records calls and returns canned values.
type stubPrompter struct {
audience string
credChoice string
pasteJSON string
filePath string
Expand All @@ -151,6 +152,14 @@ type stubPrompter struct {
calls []string
}

func (s *stubPrompter) SelectAudience() (string, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low (harness-engineering:harness-self-documenting-code-reviewer): stubPrompter.SelectAudience() silently returns "diy" when the audience field is the zero value (empty string). Any existing or future test that constructs a &stubPrompter{} without setting audience will quietly exercise the diy path rather than failing. A sentinel value or required-non-empty check would surface misconfigured stubs earlier.

Reply to this thread when addressed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining — this was a deliberate design choice flagged in the architect review for this PR. The zero-value default to 'diy' was specifically requested to avoid touching ~20 existing test setups that would otherwise need to set audience: "diy" to keep compiling. Existing tests that exercise the wizard exercised the DIY path before this PR; defaulting their zero-value to 'diy' preserves that semantic exactly. New tests that care about audience set it explicitly. The two bypass tests (TestEnsureCredentialsFlagFile, TestEnsureCredentialsShortCircuitsWhenAlreadyPresent) now assert audience is NOT in calls, which catches the misuse case you're worried about: if a wizard test forgot to think about audience and the audience prompt fires unexpectedly, those tests would surface it.

s.calls = append(s.calls, "audience")
if s.audience == "" {
return "diy", nil
}
return s.audience, nil
}

func (s *stubPrompter) SelectCredSource(_ bool) (string, error) {
s.calls = append(s.calls, "select")
return s.credChoice, nil
Expand Down Expand Up @@ -286,7 +295,8 @@ func TestEnsureCredentialsFlagFile(t *testing.T) {
}
dst, _ := d.GetCredentialsPath()

d.Prompter = &stubPrompter{}
stub := &stubPrompter{}
d.Prompter = stub
if err := ensureCredentials(d, &initOptions{credentialsFile: src}, dst); err != nil {
t.Fatalf("ensureCredentials: %v", err)
}
Expand All @@ -300,6 +310,10 @@ func TestEnsureCredentialsFlagFile(t *testing.T) {
if perm := fs.perms[dst]; perm != 0600 {
t.Errorf("expected perms 0600, got %o", perm)
}
// --credentials-file bypasses the wizard entirely; audience must not be asked.
if contains(stub.calls, "audience") {
t.Errorf("expected --credentials-file bypass; SelectAudience was called: calls=%v", stub.calls)
}
}

func TestEnsureCredentialsRejectsBadJSON(t *testing.T) {
Expand Down Expand Up @@ -397,6 +411,95 @@ func TestEnsureCredentialsShortCircuitsWhenAlreadyPresent(t *testing.T) {
if contains(stub.calls, "select") {
t.Errorf("expected wizard short-circuit, but SelectCredSource was called: calls=%v", stub.calls)
}
// Audience prompt must not be reached before the credentials.json short-circuit.
if contains(stub.calls, "audience") {
t.Errorf("expected short-circuit before audience prompt; calls=%v", stub.calls)
}
}

func TestEnsureCredentialsAudienceAdminSkipsDIYSteps(t *testing.T) {
t.Parallel()
fs := newFakeFS()
d := baseDeps(t, fs)
out := &bytes.Buffer{}
d.View = view.NewWithWriters(out, &bytes.Buffer{})
dst, _ := d.GetCredentialsPath()
d.Prompter = &stubPrompter{audience: "admin", credChoice: "paste", pasteJSON: validOAuthJSON}

if err := ensureCredentials(d, &initOptions{}, dst); err != nil {
t.Fatalf("ensureCredentials: %v", err)
}
got := out.String()
if strings.Contains(got, "Enable APIs: Gmail") {
t.Errorf("admin audience should not show DIY steps; output:\n%s", got)
}
if !strings.Contains(got, "Your admin should have shared credentials.json") {
t.Errorf("admin audience should show the admin-provisioned one-liner; output:\n%s", got)
}
}

func TestEnsureCredentialsAudienceDIYShowsDIYStepsAndAdminPointer(t *testing.T) {
t.Parallel()
fs := newFakeFS()
d := baseDeps(t, fs)
out := &bytes.Buffer{}
d.View = view.NewWithWriters(out, &bytes.Buffer{})
dst, _ := d.GetCredentialsPath()
d.Prompter = &stubPrompter{audience: "diy", credChoice: "paste", pasteJSON: validOAuthJSON}

if err := ensureCredentials(d, &initOptions{}, dst); err != nil {
t.Fatalf("ensureCredentials: %v", err)
}
got := out.String()
if !strings.Contains(got, "Enable APIs: Gmail") {
t.Errorf("diy audience should show DIY steps; output:\n%s", got)
}
if !strings.Contains(got, "https://github.com/open-cli-collective/google-readonly/blob/main/WORKSPACE_ADMINS.md") {
t.Errorf("diy audience should show the fully-qualified Workspace admin doc URL (so Homebrew/Choco users can reach it); output:\n%s", got)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium (harness-engineering:harness-self-documenting-code-reviewer): TestEnsureCredentialsAudienceIsAskedOncePerWizard accesses reads[idx] without bounds checking. If ClipboardReadAll is called more than twice (e.g., after a refactor raising the retry cap), the test panics with an index-out-of-range instead of failing cleanly. Use a bounds-checked helper or t.Fatal inside the closure.

Reply to this thread when addressed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8a40d4d — the ClipboardReadAll closure now checks idx >= len(reads) and calls t.Fatalf if the wizard makes more than 2 reads, so a future retry-cap bump fails cleanly instead of panicking with index-out-of-range.

}
}

func TestEnsureCredentialsAudienceIsAskedOncePerWizard(t *testing.T) {
t.Parallel()
fs := newFakeFS()
d := baseDeps(t, fs)
d.ClipboardSupported = func() bool { return true }
// First clipboard read returns invalid JSON, second returns valid. This
// forces the wizard's retry loop to run twice while audience must remain
// sticky. Bounds-check: if the wizard reads more than twice (e.g. a future
// retry-cap change), fail cleanly instead of panicking.
reads := []string{"not valid json", validOAuthJSON}
idx := 0
d.ClipboardReadAll = func() (string, error) {
if idx >= len(reads) {
t.Fatalf("unexpected extra ClipboardReadAll call #%d; wizard should have succeeded after 2 reads", idx+1)
}
s := reads[idx]
idx++
return s, nil
}
dst, _ := d.GetCredentialsPath()
stub := &stubPrompter{audience: "admin", credChoice: "clipboard"}
d.Prompter = stub

if err := ensureCredentials(d, &initOptions{}, dst); err != nil {
t.Fatalf("ensureCredentials: %v", err)
}
gotAudience, gotSelect := 0, 0
for _, c := range stub.calls {
switch c {
case "audience":
gotAudience++
case "select":
gotSelect++
}
}
if gotAudience != 1 {
t.Errorf("expected SelectAudience called exactly once, got %d; calls=%v", gotAudience, stub.calls)
}
if gotSelect != 2 {
t.Errorf("expected SelectCredSource called exactly twice (retry), got %d; calls=%v", gotSelect, stub.calls)
}
}

func TestEnsureCredentialsTightensPermsOnOverwrite(t *testing.T) {
Expand Down
Loading