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
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,43 @@ grw drive trash --query "name contains 'old'" --dry-run

One desktop OAuth client can be used by both tools, but each tool asks for consent and stores its token under its own identity. Google Workspace administrators should start with [`WORKSPACE_ADMINS.md`](WORKSPACE_ADMINS.md).

## Profiles

Each tool has its own profile namespace. Use a bare profile name with the global
`--profile` shorthand, or pass the full credential reference with `--ref`:

```bash
gro --profile work mail list
grw --profile work calendar today
gro --ref google-readonly/work mail list
```

The selector precedence is explicit flag (`--profile` or `--ref`), credential

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The precedence description names a credential-reference environment override but never tells users which variable to set, leaving that documented selector unusable without source inspection. Document the concrete per-binary names, e.g. GOOGLE_READONLY_CREDENTIAL_REF for gro and GOOGLE_READWRITE_CREDENTIAL_REF for grw, alongside a short example.

Reply inline to this comment.

reference environment variable, saved `credential_ref`, then the built-in
`default` profile. For environment selection, use
`GOOGLE_READONLY_CREDENTIAL_REF` with `gro` or
`GOOGLE_READWRITE_CREDENTIAL_REF` with `grw`, for example:

```bash
GOOGLE_READONLY_CREDENTIAL_REF=google-readonly/work gro mail list
```

`--profile` and `--ref` cannot be used together. To add an account without
changing the active profile, run `gro --profile work init` (or the equivalent
`grw` command). Inspect and manage profiles with:

```bash
gro profiles list
gro profiles rename old-name new-name
```

Renaming moves the stored credentials without re-authentication, updates the
saved active profile when necessary, and refuses a destination that already
has credentials. If saving the active-profile update fails, the copied

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This states that the copied destination is removed after an active-profile save failure, but the implementation only attempts that rollback; if deleting the copy also fails, it reports that the destination may remain. Qualify this as an attempted removal and mention that a rollback failure is reported, so users do not assume a retry cannot encounter a destination collision.

Reply inline to this comment.

destination is removed when rollback succeeds while the source remains, so the
command can be retried. If rollback also fails, the command reports that the
destination may remain.

## Documentation

- [Development](docs/development.md)
Expand Down
98 changes: 98 additions & 0 deletions internal/app/gro/credref_wire_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package gro

import (
"path/filepath"
"strings"
"testing"

"github.com/spf13/cobra"

initcmd "github.com/open-cli-collective/google-cli/internal/cmd/init"
"github.com/open-cli-collective/google-cli/internal/cmd/setcred"
"github.com/open-cli-collective/google-cli/internal/credtest"
"github.com/open-cli-collective/google-cli/internal/keychain"
"github.com/open-cli-collective/google-cli/internal/rootutil"
)
Expand Down Expand Up @@ -116,3 +120,97 @@ func TestCredentialRef_SetCredentialShadowsPersistent(t *testing.T) {
t.Errorf("read command --%s = %p, want canonical %p (unexpected shadow)", rootutil.CredentialRefFlagName, got, canonical)
}
}

func selectorTestRoot() *cobra.Command {
var verbose, noColor bool
root := &cobra.Command{
Use: "gro",
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
return rootutil.ApplyGlobalFlags(cmd, verbose, noColor)
},
}
rootutil.AddGlobalFlags(root, &verbose, &noColor)
root.AddCommand(initcmd.NewCommand())
root.AddCommand(setcred.NewCmd())
return root
}

func TestProfileFlagInheritedByInitInBothFlagOrders(t *testing.T) {
for _, tc := range []struct {
name string
args func(string) []string
}{
{name: "before command", args: func(path string) []string {
return []string{"--profile", "work", "init", "--credentials-file", path}
}},
{name: "after command", args: func(path string) []string {
return []string{"init", "--profile", "work", "--credentials-file", path}
}},
} {
t.Run(tc.name, func(t *testing.T) {
credtest.Setup(t)
t.Setenv(keychain.CredentialRefEnvVar(), "")
root := selectorTestRoot()
root.SetArgs(tc.args(filepath.Join(t.TempDir(), "missing.json")))
if err := root.Execute(); err == nil {
t.Fatal("init should fail for the intentionally missing client file")
}
if got, set := keychain.GetCredentialRefOverride(); !set || got != "google-readonly/work" {
t.Fatalf("selector after init path = (%q, %v), want google-readonly/work", got, set)
}
})
}
}

