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
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,17 @@ Bootstraps the CLI configuration in your project's folder. This command creates
- **Remote mode (fetch configuration from Codacy):**
```bash
codacy-cli init --api-token <token> --provider <gh|gl|bb> --organization <org> --repository <repo>

# or, with a repository token instead of an account API token
codacy-cli init --project-token <token> --provider <gh|gl|bb> --organization <org> --repository <repo>
```

**Flags:**
- `--api-token` (string): Codacy API token (optional; enables fetching remote config)
- `--provider` (string): Provider (`gh`, `gl`, `bb`), required with `--api-token`
- `--organization` (string): Organization name, required with `--api-token`
- `--repository` (string): Repository name, required with `--api-token`
- `--api-token` (string): Codacy account API token (optional; enables fetching remote config)
- `--project-token` (string): [Codacy repository token](https://docs.codacy.com/codacy-api/api-tokens/#repository-api-tokens), alternative to `--api-token`. Falls back to the `CODACY_PROJECT_TOKEN` environment variable, but only when `--provider`, `--organization` and `--repository` are also given — the variable alone never switches a local run to remote mode
- `--provider` (string): Provider (`gh`, `gl`, `bb`), required with `--api-token` or `--project-token`
- `--organization` (string): Organization name, required with `--api-token` or `--project-token`
- `--repository` (string): Repository name, required with `--api-token` or `--project-token`

### `config reset` — Reset Configuration

Expand All @@ -90,7 +94,7 @@ codacy-cli config reset --api-token <token> --provider <gh|gl|bb> --organization
- Overwrites existing `.codacy/codacy.yaml` and tool configurations
- Creates or updates `.codacy/.gitignore` file

**Flags:** Same as `init` command (api-token, provider, organization, repository)
**Flags:** Same as `init` command (api-token, project-token, provider, organization, repository)

### `config discover` — Discover Project Languages

Expand Down
8 changes: 5 additions & 3 deletions cmd/analyze.go
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ func checkIfConfigExistsAndIsNeeded(toolName string, cliLocalMode bool) error {
"toolConfigPath": repoConfigPath,
})
return nil
} else if (!cliLocalMode && initFlags.ApiToken != "") || cliLocalMode {
} else if (!cliLocalMode && initFlags.HasRemoteToken()) || cliLocalMode {

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 RISK

Suggestion: This condition (!cliLocalMode && initFlags.HasRemoteToken()) || cliLocalMode always evaluates to true because cliLocalMode is the negation of HasRemoteToken(). This makes the else block on line 350 unreachable dead code, including the log message on line 354. The logic should be simplified or corrected to ensure the intended fallback behavior works.

if err := configsetup.CreateToolConfigurationFile(toolName, initFlags); err != nil {
return fmt.Errorf("failed to create config file for tool %s: %w", toolName, err)
}
Expand All @@ -351,7 +351,7 @@ func checkIfConfigExistsAndIsNeeded(toolName string, cliLocalMode bool) error {
logger.Debug("Config file not found for tool, using tool defaults", logrus.Fields{
"tool": toolName,
"toolConfigPath": toolConfigPath,
"message": "No API token provided",
"message": "No API token or project token provided",
})
}
} else if err != nil {
Expand Down Expand Up @@ -507,6 +507,8 @@ var analyzeCmd = &cobra.Command{

Supports API token, provider, and repository flags to automatically fetch tool configurations from Codacy API if they don't exist locally.`,
Run: func(cmd *cobra.Command, args []string) {
cmdutils.PrepareRemoteFlags(cmd, &initFlags)

// Validate paths before proceeding
if err := validatePaths(args); err != nil {
fmt.Println(err)
Expand All @@ -519,7 +521,7 @@ Supports API token, provider, and repository flags to automatically fetch tool c
log.Fatalf("Failed to get current working directory: %v", err)
}

cliLocalMode := len(initFlags.ApiToken) == 0
cliLocalMode := !initFlags.HasRemoteToken()

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 RISK

The analyze command is missing a call to cmdutils.ValidateRemoteFlags(initFlags). This results in the command attempting to fetch repository tools with potentially empty provider, organization, or repository segments if a token is provided without coordinates.


var toolsToRun map[string]*plugins.ToolInfo

Expand Down
60 changes: 56 additions & 4 deletions cmd/cmdutils/flags.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,68 @@
package cmdutils

import (
"errors"
"fmt"
"log"
"os"

"codacy/cli-v2/domain"

"github.com/spf13/cobra"
)

// ProjectTokenEnvVar is also the codacy-coverage-reporter's variable, so it is often
// exported for unrelated reasons - see ResolveProjectToken.
const ProjectTokenEnvVar = "CODACY_PROJECT_TOKEN"

// AddCloudFlags adds the common cloud-related flags to a cobra command.
// The flags will be bound to the provided flags struct.
func AddCloudFlags(cmd *cobra.Command, flags *domain.InitFlags) {
cmd.Flags().StringVar(&flags.ApiToken, "api-token", "", "Optional Codacy API token. If defined, configurations will be fetched from Codacy")
cmd.Flags().StringVar(&flags.Provider, "provider", "", "Provider (e.g., gh, bb, gl) to fetch configurations from Codacy. Required when api-token is provided")
cmd.Flags().StringVar(&flags.Organization, "organization", "", "Remote organization name to fetch configurations from Codacy. Required when api-token is provided")
cmd.Flags().StringVar(&flags.Repository, "repository", "", "Remote repository name to fetch configurations from Codacy. Required when api-token is provided")
cmd.Flags().StringVar(&flags.ApiToken, "api-token", "", "Optional Codacy account API token. If defined, configurations will be fetched from Codacy")

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 RISK

Suggestion: The flag registration logic in AddCloudFlags is currently untested. Consider adding a unit test in flags_test.go that initializes a cobra command, calls AddCloudFlags, and verifies that all flags (api-token, project-token, provider, organization, repository) are correctly registered with their expected default values.

See Coverage in Codacy

cmd.Flags().StringVar(&flags.ProjectToken, "project-token", "", "Optional Codacy repository token, an alternative to api-token for fetching configurations from Codacy. Falls back to CODACY_PROJECT_TOKEN when provider, organization and repository are given. See https://docs.codacy.com/codacy-api/api-tokens/#repository-api-tokens")
cmd.Flags().StringVar(&flags.Provider, "provider", "", "Provider (e.g., gh, bb, gl) to fetch configurations from Codacy. Required when api-token or project-token is provided")
cmd.Flags().StringVar(&flags.Organization, "organization", "", "Remote organization name to fetch configurations from Codacy. Required when api-token or project-token is provided")
cmd.Flags().StringVar(&flags.Repository, "repository", "", "Remote repository name to fetch configurations from Codacy. Required when api-token or project-token is provided")
}

// ResolveProjectToken falls back to CODACY_PROJECT_TOKEN, but only once the repository
// coordinates are known. The variable is the coverage reporter's and is routinely exported
// CI-wide, so on its own it must not switch a local run into remote mode.
func ResolveProjectToken(flags *domain.InitFlags) {
if flags.HasRemoteToken() || !flags.HasRepositoryCoordinates() {
return
}
flags.ProjectToken = os.Getenv(ProjectTokenEnvVar)
}

// ValidateRemoteFlags rejects a token given without the repository coordinates every
// remote read needs, so commands fail with a clear message instead of requesting a URL
// with empty path segments.
func ValidateRemoteFlags(flags domain.InitFlags) error {
if !flags.HasRemoteToken() || flags.HasRepositoryCoordinates() {
return nil
}
return errors.New("when using --api-token or --project-token, you must also provide --provider, --organization, and --repository flags")
}

// exit is swappable so the failure path stays testable.
var exit = os.Exit

// PrepareRemoteFlags resolves the environment fallback and rejects an incomplete remote
// setup, so every command that takes cloud flags behaves the same way.
func PrepareRemoteFlags(cmd *cobra.Command, flags *domain.InitFlags) {
ResolveProjectToken(flags)

err := ValidateRemoteFlags(*flags)
if err == nil {
return
}

fmt.Printf("Error: %s.\n", err)
fmt.Println("Please provide all required flags and try again.")
fmt.Println()
if errHelp := cmd.Help(); errHelp != nil {
log.Printf("Warning: Failed to display command help: %v", errHelp)
}
exit(1)
}
157 changes: 157 additions & 0 deletions cmd/cmdutils/flags_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package cmdutils

import (
"io"
"testing"

"codacy/cli-v2/domain"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
)

func completeFlags() domain.InitFlags {
return domain.InitFlags{Provider: "gh", Organization: "org", Repository: "repo"}
}

func TestAddCloudFlags(t *testing.T) {
t.Setenv(ProjectTokenEnvVar, "token-from-env")

cmd := &cobra.Command{Use: "test"}
flags := domain.InitFlags{}
AddCloudFlags(cmd, &flags)

for _, name := range []string{"api-token", "project-token", "provider", "organization", "repository"} {
assert.NotNil(t, cmd.Flags().Lookup(name), "flag %s should be registered", name)
}

// the environment must not leak in at registration time - see ResolveProjectToken
assert.Equal(t, "", cmd.Flags().Lookup("project-token").DefValue)
assert.Equal(t, domain.InitFlags{}, flags)
}

func TestAddCloudFlagsBindsValues(t *testing.T) {
cmd := &cobra.Command{Use: "test", Run: func(*cobra.Command, []string) {}}
flags := domain.InitFlags{}
AddCloudFlags(cmd, &flags)

cmd.SetArgs([]string{"--project-token", "tok", "--provider", "gh", "--organization", "org", "--repository", "repo"})
assert.NoError(t, cmd.Execute())

assert.Equal(t, domain.InitFlags{ProjectToken: "tok", Provider: "gh", Organization: "org", Repository: "repo"}, flags)
assert.NoError(t, ValidateRemoteFlags(flags))
}

func TestResolveProjectToken(t *testing.T) {
tests := []struct {
name string
env string
flags domain.InitFlags
expectedToken string
}{
{"environment applies once coordinates are known", "env-token", completeFlags(), "env-token"},
{"environment ignored without coordinates", "env-token", domain.InitFlags{}, ""},
{"environment ignored with partial coordinates", "env-token", domain.InitFlags{Provider: "gh", Organization: "org"}, ""},
{"explicit flag wins over environment", "env-token", domain.InitFlags{ProjectToken: "flag-token", Provider: "gh", Organization: "org", Repository: "repo"}, "flag-token"},
{"account token is not overwritten", "env-token", domain.InitFlags{ApiToken: "api", Provider: "gh", Organization: "org", Repository: "repo"}, ""},
{"unset environment leaves flags alone", "", completeFlags(), ""},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv(ProjectTokenEnvVar, tt.env)

flags := tt.flags
ResolveProjectToken(&flags)

assert.Equal(t, tt.expectedToken, flags.ProjectToken)
})
}
}

func TestValidateRemoteFlags(t *testing.T) {
tests := []struct {
name string
flags domain.InitFlags
expectError bool
}{
{"no token needs no coordinates", domain.InitFlags{}, false},
{"no token with partial coordinates", domain.InitFlags{Provider: "gh"}, false},
{"account token with coordinates", domain.InitFlags{ApiToken: "api", Provider: "gh", Organization: "org", Repository: "repo"}, false},
{"repository token with coordinates", domain.InitFlags{ProjectToken: "project", Provider: "gh", Organization: "org", Repository: "repo"}, false},
{"repository token without coordinates", domain.InitFlags{ProjectToken: "project"}, true},
{"account token without coordinates", domain.InitFlags{ApiToken: "api"}, true},
{"token missing provider", domain.InitFlags{ProjectToken: "project", Organization: "org", Repository: "repo"}, true},
{"token missing organization", domain.InitFlags{ProjectToken: "project", Provider: "gh", Repository: "repo"}, true},
{"token missing repository", domain.InitFlags{ProjectToken: "project", Provider: "gh", Organization: "org"}, true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateRemoteFlags(tt.flags)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}

// stopped reports whether PrepareRemoteFlags terminated the command. The exit stub panics
// so a helper that kept running past it would fail the assertion instead of passing.
func stopped(t *testing.T, flags *domain.InitFlags) bool {
t.Helper()

original := exit
defer func() { exit = original }()

type exitCall struct{ code int }
exit = func(code int) { panic(exitCall{code}) }

cmd := &cobra.Command{Use: "test"}
cmd.SetOut(io.Discard)

didExit := false
func() {
defer func() {
if r := recover(); r != nil {
call, ok := r.(exitCall)
assert.True(t, ok, "unexpected panic: %v", r)
assert.Equal(t, 1, call.code)
didExit = true
}
}()
PrepareRemoteFlags(cmd, flags)
}()

return didExit
}

func TestPrepareRemoteFlags(t *testing.T) {
t.Run("stops on a token without coordinates", func(t *testing.T) {
flags := domain.InitFlags{ProjectToken: "tok"}
assert.True(t, stopped(t, &flags))
})

t.Run("continues without any token", func(t *testing.T) {
flags := domain.InitFlags{}
assert.False(t, stopped(t, &flags))
})

t.Run("continues when the environment token is ignored", func(t *testing.T) {
t.Setenv(ProjectTokenEnvVar, "env-token")

flags := domain.InitFlags{}
assert.False(t, stopped(t, &flags))
assert.Equal(t, "", flags.ProjectToken)
})

t.Run("adopts the environment token with coordinates", func(t *testing.T) {
t.Setenv(ProjectTokenEnvVar, "env-token")

flags := completeFlags()
assert.False(t, stopped(t, &flags))
assert.Equal(t, "env-token", flags.ProjectToken)
})
}
31 changes: 11 additions & 20 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,14 @@ var configResetCmd = &cobra.Command{
currentCliMode = "local" // Default to local as per existing logic
}

apiTokenFlagProvided := len(configResetInitFlags.ApiToken) > 0
// Resolve the environment fallback first, so a CI run that exports the token and
// passes the repository coordinates counts as an explicit remote reset below.
cmdutils.PrepareRemoteFlags(cmd, &configResetInitFlags)

// If current mode is 'remote', prevent resetting to local without explicit API token for a remote reset.
if currentCliMode == "remote" && !apiTokenFlagProvided {
// If current mode is 'remote', prevent resetting to local without a token for a remote reset.
if currentCliMode == "remote" && !configResetInitFlags.HasRemoteToken() {
fmt.Println("Error: Your Codacy CLI is currently configured in 'remote' (cloud) mode.")
fmt.Println("To reset your configuration using remote settings, you must provide the --api-token, --provider, --organization, and --repository flags.")
fmt.Println("To reset your configuration using remote settings, you must provide --api-token (or --project-token) together with the --provider, --organization, and --repository flags.")
fmt.Println("Running 'config reset' without these flags is not permitted while configured for 'remote' mode.")
fmt.Println("This prevents an accidental switch to a local default configuration.")
fmt.Println()
Expand All @@ -66,19 +68,6 @@ var configResetCmd = &cobra.Command{
os.Exit(1)
}

// Validate flags: if API token is provided, other related flags must also be provided.
if apiTokenFlagProvided {
if configResetInitFlags.Provider == "" || configResetInitFlags.Organization == "" || configResetInitFlags.Repository == "" {
fmt.Println("Error: When using --api-token, you must also provide --provider, --organization, and --repository flags.")
fmt.Println("Please provide all required flags and try again.")
fmt.Println()
if errHelp := cmd.Help(); errHelp != nil {
log.Fatalf("Failed to display command help: %v", errHelp)
}
os.Exit(1)
}
}

codacyConfigFile := config.Config.ProjectConfigFile()
// Check if the main configuration file exists
if _, err := os.Stat(codacyConfigFile); os.IsNotExist(err) {
Expand Down Expand Up @@ -106,7 +95,7 @@ func runConfigResetLogic(cmd *cobra.Command, args []string, flags domain.InitFla
}

// Determine if running in local mode (no API token)
cliLocalMode := len(flags.ApiToken) == 0
cliLocalMode := !flags.HasRemoteToken()

if cliLocalMode {
fmt.Println()
Expand All @@ -125,7 +114,7 @@ func runConfigResetLogic(cmd *cobra.Command, args []string, flags domain.InitFla
}
} else {
// API token provided, fetch configuration from Codacy
fmt.Println("API token specified. Fetching and applying repository-specific configurations from Codacy...")
fmt.Println("Token specified. Fetching and applying repository-specific configurations from Codacy...")
if err := configsetup.BuildRepositoryConfigurationFiles(flags); err != nil {
log.Fatalf("Failed to build repository-specific configuration files: %v", err)
}
Expand Down Expand Up @@ -158,6 +147,8 @@ var configDiscoverCmd = &cobra.Command{
"In Cloud mode, tools are only added if enabled in the cloud for the repository.",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
cmdutils.PrepareRemoteFlags(cmd, &configResetInitFlags)

discoverPath = args[0]

// Check if path exists
Expand Down Expand Up @@ -306,7 +297,7 @@ func updateCodacyYAMLForTools(detectedTools map[string]struct{}, codacyYAMLPath

candidateToolsToAdd := detectedTools

if cliMode == "remote" && initFlags.ApiToken != "" {
if cliMode == "remote" && initFlags.HasRemoteToken() {
fmt.Println("Cloud mode: Verifying tools against repository settings...")
cloudTools, err := codacyclient.GetRepositoryTools(initFlags)
if err != nil {
Expand Down
4 changes: 2 additions & 2 deletions cmd/configsetup/default_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ func CreateConfigurationFilesForDiscoveredTools(discoveredToolNames map[string]s
currentCliMode = "local" // Default to local
}

if currentCliMode == "remote" && initFlags.ApiToken != "" {
if currentCliMode == "remote" && initFlags.HasRemoteToken() {
// Remote mode - create configurations based on cloud repository settings
return createRemoteToolConfigurationsForDiscovered(discoveredToolNames, initFlags)
}
Expand Down Expand Up @@ -200,7 +200,7 @@ func createDefaultConfigurationsForSpecificTools(discoveredToolNames map[string]
// createToolConfigurationsForUUIDs creates tool configurations for specific UUIDs
func createToolConfigurationsForUUIDs(uuids []string, toolsConfigDir string, initFlags domain.InitFlags) error {
for _, uuid := range uuids {
patternsConfig, err := codacyclient.GetToolPatternsConfig(initFlags, uuid, true)
patternsConfig, err := codacyclient.GetToolPatternsConfig(domain.InitFlags{}, uuid, true)

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 RISK

The initFlags parameter is no longer passed to GetToolPatternsConfig. By using an empty domain.InitFlags{}, the API request is made without any authentication headers (stripping both the API token and the new Project token), which will result in 401/403 errors.

See Issue in Codacy

if err != nil {
logToolConfigWarning(uuid, "Failed to get default patterns", err)
continue
Expand Down
2 changes: 1 addition & 1 deletion cmd/configsetup/repository_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ func CreateToolConfigurationFile(toolName string, flags domain.InitFlags) error
return fmt.Errorf("tool '%s' not found in supported tools", toolName)
}

patternsConfig, err := codacyclient.GetToolPatternsConfig(flags, toolUUID, true)
patternsConfig, err := codacyclient.GetToolPatternsConfig(domain.InitFlags{}, toolUUID, true)

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 RISK

The flags parameter is ignored here; GetToolPatternsConfig is called with an empty struct, stripping authentication. If the goal is to prevent sending a repository-scoped ProjectToken to a global endpoint, it is safer to selectively strip that token while preserving the ApiToken.

Suggested change
patternsConfig, err := codacyclient.GetToolPatternsConfig(domain.InitFlags{}, toolUUID, true)
patternsConfig, err := codacyclient.GetToolPatternsConfig(flags, toolUUID, true)

See Issue in Codacy

if err != nil {
return fmt.Errorf("failed to get default patterns: %w", err)
}
Expand Down
Loading
Loading