From 7a5e065453670e82ecae731f77187da4306923b6 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Mon, 26 Oct 2026 16:19:48 -0400 Subject: [PATCH 1/3] Added a way to pause and delete workflwo from name / id, without needing the full project config file --- .changeset/folderless-pause-delete.md | 5 + cmd/client/client_factory.go | 20 +- cmd/root.go | 17 +- cmd/secrets/common/test_helpers.go | 9 + .../common/display_workflow_details.go | 6 +- cmd/workflow/common/folderless.go | 214 ++++++++++++++ cmd/workflow/common/folderless_test.go | 203 +++++++++++++ cmd/workflow/delete/delete.go | 94 +++++- cmd/workflow/delete/delete_test.go | 34 +++ .../registry_delete_strategy_onchain.go | 10 +- .../registry_delete_strategy_private.go | 14 + cmd/workflow/pause/pause.go | 97 ++++++- cmd/workflow/pause/pause_test.go | 56 ++++ .../pause/registry_pause_strategy_onchain.go | 15 +- .../pause/registry_pause_strategy_private.go | 29 +- docs/cre_workflow_delete.md | 21 +- docs/cre_workflow_pause.md | 23 +- internal/settings/settings_load.go | 12 + .../chainsim/simulated_client_factory.go | 6 + internal/workflowresolve/resolve_row.go | 197 +++++++++++++ internal/workflowresolve/resolve_row_test.go | 271 ++++++++++++++++++ .../workflow_folderless.go | 192 +++++++++++++ test/multi_command_test.go | 9 + 23 files changed, 1502 insertions(+), 52 deletions(-) create mode 100644 .changeset/folderless-pause-delete.md create mode 100644 cmd/workflow/common/folderless.go create mode 100644 cmd/workflow/common/folderless_test.go create mode 100644 internal/workflowresolve/resolve_row.go create mode 100644 internal/workflowresolve/resolve_row_test.go create mode 100644 test/multi_command_flows/workflow_folderless.go diff --git a/.changeset/folderless-pause-delete.md b/.changeset/folderless-pause-delete.md new file mode 100644 index 00000000..02051fe9 --- /dev/null +++ b/.changeset/folderless-pause-delete.md @@ -0,0 +1,5 @@ +--- +"cre-cli": minor +--- + +`cre workflow pause` and `cre workflow delete` 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. diff --git a/cmd/client/client_factory.go b/cmd/client/client_factory.go index bb496a82..ebc9ad1d 100644 --- a/cmd/client/client_factory.go +++ b/cmd/client/client_factory.go @@ -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 } @@ -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(), @@ -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) { diff --git a/cmd/root.go b/cmd/root.go index 64f26fe5..366c8744 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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" @@ -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...") } @@ -551,6 +552,20 @@ func newRootCommand() *cobra.Command { return rootCmd } +// isFolderlessLifecycleInvocation reports whether a workflow pause/delete +// 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": + 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{}{ diff --git a/cmd/secrets/common/test_helpers.go b/cmd/secrets/common/test_helpers.go index a059c2e3..707fd94c 100644 --- a/cmd/secrets/common/test_helpers.go +++ b/cmd/secrets/common/test_helpers.go @@ -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") } diff --git a/cmd/workflow/common/display_workflow_details.go b/cmd/workflow/common/display_workflow_details.go index a11e2bca..fb47bd7f 100644 --- a/cmd/workflow/common/display_workflow_details.go +++ b/cmd/workflow/common/display_workflow_details.go @@ -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() } diff --git a/cmd/workflow/common/folderless.go b/cmd/workflow/common/folderless.go new file mode 100644 index 00000000..54bc23fa --- /dev/null +++ b/cmd/workflow/common/folderless.go @@ -0,0 +1,214 @@ +package common + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/viper" + + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + crecontext "github.com/smartcontractkit/cre-cli/internal/context" + "github.com/smartcontractkit/cre-cli/internal/ethkeys" + "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/internal/workflowresolve" +) + +// FolderlessTarget bundles the resolution result for pause/delete invocations +// that run without a project folder: the platform workflow row plus the +// registry the workflow is deployed to. +type FolderlessTarget struct { + Workflow workflowresolve.Workflow + Registry settings.ResolvedRegistry +} + +// IsFolderlessInvocation reports whether a pause/delete invocation should resolve +// the workflow remotely (by name, ID, or picker) instead of loading project and +// workflow settings from disk. An argument that is an existing directory — or a +// path-like argument — keeps the legacy project-based flow. +// +// The legacy flow resolves the workflow directory relative to the project root +// (found via projectRootFlag or by walking up from the current directory), so the +// directory check must look there as well as at the invocation directory. +func IsFolderlessInvocation(args []string, projectRootFlag string) bool { + if len(args) == 0 { + return true + } + arg := args[0] + // Path-like arguments always keep the legacy flow, so the user gets the + // original "path not found" error rather than a confusing + // "no workflow named ./foo" lookup failure. + if arg == "." || arg == ".." || strings.ContainsRune(arg, '/') { + return false + } + // Existing directory relative to the invocation directory. + if info, err := os.Stat(arg); err == nil && info.IsDir() { + return false + } + // Existing directory relative to the project root, mirroring SetExecutionContext. + if root := resolveProjectRoot(projectRootFlag); root != "" { + if info, err := os.Stat(filepath.Join(root, arg)); err == nil && info.IsDir() { + return false + } + } + return true +} + +// resolveProjectRoot returns the explicit --project-root when it points at an +// existing directory, otherwise the nearest ancestor containing project.yaml. +func resolveProjectRoot(projectRootFlag string) string { + if projectRootFlag != "" { + if resolved, err := filepath.Abs(projectRootFlag); err == nil { + if info, err := os.Stat(resolved); err == nil && info.IsDir() { + return resolved + } + } + return "" + } + + cwd, err := os.Getwd() + if err != nil { + return "" + } + settingsPath, found, err := crecontext.FindProjectSettingsPath(cwd) + if err != nil || !found { + return "" + } + return filepath.Dir(settingsPath) +} + +// GuardFolderlessTxnType rejects transaction types that need project settings +// (MSIG raw transactions and CLD changesets) in folder-less mode. +func GuardFolderlessTxnType(v *viper.Viper) error { + if v.GetBool(settings.Flags.RawTxFlag.Name) || v.GetBool(settings.Flags.Changeset.Name) { + return fmt.Errorf( + "--%s and --%s require a project folder; run from your CRE project or pass the workflow folder path instead", + settings.Flags.RawTxFlag.Name, settings.Flags.Changeset.Name, + ) + } + return nil +} + +// ResolveFolderlessTarget resolves a workflow reference (name, ID, or empty for +// interactive picker) to the platform workflow row and its deployment registry, +// using only credentials and tenant context — no project settings. +func ResolveFolderlessTarget( + ctx context.Context, + runtimeContext *runtime.Context, + arg string, + opts workflowresolve.RowResolveOptions, +) (*FolderlessTarget, error) { + if runtimeContext.Credentials == nil { + return nil, fmt.Errorf("credentials not available — run `cre login` and retry") + } + if runtimeContext.TenantContext == nil { + return nil, fmt.Errorf("user context not available — run `cre login` and retry") + } + + gql := graphqlclient.New(runtimeContext.Credentials, runtimeContext.EnvironmentSet, runtimeContext.Logger) + wdc := workflowdataclient.New(gql, runtimeContext.Logger) + + opts.Registries = runtimeContext.TenantContext.Registries + row, err := workflowresolve.ResolveWorkflowRow(ctx, wdc, arg, opts) + if err != nil { + return nil, err + } + + registry, err := RegistryFromWorkflowSource(runtimeContext, row.WorkflowSource) + if err != nil { + return nil, err + } + + return &FolderlessTarget{Workflow: *row, Registry: registry}, nil +} + +// RegistryFromWorkflowSource maps a platform workflow's source back to a +// ResolvedRegistry. It prefers the tenant-context registry match; on-chain +// "contract::
" sources carry enough information to build a +// registry even when no tenant registry matches. Anything else is treated as +// an off-chain (private) registry source. +func RegistryFromWorkflowSource(runtimeContext *runtime.Context, source string) (settings.ResolvedRegistry, error) { + donFamily := settings.EffectiveDonFamily(runtimeContext.EnvironmentSet, runtimeContext.TenantContext) + explorerURL := "" + if runtimeContext.EnvironmentSet != nil { + explorerURL = runtimeContext.EnvironmentSet.WorkflowRegistryChainExplorerURL + } + + if reg := workflowresolve.ResolveWorkflowRegistry(source, runtimeContext.TenantContext.Registries); reg != nil { + regType, err := settings.ParseRegistryType(reg.Type) + if err != nil { + return nil, fmt.Errorf("registry %q: %w", reg.ID, err) + } + switch regType { + case settings.RegistryTypeOffChain: + return settings.NewOffChainRegistry(reg.ID, donFamily), nil + case settings.RegistryTypeOnChain: + if reg.Address == nil || strings.TrimSpace(*reg.Address) == "" { + return nil, fmt.Errorf("on-chain registry %q has no address in user context", reg.ID) + } + if reg.ChainSelector == nil || strings.TrimSpace(*reg.ChainSelector) == "" { + return nil, fmt.Errorf("on-chain registry %q has no chain selector in user context", reg.ID) + } + chainName, err := settings.ChainNameFromSelectorString(*reg.ChainSelector) + if err != nil { + return nil, fmt.Errorf("registry %q: %w", reg.ID, err) + } + return settings.NewOnChainRegistry(reg.ID, strings.TrimSpace(*reg.Address), chainName, donFamily, explorerURL), nil + default: + return nil, fmt.Errorf( + "registry %q is not supported by this CLI version (unrecognised type from server); run `cre login` after upgrading", + reg.ID, + ) + } + } + + // Fallback: the source itself encodes the on-chain registry location. + if selector, addr, ok := workflowresolve.ParseContractWorkflowSource(source); ok && strings.TrimSpace(addr) != "" { + chainName, err := settings.ChainNameFromSelectorString(selector) + if err != nil { + return nil, fmt.Errorf("cannot resolve registry chain from workflow source %q: %w", source, err) + } + return settings.NewOnChainRegistry(source, strings.TrimSpace(addr), chainName, donFamily, explorerURL), nil + } + + if strings.TrimSpace(source) == "" { + source = "private" + } + return settings.NewOffChainRegistry(source, donFamily), nil +} + +// VerifyEOAOwner ensures the CRE_ETH_PRIVATE_KEY in the environment derives the +// given owner address, so an on-chain folder-less operation fails fast instead +// of sending a transaction that the registry would reject. +func VerifyEOAOwner(v *viper.Viper, ownerAddress string) error { + resolvedKey, err := settings.ResolveEthPrivateKeyFromEnv(v.GetString(settings.EthPrivateKeyEnvVar)) + if err != nil { + return err + } + if !resolvedKey.IsSet() { + return fmt.Errorf( + "%s is not set. Set it in your .env file or export it in your shell environment to sign the transaction", + settings.EthPrivateKeyEnvVar, + ) + } + + derived, err := ethkeys.DeriveEthAddressFromPrivateKey(resolvedKey.Hex()) + if err != nil { + return err + } + owner, err := ethkeys.FormatWorkflowOwnerAddress(ownerAddress) + if err != nil { + return err + } + if derived != owner { + return fmt.Errorf( + "workflow is owned by %s but %s derives %s; export the private key of the owner account", + owner, settings.EthPrivateKeyEnvVar, derived, + ) + } + return nil +} diff --git a/cmd/workflow/common/folderless_test.go b/cmd/workflow/common/folderless_test.go new file mode 100644 index 00000000..fd7340f5 --- /dev/null +++ b/cmd/workflow/common/folderless_test.go @@ -0,0 +1,203 @@ +package common + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/environments" + "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/testutil/chainsim" +) + +func TestIsFolderlessInvocation(t *testing.T) { + t.Parallel() + + existingDir := t.TempDir() + + // A project layout where the workflow dir only exists relative to the + // project root, not the invocation directory (the legacy resolution mode). + projectRoot := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(projectRoot, "project.yaml"), []byte("staging-settings: {}"), 0600)) + require.NoError(t, os.Mkdir(filepath.Join(projectRoot, "blank_workflow"), 0755)) + + tests := []struct { + name string + args []string + projectRootFlag string + want bool + }{ + {"no args (picker mode)", nil, "", true}, + {"workflow name", []string{"my-workflow"}, "", true}, + {"on-chain workflow ID", []string{"00da21b8b3e117e31f3a3e8a0795225cbde6c00283a84395117669691f2b7856"}, "", true}, + {"existing directory", []string{existingDir}, "", false}, + {"current directory", []string{"."}, "", false}, + {"parent directory", []string{".."}, "", false}, + {"relative path", []string{"./my-workflow"}, "", false}, + {"nested relative path", []string{"workflows/my-workflow"}, "", false}, + {"absolute path", []string{filepath.Join(string(os.PathSeparator), "tmp", "my-workflow")}, "", false}, + {"name that is a directory under --project-root", []string{"blank_workflow"}, projectRoot, false}, + {"unknown name with --project-root", []string{"other-workflow"}, projectRoot, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, IsFolderlessInvocation(tt.args, tt.projectRootFlag)) + }) + } +} + +func folderlessRuntimeContext(registries []*tenantctx.Registry) *runtime.Context { + return &runtime.Context{ + EnvironmentSet: &environments.EnvironmentSet{ + WorkflowRegistryChainExplorerURL: "https://explorer.example", + }, + TenantContext: &tenantctx.EnvironmentContext{ + DefaultDonFamily: "don-a", + Registries: registries, + }, + } +} + +func TestRegistryFromWorkflowSource(t *testing.T) { + t.Parallel() + + onChainReg := &tenantctx.Registry{ + ID: "onchain-sepolia", + Type: "on-chain", + Address: strPtr("0x51D3acf4526e014deBf9884159A57f63Fc0Ca49D"), + ChainSelector: strPtr("16015286601757825753"), + } + privateReg := &tenantctx.Registry{ID: "private", Type: "off-chain"} + sepoliaSource := "contract:16015286601757825753:0x51D3acf4526e014deBf9884159A57f63Fc0Ca49D" + + t.Run("private source matched in tenant context", func(t *testing.T) { + t.Parallel() + reg, err := RegistryFromWorkflowSource(folderlessRuntimeContext([]*tenantctx.Registry{privateReg, onChainReg}), "private") + require.NoError(t, err) + assert.Equal(t, settings.RegistryTypeOffChain, reg.Type()) + assert.Equal(t, "private", reg.ID()) + assert.Equal(t, "don-a", reg.DonFamily()) + }) + + t.Run("contract source matched in tenant context", func(t *testing.T) { + t.Parallel() + reg, err := RegistryFromWorkflowSource(folderlessRuntimeContext([]*tenantctx.Registry{privateReg, onChainReg}), sepoliaSource) + require.NoError(t, err) + assert.Equal(t, settings.RegistryTypeOnChain, reg.Type()) + assert.Equal(t, "onchain-sepolia", reg.ID()) + + oc, ok := reg.(*settings.OnChainRegistry) + require.True(t, ok) + assert.Equal(t, "0x51D3acf4526e014deBf9884159A57f63Fc0Ca49D", oc.Address()) + assert.Equal(t, "ethereum-testnet-sepolia", oc.ChainName()) + assert.Equal(t, "https://explorer.example", oc.ExplorerURL()) + }) + + t.Run("contract source fallback without tenant match", func(t *testing.T) { + t.Parallel() + reg, err := RegistryFromWorkflowSource(folderlessRuntimeContext(nil), sepoliaSource) + require.NoError(t, err) + assert.Equal(t, settings.RegistryTypeOnChain, reg.Type()) + assert.Equal(t, sepoliaSource, reg.ID()) + + oc, ok := reg.(*settings.OnChainRegistry) + require.True(t, ok) + assert.Equal(t, "0x51D3acf4526e014deBf9884159A57f63Fc0Ca49D", oc.Address()) + assert.Equal(t, "ethereum-testnet-sepolia", oc.ChainName()) + }) + + t.Run("contract source with unknown chain selector", func(t *testing.T) { + t.Parallel() + _, err := RegistryFromWorkflowSource(folderlessRuntimeContext(nil), "contract:99999999999999999999:0x51D3acf4526e014deBf9884159A57f63Fc0Ca49D") + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot resolve registry chain") + }) + + t.Run("grpc source falls back to off-chain", func(t *testing.T) { + t.Parallel() + reg, err := RegistryFromWorkflowSource(folderlessRuntimeContext(nil), "grpc:some-registry") + require.NoError(t, err) + assert.Equal(t, settings.RegistryTypeOffChain, reg.Type()) + assert.Equal(t, "grpc:some-registry", reg.ID()) + }) + + t.Run("empty source defaults to private", func(t *testing.T) { + t.Parallel() + reg, err := RegistryFromWorkflowSource(folderlessRuntimeContext(nil), "") + require.NoError(t, err) + assert.Equal(t, settings.RegistryTypeOffChain, reg.Type()) + assert.Equal(t, "private", reg.ID()) + }) + + t.Run("unsupported registry type errors", func(t *testing.T) { + t.Parallel() + future := &tenantctx.Registry{ID: "future", Type: "FUTURE_TYPE"} + _, err := RegistryFromWorkflowSource(folderlessRuntimeContext([]*tenantctx.Registry{future}), "future") + require.Error(t, err) + }) +} + +func TestVerifyEOAOwner(t *testing.T) { + t.Parallel() + + t.Run("matching key passes", func(t *testing.T) { + t.Parallel() + v := viper.New() + v.Set(settings.EthPrivateKeyEnvVar, chainsim.TestPrivateKey) + require.NoError(t, VerifyEOAOwner(v, chainsim.TestAddress)) + }) + + t.Run("owner mismatch fails", func(t *testing.T) { + t.Parallel() + v := viper.New() + v.Set(settings.EthPrivateKeyEnvVar, chainsim.TestPrivateKey) + err := VerifyEOAOwner(v, "0x4c0883a69102937d6234146e38a6aefbf95944c6") + require.Error(t, err) + assert.Contains(t, err.Error(), settings.EthPrivateKeyEnvVar) + }) + + t.Run("missing key fails", func(t *testing.T) { + t.Parallel() + v := viper.New() + err := VerifyEOAOwner(v, chainsim.TestAddress) + require.Error(t, err) + assert.Contains(t, err.Error(), settings.EthPrivateKeyEnvVar+" is not set") + }) +} + +func TestGuardFolderlessTxnType(t *testing.T) { + t.Parallel() + + t.Run("unsigned is rejected", func(t *testing.T) { + t.Parallel() + v := viper.New() + v.Set(settings.Flags.RawTxFlag.Name, true) + err := GuardFolderlessTxnType(v) + require.Error(t, err) + assert.Contains(t, err.Error(), "require a project folder") + }) + + t.Run("changeset is rejected", func(t *testing.T) { + t.Parallel() + v := viper.New() + v.Set(settings.Flags.Changeset.Name, true) + err := GuardFolderlessTxnType(v) + require.Error(t, err) + assert.Contains(t, err.Error(), "require a project folder") + }) + + t.Run("regular transactions pass", func(t *testing.T) { + t.Parallel() + require.NoError(t, GuardFolderlessTxnType(viper.New())) + }) +} + +func strPtr(s string) *string { return &s } diff --git a/cmd/workflow/delete/delete.go b/cmd/workflow/delete/delete.go index f15fb95c..d016636a 100644 --- a/cmd/workflow/delete/delete.go +++ b/cmd/workflow/delete/delete.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "strings" "github.com/rs/zerolog" "github.com/spf13/cobra" @@ -17,26 +18,44 @@ 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" ) type Inputs struct { WorkflowName string `validate:"workflow_name"` WorkflowOwner string `validate:"workflow_owner"` + WorkflowID string + RPCURL string SkipConfirmation bool NonInteractive bool } func New(runtimeContext *runtime.Context) *cobra.Command { deleteCmd := &cobra.Command{ - Use: "delete ", - Short: "Deletes all versions of a workflow from the Workflow Registry", - Long: "Deletes all workflow versions matching the given name and owner address.", - Args: cobra.ExactArgs(1), - Example: `cre workflow delete ./my-workflow`, + Use: "delete [workflow-name-or-id | workflow-folder-path]", + Short: "Deletes all versions of a workflow from the Workflow Registry", + Long: `Deletes all workflow versions matching the given name and owner address. + +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.`, + Args: cobra.MaximumNArgs(1), + Example: `cre workflow delete ./my-workflow + cre workflow delete my-workflow + cre workflow delete 00da21b8b3e117e31f3a3e8a0795225cbde6c00283a84395117669691f2b7856 + cre workflow delete my-workflow --rpc-url https://... + cre workflow delete # interactive picker`, RunE: func(cmd *cobra.Command, args []string) error { handler := newHandler(runtimeContext, cmd.InOrStdin()) - 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 } @@ -51,6 +70,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { settings.AddTxnTypeFlags(deleteCmd) settings.AddSkipConfirmation(deleteCmd) + settings.AddFolderlessLifecycleFlags(deleteCmd) return deleteCmd } @@ -66,6 +86,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 } @@ -86,18 +110,62 @@ func newHandler(ctx *runtime.Context, stdin io.Reader) *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() if err != nil { return Inputs{}, fmt.Errorf("failed to resolve workflow owner: %w", err) } - return Inputs{ - WorkflowName: h.settings.Workflow.UserWorkflowSettings.WorkflowName, - WorkflowOwner: resolvedWorkflowOwner, - 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 + 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, + PickerTitle: "Select a workflow to delete", + }) + 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 + + 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 delete an on-chain workflow without a project folder", + onChain.ChainName(), settings.Flags.RPCURL.Name, + ) + } + } + + return inputs, nil } // resolveWorkflowOwner returns the effective owner address for workflow ID computation. diff --git a/cmd/workflow/delete/delete_test.go b/cmd/workflow/delete/delete_test.go index 17bb9a2c..a0c526bd 100644 --- a/cmd/workflow/delete/delete_test.go +++ b/cmd/workflow/delete/delete_test.go @@ -1,13 +1,16 @@ package delete import ( + "context" "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" @@ -65,6 +68,37 @@ func TestNonInteractive_WithYes_Proceeds(t *testing.T) { assert.True(t, ok) } +// folderlessRuntimeContext builds a runtime context with no settings attached, +// which is how delete 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), testutil.EmptyMockStdinReader()) + _, 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), testutil.EmptyMockStdinReader()) + _, err := h.ResolveInputs(context.Background(), v, "my-workflow") + require.Error(t, err) + require.Contains(t, err.Error(), "credentials not available") +} + func TestWorkflowDeleteCommand(t *testing.T) { t.Run("validation errors", func(t *testing.T) { t.Parallel() diff --git a/cmd/workflow/delete/registry_delete_strategy_onchain.go b/cmd/workflow/delete/registry_delete_strategy_onchain.go index b2c2911c..a77eb5b2 100644 --- a/cmd/workflow/delete/registry_delete_strategy_onchain.go +++ b/cmd/workflow/delete/registry_delete_strategy_onchain.go @@ -35,7 +35,15 @@ func newOnchainRegistryDeleteStrategy(h *handler) (*onchainRegistryDeleteStrateg 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 diff --git a/cmd/workflow/delete/registry_delete_strategy_private.go b/cmd/workflow/delete/registry_delete_strategy_private.go index 5001dd42..3de40f31 100644 --- a/cmd/workflow/delete/registry_delete_strategy_private.go +++ b/cmd/workflow/delete/registry_delete_strategy_private.go @@ -28,6 +28,20 @@ func (a *privateRegistryDeleteStrategy) FetchWorkflows() ([]WorkflowToDelete, er a.ensureClient() h := a.h + + // Folder-less flow: the platform row already identifies the exact registry + // workflow ID, so no name lookup is needed. + if row := h.folderlessRow; row != nil { + return []WorkflowToDelete{ + { + ID: row.WorkflowID, + Owner: row.OwnerAddress, + Status: row.Status, + RawID: row.WorkflowID, + }, + }, nil + } + workflowName := h.inputs.WorkflowName ui.Dim(fmt.Sprintf("Fetching workflow to delete... Name=%s", workflowName)) diff --git a/cmd/workflow/pause/pause.go b/cmd/workflow/pause/pause.go index fe3382ea..341ebe0d 100644 --- a/cmd/workflow/pause/pause.go +++ b/cmd/workflow/pause/pause.go @@ -3,6 +3,7 @@ package pause import ( "context" "fmt" + "strings" "github.com/rs/zerolog" "github.com/spf13/cobra" @@ -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 ( @@ -24,21 +26,38 @@ const ( type Inputs struct { WorkflowName string `validate:"workflow_name"` WorkflowOwner string `validate:"workflow_owner"` + WorkflowID string + RPCURL string SkipConfirmation bool NonInteractive bool } func New(runtimeContext *runtime.Context) *cobra.Command { var pauseCmd = &cobra.Command{ - Use: "pause ", - Short: "Pauses workflow on the Workflow Registry contract", - Long: `Changes workflow status to paused on the Workflow Registry contract`, - Args: cobra.ExactArgs(1), - Example: `cre workflow pause ./my-workflow`, + Use: "pause [workflow-name-or-id | workflow-folder-path]", + Short: "Pauses workflow on the Workflow Registry contract", + Long: `Changes workflow status to paused 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.`, + Args: cobra.MaximumNArgs(1), + Example: `cre workflow pause ./my-workflow + cre workflow pause my-workflow + cre workflow pause 00da21b8b3e117e31f3a3e8a0795225cbde6c00283a84395117669691f2b7856 + cre workflow pause my-workflow --rpc-url https://... --yes + cre workflow pause # 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 } @@ -53,6 +72,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { settings.AddTxnTypeFlags(pauseCmd) settings.AddSkipConfirmation(pauseCmd) + settings.AddFolderlessLifecycleFlags(pauseCmd) return pauseCmd } @@ -64,6 +84,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 } @@ -81,18 +105,65 @@ 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, - 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 + 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, "ACTIVE") + }, + PickerTitle: "Select a workflow to pause", + }) + 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 + + 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 pause an on-chain workflow without a project folder", + onChain.ChainName(), settings.Flags.RPCURL.Name, + ) + } + } + + return inputs, nil } func (h *handler) ValidateInputs() error { diff --git a/cmd/workflow/pause/pause_test.go b/cmd/workflow/pause/pause_test.go index 8cf59cda..5976f78e 100644 --- a/cmd/workflow/pause/pause_test.go +++ b/cmd/workflow/pause/pause_test.go @@ -5,11 +5,14 @@ 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" ) @@ -67,6 +70,59 @@ func TestNonInteractive_WithYes_PassesGuard(t *testing.T) { require.NotContains(t, err.Error(), "missing required flags for --non-interactive mode") } +// folderlessRuntimeContext builds a runtime context with no settings attached, +// which is how pause 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_ChangesetFlag_ReturnsError(t *testing.T) { + t.Parallel() + v := viper.New() + v.Set(settings.Flags.Changeset.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 TestFolderless_NonInteractiveWithoutArg_ReturnsError(t *testing.T) { + t.Parallel() + v := viper.New() + v.Set(settings.Flags.NonInteractive.Name, true) + + h := newHandler(folderlessRuntimeContext(v)) + h.runtimeContext.Credentials = nil // picker would require credentials anyway + _, err := h.ResolveInputs(context.Background(), v, "") + require.Error(t, err) +} + func TestWorkflowPauseCommand(t *testing.T) { t.Run("validation errors", func(t *testing.T) { t.Parallel() diff --git a/cmd/workflow/pause/registry_pause_strategy_onchain.go b/cmd/workflow/pause/registry_pause_strategy_onchain.go index e71a6063..ae50cf7f 100644 --- a/cmd/workflow/pause/registry_pause_strategy_onchain.go +++ b/cmd/workflow/pause/registry_pause_strategy_onchain.go @@ -34,7 +34,7 @@ func newOnchainRegistryPauseStrategy(h *handler) (*onchainRegistryPauseStrategy, a.wg.Add(1) go func() { defer a.wg.Done() - wrc, err := h.clientFactory.NewWorkflowRegistryV2Client(h.execCtx) + wrc, err := newWorkflowRegistryClient(h) if err != nil { a.initErr = fmt.Errorf("failed to create workflow registry client: %w", err) return @@ -44,6 +44,19 @@ func newOnchainRegistryPauseStrategy(h *handler) (*onchainRegistryPauseStrategy, return a, nil } +// newWorkflowRegistryClient builds the registry client, using the explicit +// --rpc-url when running folder-less (no project settings on disk). +func newWorkflowRegistryClient(h *handler) (*client.WorkflowRegistryV2Client, error) { + onChain, err := settings.AsOnChain(h.runtimeContext.ResolvedRegistry, "pause") + if err != nil { + return nil, err + } + if h.inputs.RPCURL != "" { + return h.clientFactory.NewWorkflowRegistryV2ClientWithRPC(h.execCtx, h.inputs.RPCURL, onChain.Address()) + } + return h.clientFactory.NewWorkflowRegistryV2Client(h.execCtx) +} + func (a *onchainRegistryPauseStrategy) Pause() error { h := a.h diff --git a/cmd/workflow/pause/registry_pause_strategy_private.go b/cmd/workflow/pause/registry_pause_strategy_private.go index 6db00cb2..ac3ea8fb 100644 --- a/cmd/workflow/pause/registry_pause_strategy_private.go +++ b/cmd/workflow/pause/registry_pause_strategy_private.go @@ -28,24 +28,31 @@ func (a *privateRegistryPauseStrategy) Pause() error { a.ensureClient() h := a.h - workflowName := h.inputs.WorkflowName + workflowID := h.inputs.WorkflowID - ui.Dim(fmt.Sprintf("Fetching workflow to pause... 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 pause... 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.WorkflowStatusPaused { + return fmt.Errorf("workflow is already paused, cancelling transaction") + } - if workflow.Status == privateregistryclient.WorkflowStatusPaused { - return fmt.Errorf("workflow is already paused, cancelling transaction") + workflowID = workflow.WorkflowID } - h.runtimeContext.Workflow.ID = workflow.WorkflowID + h.runtimeContext.Workflow.ID = workflowID - ui.Dim(fmt.Sprintf("Processing pause for workflow ID %s...", workflow.WorkflowID)) + ui.Dim(fmt.Sprintf("Processing pause for workflow ID %s...", workflowID)) - result, err := a.prc.PauseWorkflowInRegistry(a.h.execCtx, workflow.WorkflowID) + result, err := a.prc.PauseWorkflowInRegistry(a.h.execCtx, workflowID) if err != nil { return fmt.Errorf("failed to pause workflow in private registry: %w", err) } diff --git a/docs/cre_workflow_delete.md b/docs/cre_workflow_delete.md index 7b533b30..a0fd48e8 100644 --- a/docs/cre_workflow_delete.md +++ b/docs/cre_workflow_delete.md @@ -6,22 +6,35 @@ Deletes all versions of a workflow from the Workflow Registry Deletes all workflow versions matching the given name and owner address. +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. + ``` -cre workflow delete [optional flags] +cre workflow delete [workflow-name-or-id | workflow-folder-path] [flags] ``` ### Examples ``` cre workflow delete ./my-workflow + cre workflow delete my-workflow + cre workflow delete 00da21b8b3e117e31f3a3e8a0795225cbde6c00283a84395117669691f2b7856 + cre workflow delete my-workflow --rpc-url https://... + cre workflow delete # interactive picker ``` ### Options ``` - -h, --help help for delete - --unsigned If set, the command will either return the raw transaction instead of sending it to the network or execute the second step of secrets operations using a previously generated raw transaction - --yes If set, the command will skip the confirmation prompt and proceed with the operation even if it is potentially destructive + -h, --help help for delete + -o, --owner string Scope workflow name resolution to this owner address + --registry string Scope workflow resolution to a registry ID (see 'cre workflow list') + --rpc-url string RPC URL of the workflow registry chain (required for on-chain workflows when no project folder is used) + --unsigned If set, the command will either return the raw transaction instead of sending it to the network or execute the second step of secrets operations using a previously generated raw transaction + --yes If set, the command will skip the confirmation prompt and proceed with the operation even if it is potentially destructive ``` ### Options inherited from parent commands diff --git a/docs/cre_workflow_pause.md b/docs/cre_workflow_pause.md index 2117e703..3a291ed0 100644 --- a/docs/cre_workflow_pause.md +++ b/docs/cre_workflow_pause.md @@ -4,24 +4,37 @@ Pauses workflow on the Workflow Registry contract ### Synopsis -Changes workflow status to paused on the Workflow Registry contract +Changes workflow status to paused 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. ``` -cre workflow pause [optional flags] +cre workflow pause [workflow-name-or-id | workflow-folder-path] [flags] ``` ### Examples ``` cre workflow pause ./my-workflow + cre workflow pause my-workflow + cre workflow pause 00da21b8b3e117e31f3a3e8a0795225cbde6c00283a84395117669691f2b7856 + cre workflow pause my-workflow --rpc-url https://... --yes + cre workflow pause # interactive picker ``` ### Options ``` - -h, --help help for pause - --unsigned If set, the command will either return the raw transaction instead of sending it to the network or execute the second step of secrets operations using a previously generated raw transaction - --yes If set, the command will skip the confirmation prompt and proceed with the operation even if it is potentially destructive + -h, --help help for pause + -o, --owner string Scope workflow name resolution to this owner address + --registry string Scope workflow resolution to a registry ID (see 'cre workflow list') + --rpc-url string RPC URL of the workflow registry chain (required for on-chain workflows when no project folder is used) + --unsigned If set, the command will either return the raw transaction instead of sending it to the network or execute the second step of secrets operations using a previously generated raw transaction + --yes If set, the command will skip the confirmation prompt and proceed with the operation even if it is potentially destructive ``` ### Options inherited from parent commands diff --git a/internal/settings/settings_load.go b/internal/settings/settings_load.go index f14c371b..45305a94 100644 --- a/internal/settings/settings_load.go +++ b/internal/settings/settings_load.go @@ -49,6 +49,8 @@ type flagNames struct { ChangesetFile Flag AllowUnknownChains Flag AllowInsecureRPC Flag + RPCURL Flag + Registry Flag } var Flags = flagNames{ @@ -68,6 +70,8 @@ var Flags = flagNames{ ChangesetFile: Flag{"changeset-file", ""}, AllowUnknownChains: Flag{"allow-unknown-chains", ""}, AllowInsecureRPC: Flag{"allow-insecure-rpc", ""}, + RPCURL: Flag{"rpc-url", ""}, + Registry: Flag{"registry", ""}, } func AddTxnTypeFlags(cmd *cobra.Command) { @@ -84,6 +88,14 @@ func AddSkipConfirmation(cmd *cobra.Command) { cmd.Flags().Bool(Flags.SkipConfirmation.Name, false, "If set, the command will skip the confirmation prompt and proceed with the operation even if it is potentially destructive") } +// AddFolderlessLifecycleFlags adds the flags used by workflow pause/delete when +// resolving a workflow by name or ID without a project folder. +func AddFolderlessLifecycleFlags(cmd *cobra.Command) { + cmd.Flags().String(Flags.RPCURL.Name, "", "RPC URL of the workflow registry chain (required for on-chain workflows when no project folder is used)") + cmd.Flags().String(Flags.Registry.Name, "", "Scope workflow resolution to a registry ID (see 'cre workflow list')") + cmd.Flags().StringP(Flags.Owner.Name, Flags.Owner.Short, "", "Scope workflow name resolution to this owner address") +} + func mergeConfigToViper(v *viper.Viper, filePath string) error { v.SetConfigFile(filePath) err := v.MergeInConfig() diff --git a/internal/testutil/chainsim/simulated_client_factory.go b/internal/testutil/chainsim/simulated_client_factory.go index 6ce1fba2..6db0403c 100644 --- a/internal/testutil/chainsim/simulated_client_factory.go +++ b/internal/testutil/chainsim/simulated_client_factory.go @@ -33,6 +33,12 @@ func (f *testFactoryImpl) NewWorkflowRegistryV2Client(ctx context.Context) (*cli return client.NewWorkflowRegistryV2Client(f.logger, f.ethClient, f.simulatedContracts.WorkflowRegistry.Contract.Hex(), txcConfig), nil } +func (f *testFactoryImpl) NewWorkflowRegistryV2ClientWithRPC(ctx context.Context, rpcURL, registryAddress string) (*client.WorkflowRegistryV2Client, error) { + // The simulated environment ignores rpcURL/registryAddress and always + // targets the simulated registry contract. + return f.NewWorkflowRegistryV2Client(ctx) +} + func (f *testFactoryImpl) GetTxType() client.TxType { return client.Regular } diff --git a/internal/workflowresolve/resolve_row.go b/internal/workflowresolve/resolve_row.go new file mode 100644 index 00000000..2e6f4909 --- /dev/null +++ b/internal/workflowresolve/resolve_row.go @@ -0,0 +1,197 @@ +package workflowresolve + +import ( + "context" + "fmt" + "strings" + + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/tenantctx" + "github.com/smartcontractkit/cre-cli/internal/ui" +) + +// RowResolveOptions controls how ResolveWorkflowRow narrows candidates. +type RowResolveOptions struct { + // OwnerFilter scopes name search to workflows owned by this address. + OwnerFilter string + // RegistryFilter scopes candidates to the registry with this ID. + RegistryFilter string + // Registries is the tenant registry list, used for RegistryFilter and picker labels. + Registries []*tenantctx.Registry + // NonInteractive fails instead of prompting when the selection is ambiguous. + NonInteractive bool + // CandidateFilter further restricts eligible rows (e.g. ACTIVE-only for pause); + // nil means every non-deleted row is eligible. + CandidateFilter func(Workflow) bool + // PickerTitle is the title shown on the interactive picker. + PickerTitle string + // Pick selects one row among several candidates; nil uses the default interactive picker. + Pick func(candidates []Workflow, title string, registries []*tenantctx.Registry) (*Workflow, error) +} + +// ResolveWorkflowRow resolves a workflow reference to a single platform workflow row. +// The reference can be empty (interactive picker over all workflows), an on-chain +// workflow ID (64-char hex), or a workflow name. Deleted workflows are excluded. +func ResolveWorkflowRow(ctx context.Context, wdc WorkflowLookup, arg string, opts RowResolveOptions) (*Workflow, error) { + arg = strings.TrimSpace(arg) + + if err := opts.checkRegistryFilter(); err != nil { + return nil, err + } + + switch { + case arg == "": + return resolveInteractively(ctx, wdc, opts) + case LooksLikeWorkflowID(arg): + return resolveRowByWorkflowID(ctx, wdc, arg, opts) + default: + return resolveRowByName(ctx, wdc, arg, opts) + } +} + +func resolveInteractively(ctx context.Context, wdc WorkflowLookup, opts RowResolveOptions) (*Workflow, error) { + if opts.NonInteractive { + return nil, fmt.Errorf("a workflow name or ID is required in non-interactive mode") + } + + spinner := ui.NewSpinner() + spinner.Start("Listing workflows...") + rows, err := wdc.ListAll(ctx, workflowdataclient.DefaultPageSize) + spinner.Stop() + if err != nil { + return nil, fmt.Errorf("listing workflows: %w", err) + } + + candidates := narrowRows(OmitDeleted(rows), opts) + if len(candidates) == 0 { + return nil, fmt.Errorf("no eligible workflows found") + } + return pickRow(candidates, opts) +} + +func resolveRowByWorkflowID(ctx context.Context, wdc WorkflowLookup, workflowID string, opts RowResolveOptions) (*Workflow, error) { + spinner := ui.NewSpinner() + spinner.Start(fmt.Sprintf("Resolving workflow ID %q...", workflowID)) + rows, err := wdc.ListAll(ctx, workflowdataclient.DefaultPageSize) + spinner.Stop() + if err != nil { + return nil, fmt.Errorf("resolving workflow ID %q: %w", workflowID, err) + } + + for _, r := range rows { + if strings.EqualFold(r.WorkflowID, workflowID) { + if !eligible(r, opts) { + return nil, fmt.Errorf("workflow %q (name %q, status %s) is not eligible for this operation", workflowID, r.Name, r.Status) + } + row := r + return &row, nil + } + } + return nil, fmt.Errorf("no workflow found with ID %q", workflowID) +} + +func resolveRowByName(ctx context.Context, wdc WorkflowLookup, name string, opts RowResolveOptions) (*Workflow, error) { + spinner := ui.NewSpinner() + spinner.Start(fmt.Sprintf("Resolving workflow %q...", name)) + rows, err := wdc.SearchByName(ctx, name, workflowdataclient.DefaultPageSize, opts.OwnerFilter) + spinner.Stop() + if err != nil { + return nil, fmt.Errorf("resolving workflow name %q: %w", name, err) + } + + var matches []Workflow + for _, r := range rows { + if strings.EqualFold(strings.TrimSpace(r.Name), name) { + matches = append(matches, r) + } + } + if len(matches) == 0 { + return nil, fmt.Errorf("no workflow found with name %q", name) + } + + candidates := narrowRows(OmitDeleted(matches), opts) + if len(candidates) == 0 { + return nil, fmt.Errorf("workflow %q was found but none of its deployments are eligible (status must not be DELETED and must pass the operation's filter)", name) + } + if len(candidates) == 1 { + return &candidates[0], nil + } + if opts.NonInteractive { + return nil, fmt.Errorf("multiple workflows named %q found; provide the workflow ID instead (see 'cre workflow list'), or scope with --registry", name) + } + return pickRow(candidates, opts) +} + +// narrowRows applies the registry filter and candidate filter to rows. +func narrowRows(rows []Workflow, opts RowResolveOptions) []Workflow { + if opts.RegistryFilter != "" { + reg := FindRegistry(opts.Registries, opts.RegistryFilter) + rows = FilterRowsByRegistry(rows, reg, opts.Registries) + } + if opts.CandidateFilter == nil { + return rows + } + out := make([]Workflow, 0, len(rows)) + for _, r := range rows { + if opts.CandidateFilter(r) { + out = append(out, r) + } + } + return out +} + +func eligible(row Workflow, opts RowResolveOptions) bool { + if opts.RegistryFilter != "" { + reg := FindRegistry(opts.Registries, opts.RegistryFilter) + if len(FilterRowsByRegistry([]Workflow{row}, reg, opts.Registries)) == 0 { + return false + } + } + return opts.CandidateFilter == nil || opts.CandidateFilter(row) +} + +func (opts RowResolveOptions) checkRegistryFilter() error { + if opts.RegistryFilter == "" { + return nil + } + if FindRegistry(opts.Registries, opts.RegistryFilter) == nil { + return fmt.Errorf("registry %q not found in user context; available: [%s]", + opts.RegistryFilter, AvailableRegistryIDs(opts.Registries)) + } + return nil +} + +func pickRow(candidates []Workflow, opts RowResolveOptions) (*Workflow, error) { + if opts.Pick != nil { + return opts.Pick(candidates, opts.PickerTitle, opts.Registries) + } + return PickWorkflow(candidates, opts.PickerTitle, opts.Registries) +} + +// PickWorkflow renders an interactive picker over candidate workflow rows. +func PickWorkflow(candidates []Workflow, title string, registries []*tenantctx.Registry) (*Workflow, error) { + if title == "" { + title = "Select a workflow" + } + options := make([]ui.SelectOption[int], len(candidates)) + for i, c := range candidates { + matched := ResolveWorkflowRegistry(c.WorkflowSource, registries) + options[i] = ui.SelectOption[int]{ + Label: fmt.Sprintf("%s (registry: %s, status: %s, id: %s)", c.Name, RegistryIDOrSource(c.WorkflowSource, matched), c.Status, ShortID(c.WorkflowID)), + Value: i, + } + } + idx, err := ui.Select[int](title, options) + if err != nil { + return nil, err + } + return &candidates[idx], nil +} + +// ShortID truncates a workflow ID for compact display. +func ShortID(id string) string { + if len(id) <= 12 { + return id + } + return id[:6] + "…" + id[len(id)-4:] +} diff --git a/internal/workflowresolve/resolve_row_test.go b/internal/workflowresolve/resolve_row_test.go new file mode 100644 index 00000000..911b39f3 --- /dev/null +++ b/internal/workflowresolve/resolve_row_test.go @@ -0,0 +1,271 @@ +package workflowresolve + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/tenantctx" +) + +type fakeLookup struct { + all []Workflow + listErr error + searchErr error + + lastSearchName string + lastSearchOwner string + searchCalls int + listCalls int +} + +func (f *fakeLookup) ListAll(ctx context.Context, pageSize int) ([]workflowdataclient.Workflow, error) { + f.listCalls++ + return f.all, f.listErr +} + +func (f *fakeLookup) SearchByName(ctx context.Context, name string, pageSize int, ownerAddress string) ([]workflowdataclient.Workflow, error) { + f.searchCalls++ + f.lastSearchName = name + f.lastSearchOwner = ownerAddress + if f.searchErr != nil { + return nil, f.searchErr + } + // Mimic the server-side contains match. + var out []workflowdataclient.Workflow + for _, w := range f.all { + if strings.Contains(strings.ToLower(w.Name), strings.ToLower(name)) { + out = append(out, w) + } + } + return out, nil +} + +const ( + testWorkflowID1 = "00da21b8b3e117e31f3a3e8a0795225cbde6c00283a84395117669691f2b7856" + testWorkflowID2 = "11eb32c9c4f228f42a4b4f9b1896336dcfe7d11394b954282707a70203c89667" +) + +func strptr(s string) *string { return &s } + +func testRegistries() []*tenantctx.Registry { + return []*tenantctx.Registry{ + {ID: "private", Type: "off-chain"}, + {ID: "onchain-sepolia", Type: "on-chain", Address: strptr("0x51D3acf4526e014deBf9884159A57f63Fc0Ca49D"), ChainSelector: strptr("16015286601757825753")}, + } +} + +func TestResolveWorkflowRow_ByID(t *testing.T) { + t.Parallel() + + lookup := &fakeLookup{all: []Workflow{ + {UUID: "u1", Name: "wf-one", WorkflowID: testWorkflowID1, OwnerAddress: "0xabc", Status: "ACTIVE", WorkflowSource: "private"}, + {UUID: "u2", Name: "wf-two", WorkflowID: testWorkflowID2, OwnerAddress: "0xdef", Status: "PAUSED", WorkflowSource: "private"}, + }} + + row, err := ResolveWorkflowRow(context.Background(), lookup, testWorkflowID1, RowResolveOptions{}) + require.NoError(t, err) + assert.Equal(t, "u1", row.UUID) + + // Case-insensitive ID match. + row, err = ResolveWorkflowRow(context.Background(), lookup, strings.ToUpper(testWorkflowID2), RowResolveOptions{}) + require.NoError(t, err) + assert.Equal(t, "u2", row.UUID) + + _, err = ResolveWorkflowRow(context.Background(), lookup, "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", RowResolveOptions{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no workflow found with ID") +} + +func TestResolveWorkflowRow_ByID_Ineligible(t *testing.T) { + t.Parallel() + + lookup := &fakeLookup{all: []Workflow{ + {UUID: "u2", Name: "wf-two", WorkflowID: testWorkflowID2, Status: "PAUSED", WorkflowSource: "private"}, + }} + + _, err := ResolveWorkflowRow(context.Background(), lookup, testWorkflowID2, RowResolveOptions{ + CandidateFilter: func(w Workflow) bool { return strings.EqualFold(w.Status, "ACTIVE") }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "not eligible") + assert.Contains(t, err.Error(), "PAUSED") +} + +func TestResolveWorkflowRow_ByName(t *testing.T) { + t.Parallel() + + lookup := &fakeLookup{all: []Workflow{ + {UUID: "u1", Name: "my-workflow", WorkflowID: testWorkflowID1, Status: "ACTIVE", WorkflowSource: "private"}, + {UUID: "u2", Name: "my-workflow-staging", WorkflowID: testWorkflowID2, Status: "ACTIVE", WorkflowSource: "private"}, + }} + + // Exact match wins even when the search returns contains-matches. + row, err := ResolveWorkflowRow(context.Background(), lookup, "my-workflow", RowResolveOptions{}) + require.NoError(t, err) + assert.Equal(t, "u1", row.UUID) + assert.Equal(t, "my-workflow", lookup.lastSearchName) + + _, err = ResolveWorkflowRow(context.Background(), lookup, "missing", RowResolveOptions{}) + require.Error(t, err) + assert.Contains(t, err.Error(), `no workflow found with name "missing"`) +} + +func TestResolveWorkflowRow_ByName_Ambiguous(t *testing.T) { + t.Parallel() + + lookup := &fakeLookup{all: []Workflow{ + {UUID: "u1", Name: "dup", WorkflowID: testWorkflowID1, Status: "ACTIVE", WorkflowSource: "private"}, + {UUID: "u2", Name: "dup", WorkflowID: testWorkflowID2, Status: "ACTIVE", WorkflowSource: "contract:16015286601757825753:0x51D3acf4526e014deBf9884159A57f63Fc0Ca49D"}, + }} + + // Non-interactive ambiguity → actionable error. + _, err := ResolveWorkflowRow(context.Background(), lookup, "dup", RowResolveOptions{NonInteractive: true, Registries: testRegistries()}) + require.Error(t, err) + assert.Contains(t, err.Error(), "multiple workflows named") + assert.Contains(t, err.Error(), "--registry") + + // Interactive ambiguity → picker stub decides. + picked := false + row, err := ResolveWorkflowRow(context.Background(), lookup, "dup", RowResolveOptions{ + Registries: testRegistries(), + Pick: func(candidates []Workflow, title string, registries []*tenantctx.Registry) (*Workflow, error) { + picked = true + require.Len(t, candidates, 2) + return &candidates[1], nil + }, + }) + require.NoError(t, err) + assert.True(t, picked) + assert.Equal(t, "u2", row.UUID) +} + +func TestResolveWorkflowRow_ByName_RegistryFilter(t *testing.T) { + t.Parallel() + + lookup := &fakeLookup{all: []Workflow{ + {UUID: "u1", Name: "dup", WorkflowID: testWorkflowID1, Status: "ACTIVE", WorkflowSource: "private"}, + {UUID: "u2", Name: "dup", WorkflowID: testWorkflowID2, Status: "ACTIVE", WorkflowSource: "contract:16015286601757825753:0x51D3acf4526e014deBf9884159A57f63Fc0Ca49D"}, + }} + + // Registry filter disambiguates without a picker. + row, err := ResolveWorkflowRow(context.Background(), lookup, "dup", RowResolveOptions{ + RegistryFilter: "onchain-sepolia", + Registries: testRegistries(), + NonInteractive: true, + }) + require.NoError(t, err) + assert.Equal(t, "u2", row.UUID) + + // Unknown registry ID → error listing available registries. + _, err = ResolveWorkflowRow(context.Background(), lookup, "dup", RowResolveOptions{ + RegistryFilter: "nope", + Registries: testRegistries(), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), `registry "nope" not found`) +} + +func TestResolveWorkflowRow_ByName_OwnerFilterPassthrough(t *testing.T) { + t.Parallel() + + lookup := &fakeLookup{all: []Workflow{ + {UUID: "u1", Name: "wf", WorkflowID: testWorkflowID1, Status: "ACTIVE", WorkflowSource: "private"}, + }} + + _, err := ResolveWorkflowRow(context.Background(), lookup, "wf", RowResolveOptions{OwnerFilter: "0xOwner"}) + require.NoError(t, err) + assert.Equal(t, "0xOwner", lookup.lastSearchOwner) +} + +func TestResolveWorkflowRow_ByName_OnlyDeleted(t *testing.T) { + t.Parallel() + + lookup := &fakeLookup{all: []Workflow{ + {UUID: "u1", Name: "gone", WorkflowID: testWorkflowID1, Status: "DELETED", WorkflowSource: "private"}, + }} + + _, err := ResolveWorkflowRow(context.Background(), lookup, "gone", RowResolveOptions{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "none of its deployments are eligible") +} + +func TestResolveWorkflowRow_Picker(t *testing.T) { + t.Parallel() + + lookup := &fakeLookup{all: []Workflow{ + {UUID: "u1", Name: "wf-one", WorkflowID: testWorkflowID1, Status: "ACTIVE", WorkflowSource: "private"}, + {UUID: "u2", Name: "wf-two", WorkflowID: testWorkflowID2, Status: "PAUSED", WorkflowSource: "private"}, + {UUID: "u3", Name: "wf-deleted", WorkflowID: "aaaa21b8b3e117e31f3a3e8a0795225cbde6c00283a84395117669691f2b7856", Status: "DELETED", WorkflowSource: "private"}, + }} + + // Non-interactive with no arg → error. + _, err := ResolveWorkflowRow(context.Background(), lookup, "", RowResolveOptions{NonInteractive: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), "required in non-interactive mode") + + // Deleted rows are excluded from picker candidates. + var gotCandidates []Workflow + row, err := ResolveWorkflowRow(context.Background(), lookup, "", RowResolveOptions{ + Pick: func(candidates []Workflow, title string, registries []*tenantctx.Registry) (*Workflow, error) { + gotCandidates = candidates + return &candidates[0], nil + }, + }) + require.NoError(t, err) + assert.Equal(t, "u1", row.UUID) + require.Len(t, gotCandidates, 2) + for _, c := range gotCandidates { + assert.NotEqual(t, "DELETED", c.Status) + } + + // Candidate filter applies to the picker list too. + row, err = ResolveWorkflowRow(context.Background(), lookup, "", RowResolveOptions{ + CandidateFilter: func(w Workflow) bool { return strings.EqualFold(w.Status, "ACTIVE") }, + Pick: func(candidates []Workflow, title string, registries []*tenantctx.Registry) (*Workflow, error) { + require.Len(t, candidates, 1) + return &candidates[0], nil + }, + }) + require.NoError(t, err) + assert.Equal(t, "u1", row.UUID) + + // Nothing eligible. + _, err = ResolveWorkflowRow(context.Background(), lookup, "", RowResolveOptions{ + CandidateFilter: func(w Workflow) bool { return false }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "no eligible workflows found") +} + +func TestResolveWorkflowRow_LookupErrors(t *testing.T) { + t.Parallel() + + boom := errors.New("boom") + + _, err := ResolveWorkflowRow(context.Background(), &fakeLookup{listErr: boom}, testWorkflowID1, RowResolveOptions{}) + require.Error(t, err) + assert.True(t, errors.Is(err, boom)) + + _, err = ResolveWorkflowRow(context.Background(), &fakeLookup{listErr: boom}, "", RowResolveOptions{}) + require.Error(t, err) + assert.True(t, errors.Is(err, boom)) + + _, err = ResolveWorkflowRow(context.Background(), &fakeLookup{searchErr: boom}, "wf", RowResolveOptions{}) + require.Error(t, err) + assert.True(t, errors.Is(err, boom)) +} + +func TestShortID(t *testing.T) { + t.Parallel() + + assert.Equal(t, "abc", ShortID("abc")) + assert.Equal(t, "00da21…7856", ShortID(testWorkflowID1)) + assert.Equal(t, fmt.Sprintf("%s…%s", testWorkflowID2[:6], testWorkflowID2[len(testWorkflowID2)-4:]), ShortID(testWorkflowID2)) +} diff --git a/test/multi_command_flows/workflow_folderless.go b/test/multi_command_flows/workflow_folderless.go new file mode 100644 index 00000000..ae187be3 --- /dev/null +++ b/test/multi_command_flows/workflow_folderless.go @@ -0,0 +1,192 @@ +package multi_command_flows + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os/exec" + "strings" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/internal/testutil" +) + +// Folder-less workflow IDs/names used by the mock platform API. +const ( + folderlessWorkflowName = "folderless-wf" + folderlessWorkflowID = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" +) + +// newFolderlessMockServer mocks the platform GraphQL API for folder-less +// pause/delete: org info, tenant config (off-chain registry "reg-test"), the +// workflows list query, and the pause/delete mutations. Any other query fails. +func newFolderlessMockServer(t *testing.T, pauseCalled, deleteCalled *atomic.Bool) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/graphql") || r.Method != http.MethodPost { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("not found")) + return + } + + var req graphQLRequest + _ = json.NewDecoder(r.Body).Decode(&req) + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.Contains(req.Query, "reportUserEvent"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "reportUserEvent": map[string]any{"success": true, "message": ""}, + }, + }) + + case strings.Contains(req.Query, "getCreOrganizationInfo"): + _ = json.NewEncoder(w).Encode(mockGetCreOrganizationInfoGraphQLPayload()) + + case strings.Contains(req.Query, "GetTenantConfig") || strings.Contains(req.Query, "getTenantConfig"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "getTenantConfig": map[string]any{ + "tenantId": "42", + "defaultDonFamily": "test-don", + "vaultGatewayUrl": "https://vault.example.test", + "capabilitiesRegistry": map[string]any{ + "chainSelector": "6433500567565415381", + "address": "0x5FbDB2315678afecb367f032d93F642f64180aa3", + }, + "registries": []map[string]any{ + { + "id": "reg-test", + "label": "reg-test", + "type": "OFF_CHAIN", + }, + }, + "forwarders": []any{}, + }, + }, + }) + + case strings.Contains(req.Query, "ListWorkflows"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "workflows": map[string]any{ + "data": []map[string]any{ + { + "uuid": "9f2e7b6a-1111-4222-8333-444455556666", + "name": folderlessWorkflowName, + "workflowId": folderlessWorkflowID, + "ownerAddress": privateRegistryOwnerAddress, + "status": "ACTIVE", + "workflowSource": "reg-test", + }, + }, + "count": 1, + }, + }, + }) + + case strings.Contains(req.Query, "PauseOffchainWorkflow"): + pauseCalled.Store(true) + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "pauseOffchainWorkflow": map[string]any{ + "workflow": map[string]any{ + "workflowId": folderlessWorkflowID, + "owner": privateRegistryOwnerAddress, + "createdAt": "2025-01-01T00:00:00Z", + "status": "WORKFLOW_STATUS_PAUSED", + "workflowName": folderlessWorkflowName, + "binaryUrl": "https://example.test/binary.wasm", + "configUrl": "", + "tag": folderlessWorkflowName, + "attributes": "", + "donFamily": "test-don", + "organizationId": "test-org-id", + }, + }, + }, + }) + + case strings.Contains(req.Query, "DeleteOffchainWorkflow"): + deleteCalled.Store(true) + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "deleteOffchainWorkflow": map[string]any{ + "workflowId": folderlessWorkflowID, + }, + }, + }) + + default: + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{ + "errors": []map[string]string{{"message": "Unsupported GraphQL query: " + req.Query}}, + }) + } + })) +} + +// runFolderlessCLI runs the CLI from an empty directory (no project.yaml) so +// pause/delete must resolve the workflow remotely. +func runFolderlessCLI(t *testing.T, gqlURL string, args ...string) string { + t.Helper() + + cmd := exec.Command(CLIPath, args...) + cmd.Dir = t.TempDir() // no CRE project here on purpose + cmd.Env = append(testutil.CLIChildEnv(t, CreateTestBearerCredentialsHome(t)), + environments.EnvVarGraphQLURL+"="+gqlURL, + ) + + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + + require.NoError( + t, + cmd.Run(), + "cre %s failed:\nSTDOUT:\n%s\nSTDERR:\n%s", + strings.Join(args, " "), stdout.String(), stderr.String(), + ) + return StripANSI(stdout.String() + stderr.String()) +} + +// RunFolderlessPauseDeleteHappyPath pauses and deletes a workflow without a +// project folder: by name, by on-chain ID, and with the picker-less default +// resolution path. +func RunFolderlessPauseDeleteHappyPath(t *testing.T) { + t.Helper() + + var pauseCalled, deleteCalled atomic.Bool + srv := newFolderlessMockServer(t, &pauseCalled, &deleteCalled) + defer srv.Close() + gqlURL := srv.URL + "/graphql" + + // Pause by workflow name. + out := runFolderlessCLI(t, gqlURL, + "workflow", "pause", folderlessWorkflowName, "--"+settings.Flags.SkipConfirmation.Name) + require.True(t, pauseCalled.Load(), "expected PauseOffchainWorkflow to be called") + require.Contains(t, out, "Workflow paused successfully", "expected pause success.\nCLI OUTPUT:\n%s", out) + require.Contains(t, out, "Registry: reg-test", "expected registry ID in details.\nCLI OUTPUT:\n%s", out) + require.Contains(t, out, "Workflow Name: "+folderlessWorkflowName, "expected workflow name in details.\nCLI OUTPUT:\n%s", out) + + // Pause by on-chain workflow ID. + pauseCalled.Store(false) + out = runFolderlessCLI(t, gqlURL, + "workflow", "pause", folderlessWorkflowID, "--"+settings.Flags.SkipConfirmation.Name) + require.True(t, pauseCalled.Load(), "expected PauseOffchainWorkflow to be called for ID resolution") + require.Contains(t, out, "Workflow paused successfully", "expected pause success for ID resolution.\nCLI OUTPUT:\n%s", out) + + // Delete by workflow name. + out = runFolderlessCLI(t, gqlURL, + "workflow", "delete", folderlessWorkflowName, "--"+settings.Flags.SkipConfirmation.Name) + require.True(t, deleteCalled.Load(), "expected DeleteOffchainWorkflow to be called") + require.Contains(t, out, "Workflows deleted successfully", "expected delete success.\nCLI OUTPUT:\n%s", out) + require.Contains(t, out, "Deleted workflow ID: "+folderlessWorkflowID, "expected deleted workflow ID.\nCLI OUTPUT:\n%s", out) +} diff --git a/test/multi_command_test.go b/test/multi_command_test.go index 62bd8d54..7ed85285 100644 --- a/test/multi_command_test.go +++ b/test/multi_command_test.go @@ -153,6 +153,15 @@ func TestMultiCommandHappyPaths(t *testing.T) { multi_command_flows.RunPrivateRegistryE2E(t, tc, tc.EnvFile, filepath.Join(tc.ProjectDirectory, "blank_workflow")) }) + // Folder-less lifecycle: pause/delete resolved from the platform by name and + // by on-chain ID, running from a directory with no CRE project at all. + t.Run("WorkflowFolderless_PauseDelete", func(t *testing.T) { + t.Setenv(environments.EnvVarEnv, "STAGING") + t.Setenv(environments.EnvVarDonFamily, "test-don") + + multi_command_flows.RunFolderlessPauseDeleteHappyPath(t) + }) + // Run Account Happy Path: Link -> List -> Unlink -> List (verify unlinked) t.Run("AccountHappyPath_LinkListUnlinkList", func(t *testing.T) { anvilProc, testEthUrl := initTestEnv(t, "anvil-state.json") From 326e333ea5c2f0cfe8e69a59c84617a59624027d Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Mon, 26 Oct 2026 16:50:54 -0400 Subject: [PATCH 2/3] Activate command impl --- .changeset/folderless-pause-delete.md | 2 +- cmd/root.go | 4 +- cmd/workflow/activate/activate.go | 106 +++++++++++++++--- cmd/workflow/activate/activate_test.go | 34 ++++++ .../registry_activate_strategy_onchain.go | 10 +- .../registry_activate_strategy_private.go | 29 +++-- docs/cre_workflow_activate.md | 23 +++- .../workflow_folderless.go | 63 +++++++++-- 8 files changed, 226 insertions(+), 45 deletions(-) diff --git a/.changeset/folderless-pause-delete.md b/.changeset/folderless-pause-delete.md index 02051fe9..ded7b00b 100644 --- a/.changeset/folderless-pause-delete.md +++ b/.changeset/folderless-pause-delete.md @@ -2,4 +2,4 @@ "cre-cli": minor --- -`cre workflow pause` and `cre workflow delete` 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. +`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. diff --git a/cmd/root.go b/cmd/root.go index 366c8744..2672dd02 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -552,14 +552,14 @@ func newRootCommand() *cobra.Command { return rootCmd } -// isFolderlessLifecycleInvocation reports whether a workflow pause/delete +// 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": + case "cre workflow pause", "cre workflow delete", "cre workflow activate": return workflowcommon.IsFolderlessInvocation(args, projectRootFlag) default: return false diff --git a/cmd/workflow/activate/activate.go b/cmd/workflow/activate/activate.go index 7e63cf1a..6c6999e8 100644 --- a/cmd/workflow/activate/activate.go +++ b/cmd/workflow/activate/activate.go @@ -3,6 +3,7 @@ package activate import ( "context" "fmt" + "strings" "github.com/rs/zerolog" "github.com/spf13/cobra" @@ -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 ( @@ -25,21 +27,38 @@ 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 ", - 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.`, + 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 } @@ -54,6 +73,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { settings.AddTxnTypeFlags(activateCmd) settings.AddSkipConfirmation(activateCmd) + settings.AddFolderlessLifecycleFlags(activateCmd) return activateCmd } @@ -66,6 +86,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 } @@ -83,19 +107,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 { diff --git a/cmd/workflow/activate/activate_test.go b/cmd/workflow/activate/activate_test.go index 2772cfaf..7b097454 100644 --- a/cmd/workflow/activate/activate_test.go +++ b/cmd/workflow/activate/activate_test.go @@ -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) diff --git a/cmd/workflow/activate/registry_activate_strategy_onchain.go b/cmd/workflow/activate/registry_activate_strategy_onchain.go index add9b74c..6cc00500 100644 --- a/cmd/workflow/activate/registry_activate_strategy_onchain.go +++ b/cmd/workflow/activate/registry_activate_strategy_onchain.go @@ -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 diff --git a/cmd/workflow/activate/registry_activate_strategy_private.go b/cmd/workflow/activate/registry_activate_strategy_private.go index 71bd37a7..2394d527 100644 --- a/cmd/workflow/activate/registry_activate_strategy_private.go +++ b/cmd/workflow/activate/registry_activate_strategy_private.go @@ -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) } diff --git a/docs/cre_workflow_activate.md b/docs/cre_workflow_activate.md index 7b52eabb..7863b207 100644 --- a/docs/cre_workflow_activate.md +++ b/docs/cre_workflow_activate.md @@ -4,24 +4,37 @@ Activates workflow on the Workflow Registry contract ### Synopsis -Changes workflow status to active on the Workflow Registry contract +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. ``` -cre workflow activate [optional flags] +cre workflow activate [workflow-name-or-id | workflow-folder-path] [flags] ``` ### Examples ``` 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 ``` ### Options ``` - -h, --help help for activate - --unsigned If set, the command will either return the raw transaction instead of sending it to the network or execute the second step of secrets operations using a previously generated raw transaction - --yes If set, the command will skip the confirmation prompt and proceed with the operation even if it is potentially destructive + -h, --help help for activate + -o, --owner string Scope workflow name resolution to this owner address + --registry string Scope workflow resolution to a registry ID (see 'cre workflow list') + --rpc-url string RPC URL of the workflow registry chain (required for on-chain workflows when no project folder is used) + --unsigned If set, the command will either return the raw transaction instead of sending it to the network or execute the second step of secrets operations using a previously generated raw transaction + --yes If set, the command will skip the confirmation prompt and proceed with the operation even if it is potentially destructive ``` ### Options inherited from parent commands diff --git a/test/multi_command_flows/workflow_folderless.go b/test/multi_command_flows/workflow_folderless.go index ae187be3..d8fcf649 100644 --- a/test/multi_command_flows/workflow_folderless.go +++ b/test/multi_command_flows/workflow_folderless.go @@ -19,14 +19,17 @@ import ( // Folder-less workflow IDs/names used by the mock platform API. const ( - folderlessWorkflowName = "folderless-wf" - folderlessWorkflowID = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + folderlessWorkflowName = "folderless-wf" + folderlessWorkflowID = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + folderlessPausedWorkflowName = "folderless-wf-paused" + folderlessPausedWorkflowID = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" ) // newFolderlessMockServer mocks the platform GraphQL API for folder-less -// pause/delete: org info, tenant config (off-chain registry "reg-test"), the -// workflows list query, and the pause/delete mutations. Any other query fails. -func newFolderlessMockServer(t *testing.T, pauseCalled, deleteCalled *atomic.Bool) *httptest.Server { +// pause/delete/activate: org info, tenant config (off-chain registry +// "reg-test"), the workflows list query, and the lifecycle mutations. Any +// other query fails. +func newFolderlessMockServer(t *testing.T, pauseCalled, deleteCalled, activateCalled *atomic.Bool) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -87,8 +90,16 @@ func newFolderlessMockServer(t *testing.T, pauseCalled, deleteCalled *atomic.Boo "status": "ACTIVE", "workflowSource": "reg-test", }, + { + "uuid": "8f1e6b5a-2222-4333-8444-555566667777", + "name": folderlessPausedWorkflowName, + "workflowId": folderlessPausedWorkflowID, + "ownerAddress": privateRegistryOwnerAddress, + "status": "PAUSED", + "workflowSource": "reg-test", + }, }, - "count": 1, + "count": 2, }, }, }) @@ -115,6 +126,28 @@ func newFolderlessMockServer(t *testing.T, pauseCalled, deleteCalled *atomic.Boo }, }) + case strings.Contains(req.Query, "ActivateOffchainWorkflow"): + activateCalled.Store(true) + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "activateOffchainWorkflow": map[string]any{ + "workflow": map[string]any{ + "workflowId": folderlessPausedWorkflowID, + "owner": privateRegistryOwnerAddress, + "createdAt": "2025-01-01T00:00:00Z", + "status": "WORKFLOW_STATUS_ACTIVE", + "workflowName": folderlessPausedWorkflowName, + "binaryUrl": "https://example.test/binary.wasm", + "configUrl": "", + "tag": folderlessPausedWorkflowName, + "attributes": "", + "donFamily": "test-don", + "organizationId": "test-org-id", + }, + }, + }, + }) + case strings.Contains(req.Query, "DeleteOffchainWorkflow"): deleteCalled.Store(true) _ = json.NewEncoder(w).Encode(map[string]any{ @@ -157,14 +190,14 @@ func runFolderlessCLI(t *testing.T, gqlURL string, args ...string) string { return StripANSI(stdout.String() + stderr.String()) } -// RunFolderlessPauseDeleteHappyPath pauses and deletes a workflow without a -// project folder: by name, by on-chain ID, and with the picker-less default -// resolution path. +// RunFolderlessPauseDeleteHappyPath pauses, activates, and deletes workflows +// without a project folder: by name, by on-chain ID, and with the picker-less +// default resolution path. func RunFolderlessPauseDeleteHappyPath(t *testing.T) { t.Helper() - var pauseCalled, deleteCalled atomic.Bool - srv := newFolderlessMockServer(t, &pauseCalled, &deleteCalled) + var pauseCalled, deleteCalled, activateCalled atomic.Bool + srv := newFolderlessMockServer(t, &pauseCalled, &deleteCalled, &activateCalled) defer srv.Close() gqlURL := srv.URL + "/graphql" @@ -183,6 +216,14 @@ func RunFolderlessPauseDeleteHappyPath(t *testing.T) { require.True(t, pauseCalled.Load(), "expected PauseOffchainWorkflow to be called for ID resolution") require.Contains(t, out, "Workflow paused successfully", "expected pause success for ID resolution.\nCLI OUTPUT:\n%s", out) + // Activate by workflow name (the paused workflow; the ACTIVE one must be + // filtered out by the PAUSED-only candidate filter). + out = runFolderlessCLI(t, gqlURL, + "workflow", "activate", folderlessPausedWorkflowName, "--"+settings.Flags.SkipConfirmation.Name) + require.True(t, activateCalled.Load(), "expected ActivateOffchainWorkflow to be called") + require.Contains(t, out, "Workflow activated successfully", "expected activate success.\nCLI OUTPUT:\n%s", out) + require.Contains(t, out, "Workflow Name: "+folderlessPausedWorkflowName, "expected workflow name in details.\nCLI OUTPUT:\n%s", out) + // Delete by workflow name. out = runFolderlessCLI(t, gqlURL, "workflow", "delete", folderlessWorkflowName, "--"+settings.Flags.SkipConfirmation.Name) From a0f45fb8c7eb6949992f92b261ce300f063a2f2e Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Mon, 26 Oct 2026 17:19:05 -0400 Subject: [PATCH 3/3] Fix tests --- cmd/workflow/activate/activate.go | 3 ++- cmd/workflow/common/folderless.go | 8 +++++++ cmd/workflow/delete/delete.go | 3 ++- cmd/workflow/pause/pause.go | 3 ++- .../pause/registry_pause_strategy_onchain.go | 23 ++++++++----------- docs/cre_workflow_activate.md | 3 ++- docs/cre_workflow_delete.md | 3 ++- docs/cre_workflow_pause.md | 3 ++- .../workflow_folderless.go | 4 ++-- 9 files changed, 31 insertions(+), 22 deletions(-) diff --git a/cmd/workflow/activate/activate.go b/cmd/workflow/activate/activate.go index 6c6999e8..47a2766e 100644 --- a/cmd/workflow/activate/activate.go +++ b/cmd/workflow/activate/activate.go @@ -43,7 +43,8 @@ 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.`, +--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 diff --git a/cmd/workflow/common/folderless.go b/cmd/workflow/common/folderless.go index 54bc23fa..74afa11c 100644 --- a/cmd/workflow/common/folderless.go +++ b/cmd/workflow/common/folderless.go @@ -118,6 +118,14 @@ func ResolveFolderlessTarget( return nil, err } + // The platform API returns private-registry owners without a 0x prefix; + // normalize to the checksummed form the command validators expect. + owner, err := ethkeys.FormatWorkflowOwnerAddress(row.OwnerAddress) + if err != nil { + return nil, fmt.Errorf("platform returned invalid owner address for workflow %q: %w", row.Name, err) + } + row.OwnerAddress = owner + registry, err := RegistryFromWorkflowSource(runtimeContext, row.WorkflowSource) if err != nil { return nil, err diff --git a/cmd/workflow/delete/delete.go b/cmd/workflow/delete/delete.go index d016636a..c981cf25 100644 --- a/cmd/workflow/delete/delete.go +++ b/cmd/workflow/delete/delete.go @@ -40,7 +40,8 @@ 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.`, +--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 delete ./my-workflow cre workflow delete my-workflow diff --git a/cmd/workflow/pause/pause.go b/cmd/workflow/pause/pause.go index 341ebe0d..5cb4faf6 100644 --- a/cmd/workflow/pause/pause.go +++ b/cmd/workflow/pause/pause.go @@ -42,7 +42,8 @@ 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.`, +--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 pause ./my-workflow cre workflow pause my-workflow diff --git a/cmd/workflow/pause/registry_pause_strategy_onchain.go b/cmd/workflow/pause/registry_pause_strategy_onchain.go index ae50cf7f..ed60ea86 100644 --- a/cmd/workflow/pause/registry_pause_strategy_onchain.go +++ b/cmd/workflow/pause/registry_pause_strategy_onchain.go @@ -34,7 +34,15 @@ func newOnchainRegistryPauseStrategy(h *handler) (*onchainRegistryPauseStrategy, a.wg.Add(1) go func() { defer a.wg.Done() - wrc, err := newWorkflowRegistryClient(h) + 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 @@ -44,19 +52,6 @@ func newOnchainRegistryPauseStrategy(h *handler) (*onchainRegistryPauseStrategy, return a, nil } -// newWorkflowRegistryClient builds the registry client, using the explicit -// --rpc-url when running folder-less (no project settings on disk). -func newWorkflowRegistryClient(h *handler) (*client.WorkflowRegistryV2Client, error) { - onChain, err := settings.AsOnChain(h.runtimeContext.ResolvedRegistry, "pause") - if err != nil { - return nil, err - } - if h.inputs.RPCURL != "" { - return h.clientFactory.NewWorkflowRegistryV2ClientWithRPC(h.execCtx, h.inputs.RPCURL, onChain.Address()) - } - return h.clientFactory.NewWorkflowRegistryV2Client(h.execCtx) -} - func (a *onchainRegistryPauseStrategy) Pause() error { h := a.h diff --git a/docs/cre_workflow_activate.md b/docs/cre_workflow_activate.md index 7863b207..3fb59aaf 100644 --- a/docs/cre_workflow_activate.md +++ b/docs/cre_workflow_activate.md @@ -10,7 +10,8 @@ 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. +--rpc-url and CRE_ETH_PRIVATE_KEY in that mode. --target and --project-root +are ignored in this mode. ``` cre workflow activate [workflow-name-or-id | workflow-folder-path] [flags] diff --git a/docs/cre_workflow_delete.md b/docs/cre_workflow_delete.md index a0fd48e8..cef313d4 100644 --- a/docs/cre_workflow_delete.md +++ b/docs/cre_workflow_delete.md @@ -10,7 +10,8 @@ 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. +--rpc-url and CRE_ETH_PRIVATE_KEY in that mode. --target and --project-root +are ignored in this mode. ``` cre workflow delete [workflow-name-or-id | workflow-folder-path] [flags] diff --git a/docs/cre_workflow_pause.md b/docs/cre_workflow_pause.md index 3a291ed0..8fcdc613 100644 --- a/docs/cre_workflow_pause.md +++ b/docs/cre_workflow_pause.md @@ -10,7 +10,8 @@ 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. +--rpc-url and CRE_ETH_PRIVATE_KEY in that mode. --target and --project-root +are ignored in this mode. ``` cre workflow pause [workflow-name-or-id | workflow-folder-path] [flags] diff --git a/test/multi_command_flows/workflow_folderless.go b/test/multi_command_flows/workflow_folderless.go index d8fcf649..b7e14e3a 100644 --- a/test/multi_command_flows/workflow_folderless.go +++ b/test/multi_command_flows/workflow_folderless.go @@ -86,7 +86,7 @@ func newFolderlessMockServer(t *testing.T, pauseCalled, deleteCalled, activateCa "uuid": "9f2e7b6a-1111-4222-8333-444455556666", "name": folderlessWorkflowName, "workflowId": folderlessWorkflowID, - "ownerAddress": privateRegistryOwnerAddress, + "ownerAddress": MockDerivedWorkflowOwnerHex, // raw, no 0x — as the platform returns it "status": "ACTIVE", "workflowSource": "reg-test", }, @@ -94,7 +94,7 @@ func newFolderlessMockServer(t *testing.T, pauseCalled, deleteCalled, activateCa "uuid": "8f1e6b5a-2222-4333-8444-555566667777", "name": folderlessPausedWorkflowName, "workflowId": folderlessPausedWorkflowID, - "ownerAddress": privateRegistryOwnerAddress, + "ownerAddress": MockDerivedWorkflowOwnerHex, "status": "PAUSED", "workflowSource": "reg-test", },