func TestProfileFlagInheritedBySetCredentialTargetsNamedProfile(t *testing.T) {
for _, tc := range []struct {
name string
args []string
}{
{name: "before command", args: []string{"--profile", "work", "set-credential", "--key", "oauth_token", "--stdin"}},
{name: "after command", args: []string{"set-credential", "--profile", "work", "--key", "oauth_token", "--stdin"}},
} {
t.Run(tc.name, func(t *testing.T) {
credtest.Setup(t)
t.Setenv(keychain.CredentialRefEnvVar(), "")
root := selectorTestRoot()
root.SetIn(strings.NewReader(`{"access_token":"profile-token","refresh_token":"refresh"}`))
root.SetArgs(tc.args)
if err := root.Execute(); err != nil {
t.Fatalf("set-credential: %v", err)
}
st, err := keychain.OpenRef("google-readonly/work")
if err != nil {
t.Fatal(err)
}
tok, err := st.Token()
_ = st.Close()
if err != nil || tok.AccessToken != "profile-token" {
t.Fatalf("named profile token = %+v, err=%v", tok, err)
}
assertNoTokenAtRef(t, "google-readonly/default")
})
}
}

func TestProfileAndSetCredentialRefAreMutuallyExclusive(t *testing.T) {
credtest.Setup(t)
root := selectorTestRoot()
root.SetIn(strings.NewReader(`{"access_token":"profile-token"}`))
root.SetArgs([]string{"--profile", "work", "set-credential", "--ref", "google-readonly/other", "--key", "oauth_token", "--stdin"})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("profile/local --ref conflict = %v, want mutual-exclusion error", err)
}
}

func assertNoTokenAtRef(t *testing.T, ref string) {
t.Helper()
st, err := keychain.OpenRef(ref)
if err != nil {
t.Fatal(err)
}
defer func() { _ = st.Close() }()
if has, err := st.HasToken(); err != nil || has {
t.Fatalf("%s token presence = (%v, %v), want (false, nil)", ref, has, err)
}
}
109 changes: 109 additions & 0 deletions internal/app/grw/credref_wire_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package grw

import (
"path/filepath"
"strings"
"testing"

"github.com/spf13/cobra"

initcmd "github.com/open-cli-collective/google-cli/internal/cmd/init"
"github.com/open-cli-collective/google-cli/internal/cmd/setcred"
"github.com/open-cli-collective/google-cli/internal/credtest"
"github.com/open-cli-collective/google-cli/internal/keychain"
"github.com/open-cli-collective/google-cli/internal/rootutil"
)

func selectorTestRoot() *cobra.Command {
var verbose, noColor bool
root := &cobra.Command{
Use: "grw",
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
return rootutil.ApplyGlobalFlags(cmd, verbose, noColor)
},
}
rootutil.AddGlobalFlags(root, &verbose, &noColor)
root.AddCommand(initcmd.NewCommand())
root.AddCommand(setcred.NewCmd())
return root
}

func TestProfileFlagInheritedByInitInBothFlagOrders(t *testing.T) {
for _, tc := range []struct {
name string
args func(string) []string
}{
{name: "before command", args: func(path string) []string {
return []string{"--profile", "work", "init", "--credentials-file", path}
}},
{name: "after command", args: func(path string) []string {
return []string{"init", "--profile", "work", "--credentials-file", path}
}},
} {
t.Run(tc.name, func(t *testing.T) {
credtest.Setup(t)
t.Setenv(keychain.CredentialRefEnvVar(), "")
root := selectorTestRoot()
root.SetArgs(tc.args(filepath.Join(t.TempDir(), "missing.json")))
if err := root.Execute(); err == nil {
t.Fatal("init should fail for the intentionally missing client file")
}
if got, set := keychain.GetCredentialRefOverride(); !set || got != "google-readwrite/work" {
t.Fatalf("selector after init path = (%q, %v), want google-readwrite/work", got, set)
}
})
}
}

func TestProfileFlagInheritedBySetCredentialTargetsNamedProfile(t *testing.T) {
for _, tc := range []struct {
name string
args []string
}{
{name: "before command", args: []string{"--profile", "work", "set-credential", "--key", "oauth_token", "--stdin"}},
{name: "after command", args: []string{"set-credential", "--profile", "work", "--key", "oauth_token", "--stdin"}},
} {
t.Run(tc.name, func(t *testing.T) {
credtest.Setup(t)
t.Setenv(keychain.CredentialRefEnvVar(), "")
root := selectorTestRoot()
root.SetIn(strings.NewReader(`{"access_token":"profile-token","refresh_token":"refresh"}`))
root.SetArgs(tc.args)
if err := root.Execute(); err != nil {
t.Fatalf("set-credential: %v", err)
}
st, err := keychain.OpenRef("google-readwrite/work")
if err != nil {
t.Fatal(err)
}
tok, err := st.Token()
_ = st.Close()
if err != nil || tok.AccessToken != "profile-token" {
t.Fatalf("named profile token = %+v, err=%v", tok, err)
}
assertNoTokenAtRef(t, "google-readwrite/default")
})
}
}

func TestProfileAndSetCredentialRefAreMutuallyExclusive(t *testing.T) {
credtest.Setup(t)
root := selectorTestRoot()
root.SetIn(strings.NewReader(`{"access_token":"profile-token"}`))
root.SetArgs([]string{"--profile", "work", "set-credential", "--ref", "google-readwrite/other", "--key", "oauth_token", "--stdin"})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("profile/local --ref conflict = %v, want mutual-exclusion error", err)
}
}

