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
5 changes: 5 additions & 0 deletions .changeset/folderless-pause-delete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"cre-cli": minor
---

`cre workflow pause`, `cre workflow delete` and `cre workflow activate` no longer require the project folder. The positional argument now accepts a workflow folder path (unchanged legacy flow), a workflow name, or an on-chain workflow ID, and can be omitted entirely for an interactive picker of your deployed workflows (resolved from the CRE platform, like `cre workflow list`). Folder-less on-chain workflows require `--rpc-url` and `CRE_ETH_PRIVATE_KEY`; new flags `--owner` and `--registry` scope name resolution. MSIG flows (`--unsigned`/`--changeset`) still require the project folder.
20 changes: 18 additions & 2 deletions cmd/client/client_factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import (

type Factory interface {
NewWorkflowRegistryV2Client(ctx context.Context) (*WorkflowRegistryV2Client, error)
// NewWorkflowRegistryV2ClientWithRPC builds a registry client against an explicit
// RPC URL and registry contract address, for flows that run without project settings.
NewWorkflowRegistryV2ClientWithRPC(ctx context.Context, rpcURL, registryAddress string) (*WorkflowRegistryV2Client, error)
GetTxType() TxType
GetSkipConfirmation() bool
}
Expand Down Expand Up @@ -48,6 +51,19 @@ func (f *factoryImpl) NewWorkflowRegistryV2Client(ctx context.Context) (*Workflo
return nil, fmt.Errorf("failed to create client for chain %q: %w", environmentSet.WorkflowRegistryChainName, err)
}

return f.newRegistryClient(ctx, ethClient, environmentSet.WorkflowRegistryAddress)
}

func (f *factoryImpl) NewWorkflowRegistryV2ClientWithRPC(ctx context.Context, rpcURL, registryAddress string) (*WorkflowRegistryV2Client, error) {
ethClient, err := NewEthClientFromEnv(f.viper, f.logger, rpcURL)
if err != nil {
return nil, fmt.Errorf("failed to create client for registry chain via --%s: %w", settings.Flags.RPCURL.Name, err)
}

return f.newRegistryClient(ctx, ethClient, registryAddress)
}

