Skip to content
Open
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
9 changes: 9 additions & 0 deletions cmd/account/link_key/link_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type Inputs struct {
WorkflowOwnerLabel string `validate:"omitempty"`
WorkflowOwner string `validate:"required,workflow_owner"`
WorkflowRegistryContractAddress string `validate:"required"`
NonInteractive bool
}

type initiateLinkingResponse struct {
Expand Down Expand Up @@ -137,6 +138,7 @@ func (h *handler) ResolveInputs(v *viper.Viper) (Inputs, error) {
WorkflowOwner: h.settings.Workflow.UserWorkflowSettings.WorkflowOwnerAddress,
WorkflowRegistryContractAddress: h.environmentSet.WorkflowRegistryAddress,
WorkflowOwnerLabel: v.GetString("owner-label"),
NonInteractive: v.GetBool(settings.Flags.NonInteractive.Name),
}, nil
}

Expand All @@ -160,6 +162,13 @@ func (h *handler) Execute(in Inputs) error {
h.displayDetails()

if in.WorkflowOwnerLabel == "" {
if in.NonInteractive {
ui.ErrorWithSuggestions(
"Non-interactive mode requires all inputs via flags",
[]string{"--owner-label"},
)
return fmt.Errorf("missing required flags for --non-interactive mode")
}
label, err := ui.Input("Provide a label for your owner address")
if err != nil {
return err
Expand Down
30 changes: 30 additions & 0 deletions cmd/account/link_key/link_key_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package link_key

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestNonInteractive_WithoutOwnerLabel_BlocksPrompt(t *testing.T) {
t.Parallel()
in := Inputs{
NonInteractive: true,
WorkflowOwnerLabel: "",
}
// Simulate the guard check from Execute
require.True(t, in.NonInteractive && in.WorkflowOwnerLabel == "",
"should require --owner-label in non-interactive mode")
}

func TestNonInteractive_WithOwnerLabel_AllowsProceeding(t *testing.T) {
t.Parallel()
in := Inputs{
NonInteractive: true,
WorkflowOwnerLabel: "my-label",
}
// Guard should NOT trigger
assert.False(t, in.NonInteractive && in.WorkflowOwnerLabel == "",
"should allow proceeding when --owner-label is set")
}
10 changes: 10 additions & 0 deletions cmd/account/unlink_key/unlink_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type Inputs struct {
WorkflowOwner string `validate:"workflow_owner"`
WorkflowRegistryContractAddress string `validate:"required"`
SkipConfirmation bool
NonInteractive bool
}

type initiateUnlinkingResponse struct {
Expand Down Expand Up @@ -120,6 +121,7 @@ func (h *handler) ResolveInputs(v *viper.Viper) (Inputs, error) {
WorkflowOwner: h.settings.Workflow.UserWorkflowSettings.WorkflowOwnerAddress,
WorkflowRegistryContractAddress: h.environmentSet.WorkflowRegistryAddress,
SkipConfirmation: v.GetBool(settings.Flags.SkipConfirmation.Name),
NonInteractive: v.GetBool(settings.Flags.NonInteractive.Name),
}, nil
}

Expand Down Expand Up @@ -158,6 +160,14 @@ func (h *handler) Execute(in Inputs) error {
return nil
}

// Check non-interactive mode
if in.NonInteractive && !in.SkipConfirmation {
ui.ErrorWithSuggestions(
"Non-interactive mode requires all inputs via flags",
[]string{"--yes"},
)
return fmt.Errorf("missing required flags for --non-interactive mode")
}
// Check if confirmation should be skipped
if !in.SkipConfirmation {
ui.Warning("Unlink is a destructive action that will wipe out all workflows registered under your owner address.")
Expand Down
42 changes: 42 additions & 0 deletions cmd/account/unlink_key/unlink_key_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package unlink_key

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestNonInteractiveFlagRegistered(t *testing.T) {
t.Parallel()
// New() requires a runtime context with many fields; instead verify
// the guard logic directly on the Inputs struct.
in := Inputs{
NonInteractive: true,
SkipConfirmation: false,
}
assert.True(t, in.NonInteractive && !in.SkipConfirmation,
"non-interactive guard should trigger when --yes is missing")
}

func TestNonInteractive_WithoutYes_BlocksConfirmation(t *testing.T) {
t.Parallel()
in := Inputs{
NonInteractive: true,
SkipConfirmation: false,
}
// Simulate the guard check from Execute
require.True(t, in.NonInteractive && !in.SkipConfirmation,
"should require --yes in non-interactive mode")
}

func TestNonInteractive_WithYes_AllowsProceeding(t *testing.T) {
t.Parallel()
in := Inputs{
NonInteractive: true,
SkipConfirmation: true,
}
// Guard should NOT trigger
require.False(t, in.NonInteractive && !in.SkipConfirmation,
"should allow proceeding when --yes is set")
}
1 change: 0 additions & 1 deletion cmd/creinit/creinit.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ Templates are fetched dynamically from GitHub repositories.`,
initCmd.Flags().StringP("template", "t", "", "Name of the template to use (e.g., kv-store-go)")
initCmd.Flags().Bool("refresh", false, "Bypass template cache and fetch fresh data")
initCmd.Flags().StringArray("rpc-url", nil, "RPC URL for a network (format: chain-name=url, repeatable)")
initCmd.Flags().Bool("non-interactive", false, "Fail instead of prompting; requires all inputs via flags")

// Deprecated: --template-id is kept for backwards compatibility, maps to hello-world-go
initCmd.Flags().Uint32("template-id", 0, "")
Expand Down
29 changes: 27 additions & 2 deletions cmd/login/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ import (

"github.com/rs/zerolog"
"github.com/spf13/cobra"
"github.com/spf13/viper"

"github.com/smartcontractkit/cre-cli/internal/client/graphqlclient"
"github.com/smartcontractkit/cre-cli/internal/constants"
"github.com/smartcontractkit/cre-cli/internal/credentials"
"github.com/smartcontractkit/cre-cli/internal/environments"
"github.com/smartcontractkit/cre-cli/internal/oauth"
"github.com/smartcontractkit/cre-cli/internal/runtime"
"github.com/smartcontractkit/cre-cli/internal/settings"
"github.com/smartcontractkit/cre-cli/internal/tenantctx"
"github.com/smartcontractkit/cre-cli/internal/ui"
)
Expand All @@ -34,9 +36,32 @@ func New(runtimeCtx *runtime.Context) *cobra.Command {
cmd := &cobra.Command{
Use: "login",
Short: "Start authentication flow",
Long: "Opens browser for user login and saves credentials.",
Args: cobra.NoArgs,
Long: `Opens a browser for interactive login and saves credentials.

For non-interactive environments (CI/CD, automation, AI agents), set the
CRE_API_KEY environment variable instead:

export CRE_API_KEY=<your-api-key>

API keys can be created at https://app.chain.link (see Account Settings).
When CRE_API_KEY is set, all commands that require authentication will use
it automatically — no login needed.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
v := viper.New()
if err := v.BindPFlags(cmd.Flags()); err != nil {
return err
}
if v.GetBool(settings.Flags.NonInteractive.Name) {
ui.ErrorWithSuggestions(
"Login requires a browser and is not available in non-interactive mode",
[]string{
"Set CRE_API_KEY environment variable instead: export CRE_API_KEY=<your-api-key>",
"API keys can be created at https://app.chain.link (Account Settings)",
},
)
return fmt.Errorf("login is not supported in non-interactive mode, use CRE_API_KEY instead")
}
h := newHandler(runtimeCtx)
return h.execute()
},
Expand Down
25 changes: 25 additions & 0 deletions cmd/login/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"testing"

"github.com/rs/zerolog"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"

"github.com/smartcontractkit/cre-cli/internal/credentials"
Expand All @@ -18,6 +19,30 @@ import (
"github.com/smartcontractkit/cre-cli/internal/ui"
)

func TestLogin_NonInteractive_ReturnsError(t *testing.T) {
// Create a parent command with the global --non-interactive persistent flag,
// since in production this flag is defined on the root command.
root := &cobra.Command{Use: "cre"}
root.PersistentFlags().Bool("non-interactive", false, "")
loginCmd := New(nil)
root.AddCommand(loginCmd)

root.SetArgs([]string{"login", "--non-interactive"})
root.SetOut(io.Discard)
root.SetErr(io.Discard)

err := root.Execute()
if err == nil {
t.Fatal("expected error when --non-interactive is set")
}
if !strings.Contains(err.Error(), "non-interactive mode") {
t.Errorf("expected error to mention non-interactive mode, got: %v", err)
}
if !strings.Contains(err.Error(), "CRE_API_KEY") {
t.Errorf("expected error to mention CRE_API_KEY, got: %v", err)
}
}

func TestSaveCredentials_WritesYAML(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)
Expand Down
17 changes: 17 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"golang.org/x/term"

"github.com/smartcontractkit/cre-cli/cmd/account"
"github.com/smartcontractkit/cre-cli/cmd/client"
Expand Down Expand Up @@ -194,6 +195,16 @@ func newRootCommand() *cobra.Command {
ui.EnvContext(runtimeContext.EnvironmentSet.EnvLabel())
ui.Line()

// In non-TTY environments (CI/CD, piped stdin, AI agents),
// skip the interactive prompt and return an actionable error.
if !term.IsTerminal(int(os.Stdin.Fd())) { //nolint:gosec // os.Stdin.Fd() is always 0; overflow is impossible
ui.ErrorWithSuggestions("Authentication required: not logged in and no CRE_API_KEY set", []string{
"Run 'cre login' interactively, or",
"Set CRE_API_KEY environment variable for non-interactive use",
})
return fmt.Errorf("authentication required: %w", err)
}

runLogin, formErr := ui.Confirm("Would you like to login now?",
ui.WithLabels("Yes, login", "No, cancel"),
)
Expand Down Expand Up @@ -376,6 +387,12 @@ func newRootCommand() *cobra.Command {
"",
"Use target settings from YAML config",
)
// non-interactive flag is present for every subcommand
rootCmd.PersistentFlags().Bool(
settings.Flags.NonInteractive.Name,
false,
"Fail instead of prompting; requires all inputs via flags",
)
rootCmd.CompletionOptions.HiddenDefaultCmd = true

secretsCmd := secrets.New(runtimeContext)
Expand Down
10 changes: 10 additions & 0 deletions cmd/workflow/convert/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const (
type Inputs struct {
WorkflowFolder string
Force bool
NonInteractive bool
}

func New(runtimeContext *runtime.Context) *cobra.Command {
Expand All @@ -36,10 +37,12 @@ func New(runtimeContext *runtime.Context) *cobra.Command {
Args: cobra.ExactArgs(1),
Example: `cre workflow custom-build ./my-workflow`,
RunE: func(cmd *cobra.Command, args []string) error {
nonInteractive, _ := cmd.Flags().GetBool(settings.Flags.NonInteractive.Name)
handler := newHandler(runtimeContext)
inputs := Inputs{
WorkflowFolder: args[0],
Force: force,
NonInteractive: nonInteractive,
}
return handler.Execute(inputs)
},
Expand Down Expand Up @@ -109,6 +112,13 @@ func (h *handler) Execute(inputs Inputs) error {
return fmt.Errorf("workflow is already a custom build (workflow-path is %s)", currentPath)
}

if inputs.NonInteractive && !inputs.Force {
ui.ErrorWithSuggestions(
"Non-interactive mode requires all inputs via flags",
[]string{"--force"},
)
return fmt.Errorf("missing required flags for --non-interactive mode")
}
if !inputs.Force {
confirmed, err := h.confirmFn(convertWarning, ui.WithLabels("Yes", "No"))
if err != nil {
Expand Down
64 changes: 64 additions & 0 deletions cmd/workflow/convert/convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,70 @@ production-settings:
require.FileExists(t, filepath.Join(dir, "Makefile"))
}

func TestConvert_NonInteractive_WithoutForce_ReturnsError(t *testing.T) {
dir := t.TempDir()
workflowYAML := filepath.Join(dir, constants.DefaultWorkflowSettingsFileName)
mainGo := filepath.Join(dir, "main.go")
yamlContent := `staging-settings:
user-workflow:
workflow-name: "wf-staging"
workflow-artifacts:
workflow-path: "."
config-path: "./config.staging.json"
production-settings:
user-workflow:
workflow-name: "wf-production"
workflow-artifacts:
workflow-path: "."
config-path: "./config.production.json"
`
require.NoError(t, os.WriteFile(workflowYAML, []byte(yamlContent), 0600))
require.NoError(t, os.WriteFile(mainGo, []byte("package main\n"), 0600))

h := newHandler(nil)
err := h.Execute(Inputs{WorkflowFolder: dir, Force: false, NonInteractive: true})
require.Error(t, err)
require.Contains(t, err.Error(), "missing required flags for --non-interactive mode")

// Verify no conversion happened
data, err := os.ReadFile(workflowYAML)
require.NoError(t, err)
require.Contains(t, string(data), "workflow-path: \".\"")
require.NotContains(t, string(data), wasmWorkflowPath)
require.NoFileExists(t, filepath.Join(dir, "Makefile"))
}

func TestConvert_NonInteractive_WithForce_Proceeds(t *testing.T) {
dir := t.TempDir()
workflowYAML := filepath.Join(dir, constants.DefaultWorkflowSettingsFileName)
mainGo := filepath.Join(dir, "main.go")
yamlContent := `staging-settings:
user-workflow:
workflow-name: "wf-staging"
workflow-artifacts:
workflow-path: "."
config-path: "./config.staging.json"
production-settings:
user-workflow:
workflow-name: "wf-production"
workflow-artifacts:
workflow-path: "."
config-path: "./config.production.json"
`
require.NoError(t, os.WriteFile(workflowYAML, []byte(yamlContent), 0600))
require.NoError(t, os.WriteFile(mainGo, []byte("package main\n"), 0600))

h := newHandler(nil)
err := h.Execute(Inputs{WorkflowFolder: dir, Force: true, NonInteractive: true})
require.NoError(t, err)

data, err := os.ReadFile(workflowYAML)
require.NoError(t, err)
require.Contains(t, string(data), wasmWorkflowPath)
require.FileExists(t, filepath.Join(dir, "Makefile"))
require.DirExists(t, filepath.Join(dir, "wasm"))
}

func TestConvert_TS_InstallsDepsIfNoNodeModules(t *testing.T) {
dir := t.TempDir()
workflowYAML := filepath.Join(dir, constants.DefaultWorkflowSettingsFileName)
Expand Down
Loading
Loading