From 0f6d955e697c40a50d313d109894252f094a4409 Mon Sep 17 00:00:00 2001 From: Kai Armstrong Date: Fri, 14 Nov 2025 13:12:51 -0600 Subject: [PATCH] feat: run the Duo CLI from the GitLab CLI --- docs/duo_cli_integration.md | 114 +++++++ docs/source/duo/_index.md | 1 + docs/source/duo/cli.md | 77 +++++ internal/commands/duo/cli/cli.go | 461 ++++++++++++++++++++++++++ internal/commands/duo/cli/cli_test.go | 195 +++++++++++ internal/commands/duo/duo.go | 2 + internal/config/config.yaml.lock | 4 + internal/config/config_stub.go | 18 + internal/config/writefile.go | 1 - internal/iostreams/iostreams.go | 15 +- 10 files changed, 886 insertions(+), 2 deletions(-) create mode 100644 docs/duo_cli_integration.md create mode 100644 docs/source/duo/cli.md create mode 100644 internal/commands/duo/cli/cli.go create mode 100644 internal/commands/duo/cli/cli_test.go diff --git a/docs/duo_cli_integration.md b/docs/duo_cli_integration.md new file mode 100644 index 000000000..571d17b80 --- /dev/null +++ b/docs/duo_cli_integration.md @@ -0,0 +1,114 @@ +# GitLab Duo CLI Integration + +This document describes the integration between the GitLab CLI (`glab`) and the GitLab Duo CLI as a plugin. + +## Overview + +The `glab duo cli` command allows users to run the GitLab Duo CLI without needing to install it separately. The command: + +1. Checks that Node.js version 22+ is installed +2. Prompts for user consent (with persistent preferences) +3. Optionally shares the GitLab authentication token +4. Executes the Duo CLI via `npx @gitlab/duo-cli` +5. Provides a fully interactive AI assistant session + +## Requirements + +- Node.js version 22 or higher + +If Node.js is not installed or the version is too old, the command displays installation instructions. + +## Usage + +```shell +# Launch the interactive Duo CLI +glab duo cli + +# View help for the glab duo cli command +glab duo cli --help +``` + +## User Experience + +### First Run + +On first run, users are prompted with two questions: + +**1. Run the Duo CLI?** + +- **Yes**: Run the Duo CLI this time (will prompt again next time) +- **No**: Cancel execution +- **Always**: Save preference to config and skip this prompt in the future + +**2. Share your GitLab token with Duo CLI?** + +- **Yes**: Share token this time only +- **No**: Don't share this time +- **Always**: Always share token (saves preference) +- **Never**: Never share token (saves preference) + +### Subsequent Runs + +If "Always" was selected for the run prompt, the command launches directly without prompting. + +## Configuration + +Preferences are stored in the glab config file (`~/.config/glab-cli/config.yml`): + +```yaml +duo_cli_auto_run: "true" # Skip run confirmation +duo_cli_share_token: "true" # Always share token +``` + +### Resetting Preferences + +```shell +glab config set duo_cli_auto_run false +glab config set duo_cli_share_token false +``` + +## Implementation Details + +### npx Execution + +The command uses `npx @gitlab/duo-cli` which: + +- Automatically downloads and caches the package on first use +- Uses the latest version (or cached version if available) +- Doesn't require manual installation or updates +- Avoids permission issues with global npm installs + +The Duo CLI is fully interactive and doesn't accept command-line arguments. + +### Authentication + +Token sharing works with: + +- Config file tokens +- Keyring-stored tokens +- Environment variables (`GITLAB_TOKEN`, `GITLAB_ACCESS_TOKEN`) +- 1Password shell plugin: `alias glab='op plugin run -- glab'` + +Token is passed via `GITLAB_TOKEN` environment variable to the Duo CLI. + +### Stdin Handling + +After `huh` prompts complete, the command reopens `/dev/tty` to provide a clean stdin connection for the interactive Duo CLI session. + +## Testing + +```shell +go test ./internal/commands/duo/cli/... +``` + +## Dependencies on Unmerged Changes + +Includes changes from [MR !2549](https://gitlab.com/gitlab-org/cli/-/merge_requests/2549): + +- `ErrUserCancelled` error in iostreams +- Updated `Run()` method for proper cancellation handling + +## Related Issues + +- [#1053](https://gitlab.com/gitlab-org/cli/-/issues/1053) - Allow CLI extensions to be installed +- [#7970](https://gitlab.com/gitlab-org/cli/-/issues/7970) - Duo CLI distribution discussion diff --git a/docs/source/duo/_index.md b/docs/source/duo/_index.md index 98214af03..de00f875f 100644 --- a/docs/source/duo/_index.md +++ b/docs/source/duo/_index.md @@ -29,3 +29,4 @@ Git operations. You can accomplish specific tasks without switching contexts. ## Subcommands - [`ask`](ask.md) +- [`cli`](cli.md) diff --git a/docs/source/duo/cli.md b/docs/source/duo/cli.md new file mode 100644 index 000000000..c914c1eaa --- /dev/null +++ b/docs/source/duo/cli.md @@ -0,0 +1,77 @@ +--- +title: glab duo cli +stage: Create +group: Code Review +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments +--- + + + +Run the GitLab Duo CLI (EXPERIMENTAL) + +## Synopsis + +Run the GitLab Duo CLI as a plugin to the GitLab CLI. + +This command executes the GitLab Duo CLI using npx, providing an interactive +AI-powered assistant for your development workflow. + +Requirements: + +- Node.js version 22 or higher must be installed (includes npx) + +On first run, you'll be prompted to: + +1. Confirm running the Duo CLI (with option to "Always" skip this prompt) +2. Choose whether to share your GitLab token with Duo CLI (with "Always" or "Never" options) + +The Duo CLI package (`@gitlab/duo-cli`) is automatically fetched via npx +and cached for subsequent runs. + +Token Authentication: + +The command checks for authentication in the following priority order: + +1. `GITLAB_TOKEN` environment variable +2. Existing Duo CLI authentication (`~/.gitlab/storage.json`) +3. Sharing your configured glab token (via the `--gitlab-auth-token` flag) + +If environment token or existing Duo CLI auth is detected, you won't be prompted +about token sharing. Otherwise, you can choose to share your glab token. + +Configuration: + +- `duo_cli_auto_run`: Set to "true" to skip the run confirmation prompt +- `duo_cli_share_token`: Set to "true" or "false" to skip the token sharing prompt + +You can reset these preferences with: + +- `glab config set duo_cli_auto_run false` +- `glab config set duo_cli_share_token false` + +This feature is experimental. It might be broken or removed without any prior notice. +Read more about what experimental features mean at + + +Use experimental features at your own risk. + +```plaintext +glab duo cli [flags] +``` + +## Examples + +```console +$ glab duo cli +> Starts an interactive session with GitLab Duo CLI + +``` + +## Options inherited from parent commands + +```plaintext + -h, --help Show help for this command. +``` diff --git a/internal/commands/duo/cli/cli.go b/internal/commands/duo/cli/cli.go new file mode 100644 index 000000000..ccf50331d --- /dev/null +++ b/internal/commands/duo/cli/cli.go @@ -0,0 +1,461 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "gitlab.com/gitlab-org/cli/internal/cmdutils" + "gitlab.com/gitlab-org/cli/internal/iostreams" + "gitlab.com/gitlab-org/cli/internal/text" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" +) + +const ( + duoCLIPackage = "@gitlab/duo-cli" + minNodeVersion = 22 + nodeVersionCommand = "node" + npxCommand = "npx" + duoCLIAutoRunKey = "duo_cli_auto_run" + duoCLIShareTokenKey = "duo_cli_share_token" + nodeCheckTimeout = 5 * time.Second +) + +type opts struct { + Factory cmdutils.Factory + IO *iostreams.IOStreams +} + +func NewCmdCli(f cmdutils.Factory) *cobra.Command { + opts := &opts{ + Factory: f, + IO: f.IO(), + } + + cmd := &cobra.Command{ + Use: "cli", + Short: "Run the GitLab Duo CLI (EXPERIMENTAL)", + Long: heredoc.Docf(` + Run the GitLab Duo CLI as a plugin to the GitLab CLI. + + This command executes the GitLab Duo CLI using npx, providing an interactive + AI-powered assistant for your development workflow. + + Requirements: + + - Node.js version 22 or higher must be installed (includes npx) + + On first run, you'll be prompted to: + + 1. Confirm running the Duo CLI (with option to "Always" skip this prompt) + 2. Choose whether to share your GitLab token with Duo CLI (with "Always" or "Never" options) + + The Duo CLI package (%[1]s@gitlab/duo-cli%[1]s) is automatically fetched via npx + and cached for subsequent runs. + + Token Authentication: + + The command checks for authentication in the following priority order: + + 1. %[1]sGITLAB_TOKEN%[1]s environment variable + 2. Existing Duo CLI authentication (%[1]s~/.gitlab/storage.json%[1]s) + 3. Sharing your configured glab token (via the %[1]s--gitlab-auth-token%[1]s flag) + + If environment token or existing Duo CLI auth is detected, you won't be prompted + about token sharing. Otherwise, you can choose to share your glab token. + + Configuration: + + - %[1]sduo_cli_auto_run%[1]s: Set to "true" to skip the run confirmation prompt + - %[1]sduo_cli_share_token%[1]s: Set to "true" or "false" to skip the token sharing prompt + + You can reset these preferences with: + + - %[1]sglab config set duo_cli_auto_run false%[1]s + - %[1]sglab config set duo_cli_share_token false%[1]s + `, "`") + text.ExperimentalString, + Example: heredoc.Doc(` + $ glab duo cli + > Starts an interactive session with GitLab Duo CLI + `), + RunE: func(cmd *cobra.Command, args []string) error { + return opts.run(cmd.Context(), args) + }, + } + + return cmd +} + +func (o *opts) run(ctx context.Context, _ []string) error { + // 1. Validate prerequisites + if err := o.validatePrerequisites(ctx); err != nil { + return err + } + + // 2. Check if user wants to run Duo CLI + if err := o.checkAutoRun(ctx); err != nil { + return err + } + + // 3. Determine authentication method and get token if needed + authToken, err := o.resolveAuthentication(ctx) + if err != nil { + return err + } + + // 4. Execute Duo CLI + return o.executeDuoCLI(ctx, authToken) +} + +// validatePrerequisites checks Node.js and npx availability +func (o *opts) validatePrerequisites(ctx context.Context) error { + if err := o.checkNodeVersion(ctx); err != nil { + return err + } + return o.checkNpxAvailable(ctx) +} + +// checkAutoRun prompts user to confirm running Duo CLI (unless auto-run is enabled) +func (o *opts) checkAutoRun(ctx context.Context) error { + cfg := o.Factory.Config() + autoRunStr, _ := cfg.Get("", duoCLIAutoRunKey) + + if autoRunStr != "" { + if autoRun, err := strconv.ParseBool(autoRunStr); err == nil && autoRun { + return nil + } + } + + return o.promptAutoRun(ctx) +} + +// promptAutoRun shows the "Run the GitLab Duo CLI?" prompt +func (o *opts) promptAutoRun(ctx context.Context) error { + var choice string + + selector := huh.NewSelect[string](). + Title("Run the GitLab Duo CLI?"). + Description("Requires Node.js 22+, will download via npx on first run"). + Options( + huh.NewOption("Yes", "Yes"), + huh.NewOption("No", "No"), + huh.NewOption("Always", "Always"), + ). + Value(&choice) + + if err := o.IO.Run(ctx, selector); err != nil { + if errors.Is(err, iostreams.ErrUserCancelled) { + return cmdutils.SilentError + } + return err + } + + return o.handleAutoRunChoice(choice) +} + +// handleAutoRunChoice processes the user's auto-run choice +func (o *opts) handleAutoRunChoice(choice string) error { + switch choice { + case "No": + return cmdutils.SilentError + case "Always": + if err := o.saveConfigValue(duoCLIAutoRunKey, "true", "preference"); err != nil { + return err + } + fmt.Fprintln(o.IO.StdErr, "✓ Preference saved: will always run Duo CLI without prompting") + } + return nil +} + +// resolveAuthentication determines which authentication method to use +// Returns the token to pass via --gitlab-auth-token flag (empty string if using env or Duo CLI auth) +func (o *opts) resolveAuthentication(ctx context.Context) (string, error) { + // Priority 1: GITLAB_TOKEN environment variable + if token := o.checkEnvironmentToken(); token != "" { + return "", nil + } + + // Priority 2: Existing Duo CLI authentication + if o.checkDuoCLIAuth() { + return "", nil + } + + // Priority 3: Share glab token (with prompt) + return o.resolveConfigToken(ctx) +} + +// checkEnvironmentToken checks for GITLAB_TOKEN environment variable +func (o *opts) checkEnvironmentToken() string { + envToken := os.Getenv("GITLAB_TOKEN") + if envToken != "" { + fmt.Fprintln(o.IO.StdErr, "ℹ Using GITLAB_TOKEN from environment") + } + return envToken +} + +// checkDuoCLIAuth checks for existing Duo CLI authentication +func (o *opts) checkDuoCLIAuth() bool { + if o.hasDuoCLIAuth() { + fmt.Fprintln(o.IO.StdErr, "ℹ Using existing Duo CLI authentication") + return true + } + return false +} + +// resolveConfigToken handles the token sharing prompt and retrieves config token if needed +func (o *opts) resolveConfigToken(ctx context.Context) (string, error) { + cfg := o.Factory.Config() + shareTokenStr, _ := cfg.Get("", duoCLIShareTokenKey) + + var shouldShare bool + var hasPreference bool + + // Check if user has already set their preference + if shareTokenStr != "" { + if parsed, err := strconv.ParseBool(shareTokenStr); err == nil { + shouldShare = parsed + hasPreference = true + } + } + + // If no preference set, prompt the user + if !hasPreference { + shareTokenBool, err := o.promptTokenSharing(ctx) + if err != nil { + return "", err + } + shouldShare = shareTokenBool + } + + // If user doesn't want to share, return empty string + if !shouldShare { + return "", nil + } + + // Get the token from config + return o.getConfigToken() +} + +// promptTokenSharing shows the "Share your GitLab token?" prompt +func (o *opts) promptTokenSharing(ctx context.Context) (bool, error) { + var choice string + + selector := huh.NewSelect[string](). + Title("Share your GitLab token with Duo CLI?"). + Description("This allows Duo CLI to authenticate automatically without separate login"). + Options( + huh.NewOption("Yes", "Yes"), + huh.NewOption("No", "No"), + huh.NewOption("Always", "Always"), + huh.NewOption("Never", "Never"), + ). + Value(&choice) + + if err := o.IO.Run(ctx, selector); err != nil { + if errors.Is(err, iostreams.ErrUserCancelled) { + return false, cmdutils.SilentError + } + return false, err + } + + return o.handleTokenSharingChoice(choice) +} + +// handleTokenSharingChoice processes the user's token sharing choice +func (o *opts) handleTokenSharingChoice(choice string) (bool, error) { + switch choice { + case "No": + return false, nil + case "Always": + if err := o.saveConfigValue(duoCLIShareTokenKey, "true", "token sharing preference"); err != nil { + return false, err + } + fmt.Fprintln(o.IO.StdErr, "✓ Preference saved: will always share GitLab token with Duo CLI") + return true, nil + case "Never": + if err := o.saveConfigValue(duoCLIShareTokenKey, "false", "token sharing preference"); err != nil { + return false, err + } + fmt.Fprintln(o.IO.StdErr, "✓ Preference saved: will never share GitLab token with Duo CLI") + return false, nil + case "Yes": + return true, nil + } + + return false, nil +} + +// getConfigToken retrieves the GitLab token from config +func (o *opts) getConfigToken() (string, error) { + cfg := o.Factory.Config() + hostname := o.Factory.DefaultHostname() + + token, _ := cfg.Get(hostname, "token") + // Return empty string if token not found or empty + // Duo CLI will need to authenticate separately in this case + return token, nil +} + +// executeDuoCLI executes the Duo CLI command with the provided auth token +func (o *opts) executeDuoCLI(ctx context.Context, authToken string) error { + // Build command + duoCmd := o.buildCommand(ctx, authToken) + + // Set up I/O + o.setupCommandIO(duoCmd) + + // Execute + if err := duoCmd.Run(); err != nil { + return cmdutils.WrapError(err, "failed to execute Duo CLI") + } + + return nil +} + +// buildCommand creates the exec.Cmd for running Duo CLI +func (o *opts) buildCommand(ctx context.Context, authToken string) *exec.Cmd { + args := []string{duoCLIPackage} + if authToken != "" { + args = append(args, "--gitlab-auth-token", authToken) + } + + cmd := exec.CommandContext(ctx, npxCommand, args...) + cmd.Env = os.Environ() + return cmd +} + +// setupCommandIO configures stdin, stdout, and stderr for the command +func (o *opts) setupCommandIO(cmd *exec.Cmd) { + cmd.Stdin = o.IO.In + cmd.Stdout = o.IO.StdOut + cmd.Stderr = o.IO.StdErr +} + +// saveConfigValue saves a configuration value and writes the config file +func (o *opts) saveConfigValue(key, value, description string) error { + cfg := o.Factory.Config() + if err := cfg.Set("", key, value); err != nil { + return fmt.Errorf("failed to save %s: %w", description, err) + } + if err := cfg.Write(); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + return nil +} + +// checkNpxAvailable verifies that npx is installed +func (o *opts) checkNpxAvailable(ctx context.Context) error { + // Add a reasonable timeout for the npx check + ctx, cancel := context.WithTimeout(ctx, nodeCheckTimeout) + defer cancel() + + // Check if npx is available + npxCmd := exec.CommandContext(ctx, npxCommand, "--version") + _, err := npxCmd.Output() + if err != nil { + // Check if it's a timeout + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("checking npx availability timed out after %v", nodeCheckTimeout) + } + return o.handleMissingNpx() + } + + return nil +} + +// checkNodeVersion verifies that Node.js is installed and meets the minimum version requirement +func (o *opts) checkNodeVersion(ctx context.Context) error { + // Add a reasonable timeout for the node version check + ctx, cancel := context.WithTimeout(ctx, nodeCheckTimeout) + defer cancel() + + // Check if node is available + nodeCmd := exec.CommandContext(ctx, nodeVersionCommand, "--version") + output, err := nodeCmd.Output() + if err != nil { + // Check if it's a timeout + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("checking Node.js version timed out after %v", nodeCheckTimeout) + } + return o.handleMissingNode() + } + + // Parse version (format: v22.0.0) + versionStr := strings.TrimSpace(string(output)) + versionStr = strings.TrimPrefix(versionStr, "v") + + // Extract major version + parts := strings.Split(versionStr, ".") + if len(parts) == 0 { + return fmt.Errorf("failed to parse Node.js version: %s", versionStr) + } + + majorVersion, err := strconv.Atoi(parts[0]) + if err != nil { + return fmt.Errorf("failed to parse Node.js major version: %w", err) + } + + if majorVersion < minNodeVersion { + return o.handleOldNodeVersion(majorVersion) + } + + return nil +} + +// handleMissingNpx provides instructions when npx is not installed +func (o *opts) handleMissingNpx() error { + return fmt.Errorf("npx is required but not installed. npx is typically installed with Node.js. Please install Node.js version %d or higher from https://nodejs.org/", minNodeVersion) +} + +// handleMissingNode provides instructions when Node.js is not installed +func (o *opts) handleMissingNode() error { + return fmt.Errorf("Node.js is required but not installed. Please install Node.js version %d or higher from https://nodejs.org/", minNodeVersion) +} + +// handleOldNodeVersion provides instructions when Node.js version is too old +func (o *opts) handleOldNodeVersion(currentVersion int) error { + return fmt.Errorf("Node.js version %d is installed, but version %d or higher is required. Please install Node.js version %d or higher from https://nodejs.org/", currentVersion, minNodeVersion, minNodeVersion) +} + +// hasDuoCLIAuth checks if the Duo CLI has existing authentication configured +// by checking for the presence and validity of ~/.gitlab/storage.json +func (o *opts) hasDuoCLIAuth() bool { + home, err := os.UserHomeDir() + if err != nil { + return false + } + + storagePath := filepath.Join(home, ".gitlab", "storage.json") + + // Read the storage file + data, err := os.ReadFile(storagePath) + if err != nil { + // File doesn't exist or can't be read + return false + } + + // Parse the JSON to check for authentication token + var storage struct { + DuoCLIConfig struct { + GitLabAuthToken string `json:"gitlabAuthToken"` + } `json:"duo-cli-config"` + } + + if err := json.Unmarshal(data, &storage); err != nil { + // Invalid JSON + return false + } + + // Only return true if there's actually a token configured + return storage.DuoCLIConfig.GitLabAuthToken != "" +} diff --git a/internal/commands/duo/cli/cli_test.go b/internal/commands/duo/cli/cli_test.go new file mode 100644 index 000000000..69b31487b --- /dev/null +++ b/internal/commands/duo/cli/cli_test.go @@ -0,0 +1,195 @@ +//go:build !integration + +package cli + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "gitlab.com/gitlab-org/cli/internal/config" + "gitlab.com/gitlab-org/cli/internal/testing/cmdtest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCmdCli(t *testing.T) { + io, _, _, _ := cmdtest.TestIOStreams() + cfg := config.NewBlankConfig() + + f := cmdtest.NewTestFactory(io, cmdtest.WithConfig(cfg)) + + cmd := NewCmdCli(f) + + // Verify the command was created with the right properties + assert.NotNil(t, cmd) + assert.Equal(t, "cli", cmd.Use) + assert.Equal(t, "Run the GitLab Duo CLI (EXPERIMENTAL)", cmd.Short) + assert.Contains(t, cmd.Long, "npx") + assert.Contains(t, cmd.Long, "Node.js version 22") + assert.Contains(t, cmd.Long, "duo_cli_auto_run") + assert.Contains(t, cmd.Long, "duo_cli_share_token") + assert.NotNil(t, cmd.RunE) +} + +func TestHandleMissingNpx(t *testing.T) { + ios, _, _, _ := cmdtest.TestIOStreams() + cfg := config.NewBlankConfig() + f := cmdtest.NewTestFactory(ios, cmdtest.WithConfig(cfg)) + + opts := &opts{ + Factory: f, + IO: ios, + } + + err := opts.handleMissingNpx() + require.Error(t, err) + assert.Contains(t, err.Error(), "npx is required but not installed") + assert.Contains(t, err.Error(), "typically installed with Node.js") + assert.Contains(t, err.Error(), "Please install Node.js version 22 or higher") + assert.Contains(t, err.Error(), "https://nodejs.org/") +} + +func TestHandleMissingNode(t *testing.T) { + ios, _, _, _ := cmdtest.TestIOStreams() + cfg := config.NewBlankConfig() + f := cmdtest.NewTestFactory(ios, cmdtest.WithConfig(cfg)) + + opts := &opts{ + Factory: f, + IO: ios, + } + + err := opts.handleMissingNode() + require.Error(t, err) + assert.Contains(t, err.Error(), "Node.js is required but not installed") + assert.Contains(t, err.Error(), "version 22 or higher") + assert.Contains(t, err.Error(), "https://nodejs.org/") +} + +func TestHandleOldNodeVersion(t *testing.T) { + ios, _, _, _ := cmdtest.TestIOStreams() + cfg := config.NewBlankConfig() + f := cmdtest.NewTestFactory(ios, cmdtest.WithConfig(cfg)) + + opts := &opts{ + Factory: f, + IO: ios, + } + + err := opts.handleOldNodeVersion(18) + require.Error(t, err) + assert.Contains(t, err.Error(), "Node.js version 18 is installed") + assert.Contains(t, err.Error(), "version 22 or higher is required") + assert.Contains(t, err.Error(), "Please install Node.js version 22 or higher") + assert.Contains(t, err.Error(), "https://nodejs.org/") +} + +func TestSaveConfigValue(t *testing.T) { + cfgStr := "" + cfg := config.NewFromString(cfgStr) + + var configBuf bytes.Buffer + var aliasesBuf bytes.Buffer + restore := config.StubWriteConfig(&configBuf, &aliasesBuf) + defer restore() + + ios, _, _, _ := cmdtest.TestIOStreams() + f := cmdtest.NewTestFactory(ios, cmdtest.WithConfig(cfg)) + + opts := &opts{ + Factory: f, + IO: ios, + } + + err := opts.saveConfigValue("test_key", "test_value", "test preference") + require.NoError(t, err) + assert.Greater(t, configBuf.Len(), 0, "config should have been written") +} + +func TestHasDuoCLIAuth(t *testing.T) { + tests := []struct { + name string + storageContent string + createFile bool + expectAuth bool + }{ + { + name: "file doesn't exist", + createFile: false, + expectAuth: false, + }, + { + name: "file exists but empty", + createFile: true, + storageContent: "", + expectAuth: false, + }, + { + name: "file exists with empty JSON", + createFile: true, + storageContent: "{}", + expectAuth: false, + }, + { + name: "file exists with duo-cli-config but no token", + createFile: true, + storageContent: `{"duo-cli-config": {}}`, + expectAuth: false, + }, + { + name: "file exists with empty token", + createFile: true, + storageContent: `{"duo-cli-config": {"gitlabAuthToken": ""}}`, + expectAuth: false, + }, + { + name: "file exists with valid token", + createFile: true, + storageContent: `{"duo-cli-config": {"gitlabAuthToken": "glpat-test-token"}}`, + expectAuth: true, + }, + { + name: "file exists with invalid JSON", + createFile: true, + storageContent: `{"duo-cli-config": invalid json}`, + expectAuth: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := cmdtest.TestIOStreams() + cfg := config.NewBlankConfig() + f := cmdtest.NewTestFactory(ios, cmdtest.WithConfig(cfg)) + + opts := &opts{ + Factory: f, + IO: ios, + } + + // Create a temporary directory for testing + tmpDir := t.TempDir() + + // Set up the storage file if needed + if tt.createFile { + gitlabDir := filepath.Join(tmpDir, ".gitlab") + err := os.MkdirAll(gitlabDir, 0o755) + require.NoError(t, err) + + storagePath := filepath.Join(gitlabDir, "storage.json") + err = os.WriteFile(storagePath, []byte(tt.storageContent), 0o644) + require.NoError(t, err) + } + + // Temporarily override the home directory for testing + t.Setenv("HOME", tmpDir) + + // Test the function + hasAuth := opts.hasDuoCLIAuth() + assert.Equal(t, tt.expectAuth, hasAuth) + }) + } +} diff --git a/internal/commands/duo/duo.go b/internal/commands/duo/duo.go index ce2d14a4c..ebf6fb7f3 100644 --- a/internal/commands/duo/duo.go +++ b/internal/commands/duo/duo.go @@ -3,6 +3,7 @@ package duo import ( "gitlab.com/gitlab-org/cli/internal/cmdutils" duoAskCmd "gitlab.com/gitlab-org/cli/internal/commands/duo/ask" + duoCliCmd "gitlab.com/gitlab-org/cli/internal/commands/duo/cli" "github.com/MakeNowJust/heredoc/v2" "github.com/spf13/cobra" @@ -22,6 +23,7 @@ func NewCmdDuo(f cmdutils.Factory) *cobra.Command { } duoCmd.AddCommand(duoAskCmd.NewCmdAsk(f)) + duoCmd.AddCommand(duoCliCmd.NewCmdCli(f)) return duoCmd } diff --git a/internal/config/config.yaml.lock b/internal/config/config.yaml.lock index 4785ed8f8..7eb72c7e1 100644 --- a/internal/config/config.yaml.lock +++ b/internal/config/config.yaml.lock @@ -20,6 +20,10 @@ no_prompt: false # See https://docs.gitlab.com/administration/settings/usage_statistics/ # for more information telemetry: true +# Automatically run GitLab Duo CLI without prompting (true/false). Set to true to skip the confirmation prompt. +duo_cli_auto_run: +# Share GitLab token with Duo CLI (true/false). Set to true to always share, false to never share, or leave empty to be prompted each time. +duo_cli_share_token: # Configuration specific for GitLab instances. hosts: gitlab.com: diff --git a/internal/config/config_stub.go b/internal/config/config_stub.go index 3ba1889db..7a8af890d 100644 --- a/internal/config/config_stub.go +++ b/internal/config/config_stub.go @@ -104,6 +104,24 @@ func rootConfig() *yaml.Node { Kind: yaml.ScalarNode, Value: "true", }, + { + HeadComment: "# Automatically run GitLab Duo CLI without prompting (true/false). Set to true to skip the confirmation prompt.", + Kind: yaml.ScalarNode, + Value: "duo_cli_auto_run", + }, + { + Kind: yaml.ScalarNode, + Value: "", + }, + { + HeadComment: "# Share GitLab token with Duo CLI (true/false). Set to true to always share, false to never share, or leave empty to be prompted each time.", + Kind: yaml.ScalarNode, + Value: "duo_cli_share_token", + }, + { + Kind: yaml.ScalarNode, + Value: "", + }, { HeadComment: "# Configuration specific for GitLab instances.", Kind: yaml.ScalarNode, diff --git a/internal/config/writefile.go b/internal/config/writefile.go index 302d4c047..4f42fd1a4 100644 --- a/internal/config/writefile.go +++ b/internal/config/writefile.go @@ -1,5 +1,4 @@ //go:build !windows -// +build !windows package config diff --git a/internal/iostreams/iostreams.go b/internal/iostreams/iostreams.go index 378e55618..1df0f072e 100644 --- a/internal/iostreams/iostreams.go +++ b/internal/iostreams/iostreams.go @@ -3,6 +3,7 @@ package iostreams import ( "bufio" "context" + "errors" "fmt" "io" "os" @@ -19,6 +20,9 @@ import ( "gitlab.com/gitlab-org/cli/internal/utils" ) +// ErrUserCancelled is returned when the user cancels a prompt (e.g., by pressing Ctrl+C) +var ErrUserCancelled = errors.New("user cancelled") + type IOStreams struct { In io.ReadCloser StdOut io.Writer @@ -347,5 +351,14 @@ func (s *IOStreams) Run(ctx context.Context, field huh.Field) error { WithOutput(s.StdOut). WithShowHelp(false). WithTheme(theme.HuhTheme()) - return form.RunWithContext(ctx) + err := form.RunWithContext(ctx) + + // Convert huh.ErrUserAborted to iostreams.ErrUserCancelled for consistent error handling + // This allows callers to handle cancellation without depending on the huh library + if errors.Is(err, huh.ErrUserAborted) { + fmt.Fprintln(s.StdErr, "Cancelled.") + return ErrUserCancelled + } + + return err } -- GitLab