func (f *factoryImpl) newRegistryClient(ctx context.Context, ethClient *seth.Client, registryAddress string) (*WorkflowRegistryV2Client, error) {
txcConfig := TxClientConfig{
TxType: f.GetTxType(),
LedgerConfig: f.getLedgerConfig(),
Expand All @@ -57,13 +73,13 @@ func (f *factoryImpl) NewWorkflowRegistryV2Client(ctx context.Context) (*Workflo
workflowRegistryV2Client := NewWorkflowRegistryV2Client(
f.logger,
ethClient,
environmentSet.WorkflowRegistryAddress,
registryAddress,
txcConfig,
)

typeAndVersion, err := workflowRegistryV2Client.TypeAndVersion(ctx)
if err != nil {
return workflowRegistryV2Client, fmt.Errorf("failed to get type and version of workflow registry contract at %s: %w", environmentSet.WorkflowRegistryAddress, err)
return workflowRegistryV2Client, fmt.Errorf("failed to get type and version of workflow registry contract at %s: %w", registryAddress, err)
}

if !strings.Contains(typeAndVersion, constants.WorkflowRegistryV2TypeAndVersion) {
Expand Down
17 changes: 16 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/smartcontractkit/cre-cli/cmd/version"
"github.com/smartcontractkit/cre-cli/cmd/whoami"
"github.com/smartcontractkit/cre-cli/cmd/workflow"
workflowcommon "github.com/smartcontractkit/cre-cli/cmd/workflow/common"
"github.com/smartcontractkit/cre-cli/internal/constants"
"github.com/smartcontractkit/cre-cli/internal/context"
"github.com/smartcontractkit/cre-cli/internal/creconfig"
Expand Down Expand Up @@ -273,7 +274,7 @@ func newRootCommand() *cobra.Command {
}

// load settings from yaml files
if isLoadSettings(cmd) {
if isLoadSettings(cmd) && !isFolderlessLifecycleInvocation(cmd, args, v.GetString(settings.Flags.ProjectRoot.Name)) {
if showSpinner {
spinner.Update("Loading settings...")
}
Expand Down Expand Up @@ -551,6 +552,20 @@ func newRootCommand() *cobra.Command {
return rootCmd
}

// isFolderlessLifecycleInvocation reports whether a workflow pause/delete/activate
// invocation resolves its workflow remotely (by name, on-chain ID, or
// interactive picker) instead of reading project and workflow settings from
// disk. Folder-less invocations skip the settings load entirely, like
// `cre workflow list` does.
func isFolderlessLifecycleInvocation(cmd *cobra.Command, args []string, projectRootFlag string) bool {
switch cmd.CommandPath() {
case "cre workflow pause", "cre workflow delete", "cre workflow activate":
return workflowcommon.IsFolderlessInvocation(args, projectRootFlag)
default:
return false
}
}

func isLoadSettings(cmd *cobra.Command) bool {
// It is not expected to have the settings file when running the following commands
var excludedCommands = map[string]struct{}{
Expand Down
9 changes: 9 additions & 0 deletions cmd/secrets/common/test_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ func (m *MockClientFactory) NewWorkflowRegistryV2Client(ctx context.Context) (*c
return c, args.Error(1)
}

func (m *MockClientFactory) NewWorkflowRegistryV2ClientWithRPC(ctx context.Context, rpcURL, registryAddress string) (*client.WorkflowRegistryV2Client, error) {
args := m.Called(ctx, rpcURL, registryAddress)
var c *client.WorkflowRegistryV2Client
if v := args.Get(0); v != nil {
c = v.(*client.WorkflowRegistryV2Client)
}
return c, args.Error(1)
}

func (m *MockClientFactory) GetTxType() client.TxType {
panic("not used in these tests")
}
107 changes: 93 additions & 14 deletions cmd/workflow/activate/activate.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package activate
import (
"context"
"fmt"
"strings"

"github.com/rs/zerolog"
"github.com/spf13/cobra"
Expand All @@ -15,6 +16,7 @@ import (
"github.com/smartcontractkit/cre-cli/internal/settings"
"github.com/smartcontractkit/cre-cli/internal/ui"
"github.com/smartcontractkit/cre-cli/internal/validation"
"github.com/smartcontractkit/cre-cli/internal/workflowresolve"
)

const (
Expand All @@ -25,21 +27,39 @@ type Inputs struct {
WorkflowName string `validate:"workflow_name"`
WorkflowOwner string `validate:"workflow_owner"`
DonFamily string `validate:"required"`
WorkflowID string
RPCURL string
SkipConfirmation bool
NonInteractive bool
}

func New(runtimeContext *runtime.Context) *cobra.Command {
activateCmd := &cobra.Command{
Use: "activate <workflow-folder-path>",
Short: "Activates workflow on the Workflow Registry contract",
Long: `Changes workflow status to active on the Workflow Registry contract`,
Args: cobra.ExactArgs(1),
Example: `cre workflow activate ./my-workflow`,
Use: "activate [workflow-name-or-id | workflow-folder-path]",
Short: "Activates workflow on the Workflow Registry contract",
Long: `Changes workflow status to active on the Workflow Registry contract.

The argument can be a workflow folder path (project-based flow), a workflow
name, or an on-chain workflow ID. When the argument is a name or ID — or is
omitted for an interactive picker — no project folder is needed; the workflow
is resolved from the CRE platform. On-chain workflows additionally require
--rpc-url and CRE_ETH_PRIVATE_KEY in that mode. --target and --project-root
are ignored in this mode.`,
Args: cobra.MaximumNArgs(1),
Example: `cre workflow activate ./my-workflow
cre workflow activate my-workflow
cre workflow activate 00da21b8b3e117e31f3a3e8a0795225cbde6c00283a84395117669691f2b7856
cre workflow activate my-workflow --rpc-url https://... --yes
cre workflow activate # interactive picker`,
RunE: func(cmd *cobra.Command, args []string) error {
handler := newHandler(runtimeContext)

inputs, err := handler.ResolveInputs(runtimeContext.Viper)
arg := ""
if len(args) == 1 {
arg = args[0]
}

inputs, err := handler.ResolveInputs(cmd.Context(), runtimeContext.Viper, arg)
if err != nil {
return err
}
Expand All @@ -54,6 +74,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command {

settings.AddTxnTypeFlags(activateCmd)
settings.AddSkipConfirmation(activateCmd)
settings.AddFolderlessLifecycleFlags(activateCmd)

return activateCmd
}
Expand All @@ -66,6 +87,10 @@ type handler struct {
inputs Inputs
runtimeContext *runtime.Context

// folderlessRow is the platform workflow row when running without a project
// folder; nil in the legacy project-based flow.
folderlessRow *workflowresolve.Workflow

validated bool
execCtx context.Context
}
Expand All @@ -83,19 +108,73 @@ func newHandler(ctx *runtime.Context) *handler {
return &h
}

func (h *handler) ResolveInputs(v *viper.Viper) (Inputs, error) {
func (h *handler) ResolveInputs(ctx context.Context, v *viper.Viper, arg string) (Inputs, error) {
inputs := Inputs{
SkipConfirmation: v.GetBool(settings.Flags.SkipConfirmation.Name),
NonInteractive: v.GetBool(settings.Flags.NonInteractive.Name),
RPCURL: strings.TrimSpace(v.GetString(settings.Flags.RPCURL.Name)),
}

// Folder-less mode: settings were not loaded; resolve everything from the platform.
if h.runtimeContext.Settings == nil {
return h.resolveFolderlessInputs(ctx, v, arg, inputs)
}

resolvedWorkflowOwner, err := h.resolveWorkflowOwner(h.runtimeContext.ResolvedRegistry.Type())
if err != nil {
return Inputs{}, fmt.Errorf("failed to resolve workflow owner: %w", err)
}

return Inputs{
WorkflowName: h.settings.Workflow.UserWorkflowSettings.WorkflowName,
WorkflowOwner: resolvedWorkflowOwner,
DonFamily: h.runtimeContext.ResolvedRegistry.DonFamily(),
SkipConfirmation: v.GetBool(settings.Flags.SkipConfirmation.Name),
NonInteractive: v.GetBool(settings.Flags.NonInteractive.Name),
}, nil
inputs.WorkflowName = h.settings.Workflow.UserWorkflowSettings.WorkflowName
inputs.WorkflowOwner = resolvedWorkflowOwner
inputs.DonFamily = h.runtimeContext.ResolvedRegistry.DonFamily()
return inputs, nil
}

func (h *handler) resolveFolderlessInputs(ctx context.Context, v *viper.Viper, arg string, inputs Inputs) (Inputs, error) {
if err := workflowcommon.GuardFolderlessTxnType(v); err != nil {
return Inputs{}, err
}

target, err := workflowcommon.ResolveFolderlessTarget(ctx, h.runtimeContext, arg, workflowresolve.RowResolveOptions{
OwnerFilter: strings.TrimSpace(v.GetString(settings.Flags.Owner.Name)),
RegistryFilter: strings.TrimSpace(v.GetString(settings.Flags.Registry.Name)),
NonInteractive: inputs.NonInteractive,
CandidateFilter: func(w workflowresolve.Workflow) bool {
return strings.EqualFold(w.Status, "PAUSED")
},
PickerTitle: "Select a workflow to activate",
})
if err != nil {
return Inputs{}, err
}

h.runtimeContext.ResolvedRegistry = target.Registry
h.folderlessRow = &target.Workflow
inputs.WorkflowName = target.Workflow.Name
inputs.WorkflowOwner = target.Workflow.OwnerAddress
inputs.WorkflowID = target.Workflow.WorkflowID
inputs.DonFamily = target.Registry.DonFamily()
if inputs.DonFamily == "" {
return Inputs{}, fmt.Errorf(
"cannot determine the DON family for workflow %q; set %s or run from your project folder",
target.Workflow.Name, environments.EnvVarDonFamily,
)
}

if onChain, ok := target.Registry.(*settings.OnChainRegistry); ok {
if err := workflowcommon.VerifyEOAOwner(v, target.Workflow.OwnerAddress); err != nil {
return Inputs{}, err
}
if inputs.RPCURL == "" {
return Inputs{}, fmt.Errorf(
"missing RPC URL for chain %q: pass --%s to activate an on-chain workflow without a project folder",
onChain.ChainName(), settings.Flags.RPCURL.Name,
)
}
}

return inputs, nil
}

func (h *handler) ValidateInputs() error {
Expand Down
34 changes: 34 additions & 0 deletions cmd/workflow/activate/activate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,49 @@ import (
"errors"
"testing"

"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/smartcontractkit/cre-cli/internal/constants"
"github.com/smartcontractkit/cre-cli/internal/runtime"
"github.com/smartcontractkit/cre-cli/internal/settings"
"github.com/smartcontractkit/cre-cli/internal/testutil"
"github.com/smartcontractkit/cre-cli/internal/testutil/chainsim"
"github.com/smartcontractkit/cre-cli/internal/validation"
)

// folderlessRuntimeContext builds a runtime context with no settings attached,
// which is how activate runs when the workflow is resolved by name/ID without a
// project folder.
func folderlessRuntimeContext(v *viper.Viper) *runtime.Context {
return &runtime.Context{
Logger: testutil.NewTestLogger(),
Viper: v,
}
}

func TestFolderless_UnsignedFlag_ReturnsError(t *testing.T) {
t.Parallel()
v := viper.New()
v.Set(settings.Flags.RawTxFlag.Name, true)

h := newHandler(folderlessRuntimeContext(v))
_, err := h.ResolveInputs(context.Background(), v, "my-workflow")
require.Error(t, err)
require.Contains(t, err.Error(), "require a project folder")
}

func TestFolderless_WithoutCredentials_ReturnsError(t *testing.T) {
t.Parallel()
v := viper.New()

h := newHandler(folderlessRuntimeContext(v))
_, err := h.ResolveInputs(context.Background(), v, "my-workflow")
require.Error(t, err)
require.Contains(t, err.Error(), "credentials not available")
}

func TestNonInteractive_WithoutYes_ReturnsError(t *testing.T) {
t.Parallel()
simulatedEnvironment := chainsim.NewSimulatedEnvironment(t)
Expand Down
10 changes: 9 additions & 1 deletion cmd/workflow/activate/registry_activate_strategy_onchain.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,15 @@ func newOnchainRegistryActivateStrategy(h *handler) (*onchainRegistryActivateStr
a.wg.Add(1)
go func() {
defer a.wg.Done()
wrc, err := h.clientFactory.NewWorkflowRegistryV2Client(h.execCtx)
var wrc *client.WorkflowRegistryV2Client
var err error
// Folder-less mode passes an explicit --rpc-url; the project-based flow
// resolves the RPC URL from settings.
if h.inputs.RPCURL != "" {
wrc, err = h.clientFactory.NewWorkflowRegistryV2ClientWithRPC(h.execCtx, h.inputs.RPCURL, onChain.Address())
} else {
wrc, err = h.clientFactory.NewWorkflowRegistryV2Client(h.execCtx)
}
if err != nil {
a.initErr = fmt.Errorf("failed to create workflow registry client: %w", err)
return
Expand Down
29 changes: 18 additions & 11 deletions cmd/workflow/activate/registry_activate_strategy_private.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,24 +28,31 @@ func (a *privateRegistryActivateStrategy) Activate() error {
a.ensureClient()

h := a.h
workflowName := h.inputs.WorkflowName
workflowID := h.inputs.WorkflowID

ui.Dim(fmt.Sprintf("Fetching workflow to activate... Name=%s", workflowName))
if workflowID == "" {
// Project-based flow: resolve the registry workflow ID by name.
workflowName := h.inputs.WorkflowName

workflow, err := a.prc.GetWorkflowByName(a.h.execCtx, workflowName)
if err != nil {
return fmt.Errorf("failed to get workflow: %w", err)
}
ui.Dim(fmt.Sprintf("Fetching workflow to activate... Name=%s", workflowName))

workflow, err := a.prc.GetWorkflowByName(a.h.execCtx, workflowName)
if err != nil {
return fmt.Errorf("failed to get workflow: %w", err)
}

if workflow.Status == privateregistryclient.WorkflowStatusActive {
return fmt.Errorf("workflow is already active, cancelling transaction")
}

if workflow.Status == privateregistryclient.WorkflowStatusActive {
return fmt.Errorf("workflow is already active, cancelling transaction")
workflowID = workflow.WorkflowID
}

h.runtimeContext.Workflow.ID = workflow.WorkflowID
h.runtimeContext.Workflow.ID = workflowID

ui.Dim(fmt.Sprintf("Processing activation for workflow ID %s...", workflow.WorkflowID))
ui.Dim(fmt.Sprintf("Processing activation for workflow ID %s...", workflowID))

result, err := a.prc.ActivateWorkflowInRegistry(a.h.execCtx, workflow.WorkflowID)
result, err := a.prc.ActivateWorkflowInRegistry(a.h.execCtx, workflowID)
if err != nil {
return fmt.Errorf("failed to activate workflow in private registry: %w", err)
}
Expand Down
6 changes: 5 additions & 1 deletion cmd/workflow/common/display_workflow_details.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ func DisplayWorkflowDetails(
ui.Title(fmt.Sprintf("%s Workflow: %s", action, workflowName))
ui.Dim(fmt.Sprintf("Registry: %s", runtimeContext.ResolvedRegistry.ID()))
ui.Dim(fmt.Sprintf("DON Family: %s", runtimeContext.ResolvedRegistry.DonFamily()))
ui.Dim(fmt.Sprintf("Target: %s", cfg.User.TargetName))
// cfg is nil when running folder-less (workflow resolved by name/ID without
// project settings); there is no target in that mode.
if cfg != nil && cfg.User.TargetName != "" {
ui.Dim(fmt.Sprintf("Target: %s", cfg.User.TargetName))
}
ui.Dim(fmt.Sprintf("Owner Address: %s", displayOwnerAddress))
ui.Line()
}
Loading
Loading