func assertNoTokenAtRef(t *testing.T, ref string) {
t.Helper()
st, err := keychain.OpenRef(ref)
if err != nil {
t.Fatal(err)
}
defer func() { _ = st.Close() }()
if has, err := st.HasToken(); err != nil || has {
t.Fatalf("%s token presence = (%v, %v), want (false, nil)", ref, has, err)
}
}
13 changes: 13 additions & 0 deletions internal/app/grw/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package grw

import (
"os"
"testing"

"github.com/open-cli-collective/google-cli/internal/config"
)

func TestMain(m *testing.M) {
config.Register(Identity())
os.Exit(m.Run())
}
41 changes: 13 additions & 28 deletions internal/cmd/init/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/open-cli-collective/google-cli/internal/config"
"github.com/open-cli-collective/google-cli/internal/identitycache"
"github.com/open-cli-collective/google-cli/internal/keychain"
"github.com/open-cli-collective/google-cli/internal/rootutil"
"github.com/open-cli-collective/google-cli/internal/sanitize"
"github.com/open-cli-collective/google-cli/internal/view"
)
Expand Down Expand Up @@ -70,13 +71,18 @@ for your whole org, see:
` + workspaceAdminsURL + `

You can also copy your credentials.json to the clipboard and run init — it will
read, validate, and write it to the config directory for you.`,
read, validate, and write it to the config directory for you.

To authenticate a named profile without changing the active selection, pass the
global --profile <name> flag before init.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if opts.profile != "" {
if _, err := applyProfileFlag(opts.profile); err != nil {
return err
}
opts.profile = ""
// --profile is registered on the application root. Keep the value
// in initOptions only for the target announcement and post-auth
// guidance; rootutil already expanded it to the keychain ref.
if f := cmd.Flag(rootutil.ProfileFlagName); f != nil && f.Changed {
opts.profile = f.Value.String()
}
return runWith(cmd.Context(), defaultDeps(), opts)
},
Expand All @@ -86,31 +92,10 @@ read, validate, and write it to the config directory for you.`,
cmd.Flags().BoolVar(&opts.noBrowser, "no-browser", false, "Don't try to open the consent URL in a browser")
cmd.Flags().BoolVar(&opts.noVerify, "no-verify", false, "Skip connectivity verification after setup")
cmd.Flags().BoolVar(&opts.authCodeStdin, "auth-code-stdin", false, "Read the OAuth authorization code/redirect URL from stdin (two-phase install; implies no browser-open)")
cmd.Flags().StringVar(&opts.profile, "profile", "", "Authenticate the named profile (stored as <service>/<name>) instead of the active one - the way to ADD an account without touching the active profile's token")

return cmd
}

// applyProfileFlag routes this init run at <service>/<name> via the same
// per-invocation override mechanism as the global --ref flag (flag-level
// precedence; the one-time migration is suppressed automatically, exactly as
// for --ref). Returns the resolved ref.
func applyProfileFlag(profile string) (string, error) {
if v, set := keychain.GetCredentialRefOverride(); set && v != "" {
return "", fmt.Errorf("--profile and --ref are mutually exclusive (--ref %s was given)", v)
}
service, _, err := credstore.ParseRef(config.DefaultCredentialRef)
if err != nil {
return "", err
}
ref, err := credstore.FormatRef(service, profile)
if err != nil {
return "", fmt.Errorf("invalid profile name %q (allowed characters: letters, digits, '-', '_'): %w", profile, err)
}
keychain.SetCredentialRefOverride(ref, true)
return ref, nil
}

// initDeps groups every external collaborator the wizard touches. Tests
// override individual fields; production uses defaultDeps().
type initDeps struct {
Expand Down Expand Up @@ -431,7 +416,7 @@ func runWith(ctx context.Context, d initDeps, opts *initOptions) error {
target = fmt.Sprintf("%s (%s)", ref, sanitize.Output(cachedEmail))
}
if opts.profile == "" {
d.View.Printf("To add a different account instead, use '%s init --profile <name>'.\n", config.ProductName())
d.View.Printf("To add a different account instead, use '%s --profile <name> init'.\n", config.ProductName())
}
d.View.Println("")
}
Expand Down Expand Up @@ -570,7 +555,7 @@ func finishRun(d initDeps, opts *initOptions, targetRef string) error {
d.View.Println("")
d.View.Printf("Profile %s is authenticated but not active.\n", targetRef)
d.View.Printf("Make it active: %s profiles use %s\n", prod, opts.profile)
d.View.Printf("Use per invocation: %s --ref %s <command>\n", prod, targetRef)
d.View.Printf("Use per invocation: %s --profile %s <command>\n", prod, opts.profile)
return nil
}

Expand Down
Loading
Loading