diff --git a/cmd/account/link_key/link_key.go b/cmd/account/link_key/link_key.go index f4bede55..452c03f6 100644 --- a/cmd/account/link_key/link_key.go +++ b/cmd/account/link_key/link_key.go @@ -7,12 +7,12 @@ import ( "fmt" "io" "math/big" - "os" "strconv" "strings" "sync" "time" + "github.com/charmbracelet/huh" "github.com/ethereum/go-ethereum/common" "github.com/google/uuid" "github.com/machinebox/graphql" @@ -26,10 +26,10 @@ import ( "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/environments" - "github.com/smartcontractkit/cre-cli/internal/prompt" "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/types" + "github.com/smartcontractkit/cre-cli/internal/ui" "github.com/smartcontractkit/cre-cli/internal/validation" ) @@ -59,7 +59,7 @@ type initiateLinkingResponse struct { } func Exec(ctx *runtime.Context, in Inputs) error { - h := newHandler(ctx, os.Stdin) + h := newHandler(ctx, nil) if err := h.ValidateInputs(in); err != nil { return err @@ -161,10 +161,14 @@ func (h *handler) Execute(in Inputs) error { h.displayDetails() if in.WorkflowOwnerLabel == "" { - if err := prompt.SimplePrompt(h.stdin, "Provide a label for your owner address", func(inputLabel string) error { - in.WorkflowOwnerLabel = inputLabel - return nil - }); err != nil { + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Provide a label for your owner address"). + Value(&in.WorkflowOwnerLabel), + ), + ).WithTheme(ui.ChainlinkTheme()) + if err := form.Run(); err != nil { return err } } @@ -182,7 +186,7 @@ func (h *handler) Execute(in Inputs) error { return nil } - fmt.Printf("Starting linking: owner=%s, label=%s\n", in.WorkflowOwner, in.WorkflowOwnerLabel) + ui.Dim(fmt.Sprintf("Starting linking: owner=%s, label=%s", in.WorkflowOwner, in.WorkflowOwnerLabel)) resp, err := h.callInitiateLinking(context.Background(), in) if err != nil { @@ -198,7 +202,7 @@ func (h *handler) Execute(in Inputs) error { h.log.Debug().Msg("\nRaw linking response payload:\n\n" + string(prettyResp)) if in.WorkflowRegistryContractAddress == resp.ContractAddress { - fmt.Println("Contract address validation passed") + ui.Success("Contract address validation passed") } else { h.log.Warn().Msg("The workflowRegistryContractAddress in your settings does not match the one returned by the server") return fmt.Errorf("contract address validation failed") @@ -299,11 +303,14 @@ func (h *handler) linkOwner(resp initiateLinkingResponse) error { switch txOut.Type { case client.Regular: - fmt.Println("Transaction confirmed") - fmt.Printf("View on explorer: \033]8;;%s/tx/%s\033\\%s/tx/%s\033]8;;\033\\\n", h.environmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash, h.environmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash) - fmt.Println("\n[OK] web3 address linked to your CRE organization successfully") - fmt.Println("\nNote: Linking verification may take up to 60 seconds.") - fmt.Println("\n→ You can now deploy workflows using this address") + ui.Success("Transaction confirmed") + ui.URL(fmt.Sprintf("%s/tx/%s", h.environmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash)) + ui.Line() + ui.Success("web3 address linked to your CRE organization successfully") + ui.Line() + ui.Dim("Note: Linking verification may take up to 60 seconds.") + ui.Line() + ui.Bold("You can now deploy workflows using this address") case client.Raw: selector, err := strconv.ParseUint(resp.ChainSelector, 10, 64) @@ -317,19 +324,19 @@ func (h *handler) linkOwner(resp initiateLinkingResponse) error { return err } - fmt.Println("") - fmt.Println("Ownership linking initialized successfully!") - fmt.Println("") - fmt.Println("Next steps:") - fmt.Println("") - fmt.Println(" 1. Submit the following transaction on the target chain:") - fmt.Printf(" Chain: %s\n", ChainName) - fmt.Printf(" Contract Address: %s\n", txOut.RawTx.To) - fmt.Println("") - fmt.Println(" 2. Use the following transaction data:") - fmt.Println("") - fmt.Printf(" %x\n", txOut.RawTx.Data) - fmt.Println("") + ui.Line() + ui.Success("Ownership linking initialized successfully!") + ui.Line() + ui.Bold("Next steps:") + ui.Line() + ui.Print(" 1. Submit the following transaction on the target chain:") + ui.Dim(fmt.Sprintf(" Chain: %s", ChainName)) + ui.Dim(fmt.Sprintf(" Contract Address: %s", txOut.RawTx.To)) + ui.Line() + ui.Print(" 2. Use the following transaction data:") + ui.Line() + ui.Code(fmt.Sprintf(" %x", txOut.RawTx.Data)) + ui.Line() case client.Changeset: chainSelector, err := settings.GetChainSelectorByChainName(h.environmentSet.WorkflowRegistryChainName) @@ -338,7 +345,7 @@ func (h *handler) linkOwner(resp initiateLinkingResponse) error { } mcmsConfig, err := settings.GetMCMSConfig(h.settings, chainSelector) if err != nil { - fmt.Println("\nMCMS config not found or is incorrect, skipping MCMS config in changeset") + ui.Warning("MCMS config not found or is incorrect, skipping MCMS config in changeset") } cldSettings := h.settings.CLDSettings changesets := []types.Changeset{ @@ -370,13 +377,13 @@ func (h *handler) linkOwner(resp initiateLinkingResponse) error { h.log.Warn().Msgf("Unsupported transaction type: %s", txOut.Type) } - fmt.Println("Linked successfully") + ui.Success("Linked successfully") return nil } func (h *handler) checkIfAlreadyLinked() (bool, error) { ownerAddr := common.HexToAddress(h.settings.Workflow.UserWorkflowSettings.WorkflowOwnerAddress) - fmt.Println("\nChecking existing registrations...") + ui.Dim("Checking existing registrations...") linked, err := h.wrc.IsOwnerLinked(ownerAddr) if err != nil { @@ -384,16 +391,18 @@ func (h *handler) checkIfAlreadyLinked() (bool, error) { } if linked { - fmt.Println("web3 address already linked") + ui.Success("web3 address already linked") return true, nil } - fmt.Println("✓ No existing link found for this address") + ui.Success("No existing link found for this address") return false, nil } func (h *handler) displayDetails() { - fmt.Println("Linking web3 key to your CRE organization") - fmt.Printf("Target : \t\t %s\n", h.settings.User.TargetName) - fmt.Printf("✔ Using Address : \t %s\n\n", h.settings.Workflow.UserWorkflowSettings.WorkflowOwnerAddress) + ui.Line() + ui.Title("Linking web3 key to your CRE organization") + ui.Dim(fmt.Sprintf("Target: %s", h.settings.User.TargetName)) + ui.Dim(fmt.Sprintf("Owner Address: %s", h.settings.Workflow.UserWorkflowSettings.WorkflowOwnerAddress)) + ui.Line() } diff --git a/cmd/account/list_key/list_key.go b/cmd/account/list_key/list_key.go index e20f83a3..0e0f3f14 100644 --- a/cmd/account/list_key/list_key.go +++ b/cmd/account/list_key/list_key.go @@ -13,6 +13,7 @@ import ( "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/environments" "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/ui" ) const queryListWorkflowOwners = ` @@ -88,6 +89,9 @@ type WorkflowOwner struct { } func (h *Handler) Execute(ctx context.Context) error { + spinner := ui.NewSpinner() + spinner.Start("Fetching workflow owners...") + req := graphql.NewRequest(queryListWorkflowOwners) var respEnvelope struct { @@ -97,32 +101,34 @@ func (h *Handler) Execute(ctx context.Context) error { } if err := h.client.Execute(ctx, req, &respEnvelope); err != nil { + spinner.Stop() return fmt.Errorf("fetch workflow owners failed: %w", err) } - fmt.Println("\nWorkflow owners retrieved successfully:") + spinner.Stop() + ui.Success("Workflow owners retrieved successfully") h.logOwners("Linked Owners", respEnvelope.ListWorkflowOwners.LinkedOwners) return nil } func (h *Handler) logOwners(label string, owners []WorkflowOwner) { - fmt.Println("") + ui.Line() if len(owners) == 0 { - fmt.Printf(" No %s found\n", strings.ToLower(label)) + ui.Warning(fmt.Sprintf("No %s found", strings.ToLower(label))) return } - fmt.Printf("%s:\n", label) - fmt.Println("") + ui.Title(label) + ui.Line() for i, o := range owners { - fmt.Printf(" %d. %s\n", i+1, o.WorkflowOwnerLabel) - fmt.Printf(" Owner Address: \t%s\n", o.WorkflowOwnerAddress) - fmt.Printf(" Status: \t%s\n", o.VerificationStatus) - fmt.Printf(" Verified At: \t%s\n", o.VerifiedAt) - fmt.Printf(" Chain Selector: \t%s\n", o.ChainSelector) - fmt.Printf(" Contract Address:\t%s\n", o.ContractAddress) - fmt.Println("") + ui.Bold(fmt.Sprintf("%d. %s", i+1, o.WorkflowOwnerLabel)) + ui.Dim(fmt.Sprintf(" Owner Address: %s", o.WorkflowOwnerAddress)) + ui.Dim(fmt.Sprintf(" Status: %s", o.VerificationStatus)) + ui.Dim(fmt.Sprintf(" Verified At: %s", o.VerifiedAt)) + ui.Dim(fmt.Sprintf(" Chain Selector: %s", o.ChainSelector)) + ui.Dim(fmt.Sprintf(" Contract Address: %s", o.ContractAddress)) + ui.Line() } } diff --git a/cmd/account/unlink_key/unlink_key.go b/cmd/account/unlink_key/unlink_key.go index 4a648097..964ad596 100644 --- a/cmd/account/unlink_key/unlink_key.go +++ b/cmd/account/unlink_key/unlink_key.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "github.com/charmbracelet/huh" "github.com/ethereum/go-ethereum/common" "github.com/google/uuid" "github.com/machinebox/graphql" @@ -24,10 +25,10 @@ import ( "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/environments" - "github.com/smartcontractkit/cre-cli/internal/prompt" "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/types" + "github.com/smartcontractkit/cre-cli/internal/ui" "github.com/smartcontractkit/cre-cli/internal/validation" ) @@ -142,7 +143,7 @@ func (h *handler) Execute(in Inputs) error { h.displayDetails() - fmt.Printf("Starting unlinking: owner=%s\n", in.WorkflowOwner) + ui.Dim(fmt.Sprintf("Starting unlinking: owner=%s", in.WorkflowOwner)) h.wg.Wait() if h.wrcErr != nil { @@ -154,20 +155,26 @@ func (h *handler) Execute(in Inputs) error { return err } if !linked { - fmt.Println("Your web3 address is not linked, nothing to do") + ui.Warning("Your web3 address is not linked, nothing to do") return nil } // Check if confirmation should be skipped if !in.SkipConfirmation { - deleteWorkflows, err := prompt.YesNoPrompt( - h.stdin, - "! Warning: Unlink is a destructive action that will wipe out all workflows registered under your owner address. Do you wish to proceed?", - ) - if err != nil { + ui.Warning("Unlink is a destructive action that will wipe out all workflows registered under your owner address.") + ui.Line() + var confirm bool + confirmForm := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Do you wish to proceed?"). + Value(&confirm), + ), + ).WithTheme(ui.ChainlinkTheme()) + if err := confirmForm.Run(); err != nil { return err } - if !deleteWorkflows { + if !confirm { return fmt.Errorf("unlinking aborted by user") } } @@ -186,7 +193,7 @@ func (h *handler) Execute(in Inputs) error { h.log.Debug().Msg("\nRaw linking response payload:\n\n" + string(prettyResp)) if in.WorkflowRegistryContractAddress == resp.ContractAddress { - fmt.Println("Contract address validation passed") + ui.Success("Contract address validation passed") } else { return fmt.Errorf("contract address validation failed") } @@ -256,12 +263,15 @@ func (h *handler) unlinkOwner(owner string, resp initiateUnlinkingResponse) erro switch txOut.Type { case client.Regular: - fmt.Println("Transaction confirmed") - fmt.Printf("View on explorer: \033]8;;%s/tx/%s\033\\%s/tx/%s\033]8;;\033\\\n", h.environmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash, h.environmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash) - fmt.Println("\n[OK] web3 address unlinked from your CRE organization successfully") - fmt.Println("\nNote: Unlinking verification may take up to 60 seconds.") - fmt.Println(" You must wait for verification to complete before linking this address again.") - fmt.Println("\n→ This address can no longer deploy workflows on behalf of your organization") + ui.Success("Transaction confirmed") + ui.URL(fmt.Sprintf("%s/tx/%s", h.environmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash)) + ui.Line() + ui.Success("web3 address unlinked from your CRE organization successfully") + ui.Line() + ui.Dim("Note: Unlinking verification may take up to 60 seconds.") + ui.Dim(" You must wait for verification to complete before linking this address again.") + ui.Line() + ui.Bold("This address can no longer deploy workflows on behalf of your organization") case client.Raw: selector, err := strconv.ParseUint(resp.ChainSelector, 10, 64) @@ -275,20 +285,19 @@ func (h *handler) unlinkOwner(owner string, resp initiateUnlinkingResponse) erro return err } - fmt.Println("") - fmt.Println("Ownership unlinking initialized successfully!") - fmt.Println("") - fmt.Println("Next steps:") - fmt.Println("") - fmt.Println(" 1. Submit the following transaction on the target chain:") - fmt.Println("") - fmt.Printf(" Chain: %s\n", ChainName) - fmt.Printf(" Contract Address: %s\n", resp.ContractAddress) - fmt.Println("") - fmt.Println(" 2. Use the following transaction data:") - fmt.Println("") - fmt.Printf(" %s\n", resp.TransactionData) - fmt.Println("") + ui.Line() + ui.Success("Ownership unlinking initialized successfully!") + ui.Line() + ui.Bold("Next steps:") + ui.Line() + ui.Print(" 1. Submit the following transaction on the target chain:") + ui.Dim(fmt.Sprintf(" Chain: %s", ChainName)) + ui.Dim(fmt.Sprintf(" Contract Address: %s", resp.ContractAddress)) + ui.Line() + ui.Print(" 2. Use the following transaction data:") + ui.Line() + ui.Code(fmt.Sprintf(" %s", resp.TransactionData)) + ui.Line() case client.Changeset: chainSelector, err := settings.GetChainSelectorByChainName(h.environmentSet.WorkflowRegistryChainName) @@ -297,7 +306,7 @@ func (h *handler) unlinkOwner(owner string, resp initiateUnlinkingResponse) erro } mcmsConfig, err := settings.GetMCMSConfig(h.settings, chainSelector) if err != nil { - fmt.Println("\nMCMS config not found or is incorrect, skipping MCMS config in changeset") + ui.Warning("MCMS config not found or is incorrect, skipping MCMS config in changeset") } cldSettings := h.settings.CLDSettings changesets := []types.Changeset{ @@ -328,7 +337,7 @@ func (h *handler) unlinkOwner(owner string, resp initiateUnlinkingResponse) erro h.log.Warn().Msgf("Unsupported transaction type: %s", txOut.Type) } - fmt.Println("Unlinked successfully") + ui.Success("Unlinked successfully") return nil } @@ -344,7 +353,9 @@ func (h *handler) checkIfAlreadyLinked() (bool, error) { } func (h *handler) displayDetails() { - fmt.Println("Unlinking web3 key from your CRE organization") - fmt.Printf("Target : \t\t %s\n", h.settings.User.TargetName) - fmt.Printf("✔ Using Address : \t %s\n\n", h.settings.Workflow.UserWorkflowSettings.WorkflowOwnerAddress) + ui.Line() + ui.Title("Unlinking web3 key from your CRE organization") + ui.Dim(fmt.Sprintf("Target: %s", h.settings.User.TargetName)) + ui.Dim(fmt.Sprintf("Owner Address: %s", h.settings.Workflow.UserWorkflowSettings.WorkflowOwnerAddress)) + ui.Line() } diff --git a/cmd/client/tx.go b/cmd/client/tx.go index 8aab2b3b..8bfc7973 100644 --- a/cmd/client/tx.go +++ b/cmd/client/tx.go @@ -5,10 +5,10 @@ import ( "errors" "fmt" "math/big" - "os" "strconv" "strings" + "github.com/charmbracelet/huh" "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -21,7 +21,7 @@ import ( cmdCommon "github.com/smartcontractkit/cre-cli/cmd/common" "github.com/smartcontractkit/cre-cli/internal/constants" - "github.com/smartcontractkit/cre-cli/internal/prompt" + "github.com/smartcontractkit/cre-cli/internal/ui" ) //go:generate stringer -type=TxType @@ -144,15 +144,20 @@ func (c *TxClient) executeTransactionByTxType(txFn func(opts *bind.TransactOpts) c.Logger.Warn().Err(gasErr).Msg("Failed to estimate gas usage") } - fmt.Println("Transaction details:") - fmt.Printf(" Chain Name:\t%s\n", chainDetails.ChainName) - fmt.Printf(" To:\t\t%s\n", simulateTx.To().Hex()) - fmt.Printf(" Function:\t%s\n", funName) - fmt.Printf(" Inputs:\n") + ui.Line() + ui.Title("Transaction details:") + ui.Printf(" Chain: %s\n", ui.RenderBold(chainDetails.ChainName)) + ui.Printf(" To: %s\n", ui.RenderCode(simulateTx.To().Hex())) + ui.Printf(" Function: %s\n", ui.RenderBold(funName)) + ui.Print(" Inputs:") for i, arg := range cmdCommon.ToStringSlice(args) { - fmt.Printf(" [%d]:\t%s\n", i, arg) + ui.Printf(" [%d]: %s\n", i, arg) } - fmt.Printf(" Data:\t\t%x\n", simulateTx.Data()) + ui.Line() + ui.Print(" Data (for verification):") + ui.Code(fmt.Sprintf("%x", simulateTx.Data())) + ui.Line() + // Calculate and print total cost for sending the transaction on-chain if gasErr == nil { gasPriceWei, gasPriceErr := c.EthClient.Client.SuggestGasPrice(c.EthClient.Context) @@ -164,16 +169,24 @@ func (c *TxClient) executeTransactionByTxType(txFn func(opts *bind.TransactOpts) // Convert from wei to ether for display etherValue := new(big.Float).Quo(new(big.Float).SetInt(totalCost), big.NewFloat(1e18)) - fmt.Println("Estimated Cost:") - fmt.Printf(" Gas Price: %s gwei\n", gasPriceGwei.Text('f', 8)) - fmt.Printf(" Total Cost: %s ETH\n", etherValue.Text('f', 8)) + ui.Title("Estimated Cost:") + ui.Printf(" Gas Price: %s gwei\n", gasPriceGwei.Text('f', 8)) + ui.Printf(" Total Cost: %s\n", ui.RenderBold(etherValue.Text('f', 8)+" ETH")) } } + ui.Line() // Ask for user confirmation before executing the transaction if !c.config.SkipPrompt { - confirm, err := prompt.YesNoPrompt(os.Stdin, "Do you want to execute this transaction?") - if err != nil { + var confirm bool + confirmForm := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Do you want to execute this transaction?"). + Value(&confirm), + ), + ).WithTheme(ui.ChainlinkTheme()) + if err := confirmForm.Run(); err != nil { return TxOutput{}, err } if !confirm { @@ -181,16 +194,23 @@ func (c *TxClient) executeTransactionByTxType(txFn func(opts *bind.TransactOpts) } } + spinner := ui.NewSpinner() + spinner.Start("Submitting transaction...") + decodedTx, err := c.EthClient.Decode(txFn(c.EthClient.NewTXOpts())) if err != nil { + spinner.Stop() return TxOutput{Type: Regular}, err } c.Logger.Debug().Interface("tx", decodedTx.Transaction).Str("TxHash", decodedTx.Transaction.Hash().Hex()).Msg("Transaction mined successfully") + spinner.Update("Validating transaction...") err = c.validateReceiptAndEvent(decodedTx.Transaction.To().Hex(), decodedTx, funName, strings.Split(validationEvent, "|")) if err != nil { + spinner.Stop() return TxOutput{Type: Regular}, err } + spinner.Stop() return TxOutput{ Type: Regular, Hash: decodedTx.Transaction.Hash(), @@ -202,8 +222,8 @@ func (c *TxClient) executeTransactionByTxType(txFn func(opts *bind.TransactOpts) }, }, nil case Raw: - fmt.Println("--unsigned flag detected: transaction not sent on-chain.") - fmt.Println("Generating call data for offline signing and submission in your preferred tool:") + ui.Warning("--unsigned flag detected: transaction not sent on-chain.") + ui.Dim("Generating call data for offline signing and submission in your preferred tool:") tx, err := txFn(cmdCommon.SimTransactOpts()) if err != nil { return TxOutput{Type: Raw}, err diff --git a/cmd/common/utils.go b/cmd/common/utils.go index 98805798..9ccf1572 100644 --- a/cmd/common/utils.go +++ b/cmd/common/utils.go @@ -1,9 +1,7 @@ package common import ( - "bufio" "encoding/json" - "errors" "fmt" "os" "os/exec" @@ -25,6 +23,7 @@ import ( "github.com/smartcontractkit/cre-cli/internal/logger" "github.com/smartcontractkit/cre-cli/internal/settings" inttypes "github.com/smartcontractkit/cre-cli/internal/types" + "github.com/smartcontractkit/cre-cli/internal/ui" ) func ValidateEventSignature(l *zerolog.Logger, tx *seth.DecodedTransaction, e abi.Event) (bool, int) { @@ -75,27 +74,6 @@ func GetDirectoryName() (string, error) { return filepath.Base(wd), nil } -func MustGetUserInputWithPrompt(l *zerolog.Logger, prompt string) (string, error) { - reader := bufio.NewReader(os.Stdin) - l.Info().Msg(prompt) - var input string - - for attempt := 0; attempt < 5; attempt++ { - var err error - input, err = reader.ReadString('\n') - if err != nil { - l.Info().Msg("✋ Failed to read user input, please try again.") - } - if input != "\n" { - return strings.TrimRight(input, "\n"), nil - } - l.Info().Msg("✋ Invalid input, please try again") - } - - l.Info().Msg("✋ Maximum number of attempts reached, aborting") - return "", errors.New("maximum attempts reached") -} - func AddTimeStampToFileName(fileName string) string { ext := filepath.Ext(fileName) name := strings.TrimSuffix(fileName, ext) @@ -255,9 +233,9 @@ func WriteChangesetFile(fileName string, changesetFile *inttypes.ChangesetFile, return fmt.Errorf("failed to write changeset yaml file: %w", err) } - fmt.Println("") - fmt.Println("Changeset YAML file generated!") - fmt.Printf("File: %s\n", fullFilePath) - fmt.Println("") + ui.Line() + ui.Success("Changeset YAML file generated!") + ui.Code(fullFilePath) + ui.Line() return nil } diff --git a/cmd/creinit/creinit.go b/cmd/creinit/creinit.go index ea8d3480..0031ddf4 100644 --- a/cmd/creinit/creinit.go +++ b/cmd/creinit/creinit.go @@ -4,24 +4,30 @@ import ( "embed" "errors" "fmt" - "io" "io/fs" "os" "path/filepath" "strings" + "github.com/charmbracelet/huh" "github.com/rs/zerolog" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/smartcontractkit/cre-cli/cmd/client" "github.com/smartcontractkit/cre-cli/internal/constants" - "github.com/smartcontractkit/cre-cli/internal/prompt" "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/internal/ui" "github.com/smartcontractkit/cre-cli/internal/validation" ) +// chainlinkTheme for all Huh forms in this package +var chainlinkTheme = ui.ChainlinkTheme() + +// chainlinkKeyMap for Tab autocomplete +var chainlinkKeyMap = ui.ChainlinkKeyMap() + //go:embed template/workflow/**/* var workflowTemplatesContent embed.FS @@ -45,7 +51,7 @@ type WorkflowTemplate struct { Title string ID uint32 Name string - Hidden bool // If true, this template will be hidden from the user selection prompt + Hidden bool } type LanguageTemplate struct { @@ -95,7 +101,7 @@ This sets up the project structure, configuration, and starter files so you can build, test, and deploy workflows quickly.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - handler := newHandler(runtimeContext, cmd.InOrStdin()) + handler := newHandler(runtimeContext) inputs, err := handler.ResolveInputs(runtimeContext.Viper) if err != nil { @@ -120,16 +126,14 @@ build, test, and deploy workflows quickly.`, type handler struct { log *zerolog.Logger clientFactory client.Factory - stdin io.Reader runtimeContext *runtime.Context validated bool } -func newHandler(ctx *runtime.Context, stdin io.Reader) *handler { +func newHandler(ctx *runtime.Context) *handler { return &handler{ log: ctx.Logger, clientFactory: ctx.ClientFactory, - stdin: stdin, runtimeContext: ctx, validated: false, } @@ -163,6 +167,9 @@ func (h *handler) Execute(inputs Inputs) error { return fmt.Errorf("handler inputs not validated") } + ui.Line() + ui.Title("Create a new CRE project") + cwd, err := os.Getwd() if err != nil { return fmt.Errorf("unable to get working directory: %w", err) @@ -190,23 +197,36 @@ func (h *handler) Execute(inputs Inputs) error { if err != nil { projName := inputs.ProjectName if projName == "" { - if err := prompt.SimplePrompt(h.stdin, fmt.Sprintf("Project name? [%s]", constants.DefaultProjectName), func(in string) error { - trimmed := strings.TrimSpace(in) - if trimmed == "" { - trimmed = constants.DefaultProjectName - fmt.Printf("Using default project name: %s\n", trimmed) - } - if err := validation.IsValidProjectName(trimmed); err != nil { - return err - } - projName = filepath.Join(trimmed, "/") - return nil - }); err != nil { - return err + defaultName := constants.DefaultProjectName + + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Project name"). + Description("Name for your new CRE project"). + Placeholder(defaultName). + Suggestions([]string{defaultName}). + Value(&projName). + Validate(func(s string) error { + name := s + if name == "" { + name = defaultName + } + return validation.IsValidProjectName(name) + }), + ), + ).WithTheme(chainlinkTheme).WithKeyMap(chainlinkKeyMap) + + if err := form.Run(); err != nil { + return fmt.Errorf("project name input cancelled: %w", err) + } + + if projName == "" { + projName = defaultName } } - projectRoot = filepath.Join(startDir, projName) + projectRoot = filepath.Join(startDir, projName, "/") if err := h.ensureProjectDirectoryExists(projectRoot); err != nil { return err } @@ -215,7 +235,7 @@ func (h *handler) Execute(inputs Inputs) error { if err == nil { envPath := filepath.Join(projectRoot, constants.DefaultEnvFileName) if !h.pathExists(envPath) { - if _, err := settings.GenerateProjectEnvFile(projectRoot, h.stdin); err != nil { + if _, err := settings.GenerateProjectEnvFile(projectRoot); err != nil { return err } } @@ -224,6 +244,7 @@ func (h *handler) Execute(inputs Inputs) error { var selectedWorkflowTemplate WorkflowTemplate var selectedLanguageTemplate LanguageTemplate var workflowTemplates []WorkflowTemplate + if inputs.TemplateID != 0 { var findErr error selectedWorkflowTemplate, selectedLanguageTemplate, findErr = h.getWorkflowTemplateByID(inputs.TemplateID) @@ -242,25 +263,69 @@ func (h *handler) Execute(inputs Inputs) error { } if len(workflowTemplates) < 1 { - languageTitles := h.extractLanguageTitles(languageTemplates) - if err := prompt.SelectPrompt(h.stdin, "What language do you want to use?", languageTitles, func(choice string) error { - selected, selErr := h.getLanguageTemplateByTitle(choice) - selectedLanguageTemplate = selected - workflowTemplates = selectedLanguageTemplate.Workflows - return selErr - }); err != nil { + languageOptions := make([]huh.Option[string], len(languageTemplates)) + for i, lang := range languageTemplates { + languageOptions[i] = huh.NewOption(lang.Title, lang.Title) + } + + var selectedLang string + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("What language do you want to use?"). + Options(languageOptions...). + Value(&selectedLang), + ), + ).WithTheme(chainlinkTheme) + + if err := form.Run(); err != nil { return fmt.Errorf("language selection aborted: %w", err) } + + selected, selErr := h.getLanguageTemplateByTitle(selectedLang) + if selErr != nil { + return selErr + } + selectedLanguageTemplate = selected + workflowTemplates = selectedLanguageTemplate.Workflows } - workflowTitles := h.extractWorkflowTitles(workflowTemplates) - if err := prompt.SelectPrompt(h.stdin, "Pick a workflow template", workflowTitles, func(choice string) error { - selected, selErr := h.getWorkflowTemplateByTitle(choice, workflowTemplates) - selectedWorkflowTemplate = selected - return selErr - }); err != nil { + visibleTemplates := make([]WorkflowTemplate, 0, len(workflowTemplates)) + for _, t := range workflowTemplates { + if !t.Hidden { + visibleTemplates = append(visibleTemplates, t) + } + } + + templateOptions := make([]huh.Option[string], len(visibleTemplates)) + for i, tpl := range visibleTemplates { + parts := strings.SplitN(tpl.Title, ": ", 2) + label := tpl.Title + if len(parts) == 2 { + label = parts[0] + } + templateOptions[i] = huh.NewOption(label, tpl.Title) + } + + var selectedTemplate string + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Pick a workflow template"). + Options(templateOptions...). + Value(&selectedTemplate), + ), + ).WithTheme(chainlinkTheme) + + if err := form.Run(); err != nil { return fmt.Errorf("template selection aborted: %w", err) } + + selected, selErr := h.getWorkflowTemplateByTitle(selectedTemplate, workflowTemplates) + if selErr != nil { + return selErr + } + selectedWorkflowTemplate = selected } if err != nil { @@ -270,15 +335,25 @@ func (h *handler) Execute(inputs Inputs) error { if strings.TrimSpace(inputs.RPCUrl) != "" { rpcURL = strings.TrimSpace(inputs.RPCUrl) } else { - if e := prompt.SimplePrompt(h.stdin, fmt.Sprintf("Sepolia RPC URL? [%s]", constants.DefaultEthSepoliaRpcUrl), func(in string) error { - trimmed := strings.TrimSpace(in) - if trimmed == "" { - trimmed = constants.DefaultEthSepoliaRpcUrl - } - rpcURL = trimmed - return nil - }); e != nil { - return e + defaultRPC := constants.DefaultEthSepoliaRpcUrl + + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Sepolia RPC URL"). + Description("RPC endpoint for Ethereum Sepolia testnet"). + Placeholder(defaultRPC). + Suggestions([]string{defaultRPC}). + Value(&rpcURL), + ), + ).WithTheme(chainlinkTheme).WithKeyMap(chainlinkKeyMap) + + if err := form.Run(); err != nil { + return err + } + + if rpcURL == "" { + rpcURL = defaultRPC } } repl["EthSepoliaRpcUrl"] = rpcURL @@ -287,42 +362,43 @@ func (h *handler) Execute(inputs Inputs) error { return e } if selectedWorkflowTemplate.Name == PoRTemplate { - fmt.Printf("RPC set to %s. You can change it later in ./%s.\n", + ui.Dim(fmt.Sprintf(" RPC set to %s (editable in %s)", rpcURL, - filepath.Join(filepath.Base(projectRoot), constants.DefaultProjectSettingsFileName)) + filepath.Join(filepath.Base(projectRoot), constants.DefaultProjectSettingsFileName))) } - if _, e := settings.GenerateProjectEnvFile(projectRoot, h.stdin); e != nil { + if _, e := settings.GenerateProjectEnvFile(projectRoot); e != nil { return e } } workflowName := strings.TrimSpace(inputs.WorkflowName) if workflowName == "" { - const maxAttempts = 3 - for attempts := 1; attempts <= maxAttempts; attempts++ { - inputErr := prompt.SimplePrompt(h.stdin, fmt.Sprintf("Workflow name? [%s]", constants.DefaultWorkflowName), func(in string) error { - trimmed := strings.TrimSpace(in) - if trimmed == "" { - trimmed = constants.DefaultWorkflowName - fmt.Printf("Using default workflow name: %s\n", trimmed) - } - if err := validation.IsValidWorkflowName(trimmed); err != nil { - return err - } - workflowName = trimmed - return nil - }) + defaultName := constants.DefaultWorkflowName - if inputErr == nil { - break - } + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Workflow name"). + Description("Name for your workflow"). + Placeholder(defaultName). + Suggestions([]string{defaultName}). + Value(&workflowName). + Validate(func(s string) error { + name := s + if name == "" { + name = defaultName + } + return validation.IsValidWorkflowName(name) + }), + ), + ).WithTheme(chainlinkTheme).WithKeyMap(chainlinkKeyMap) - fmt.Fprintf(os.Stderr, "Error: %v\n", inputErr) + if err := form.Run(); err != nil { + return fmt.Errorf("workflow name input cancelled: %w", err) + } - if attempts == maxAttempts { - fmt.Fprintln(os.Stderr, "Too many failed attempts. Aborting.") - os.Exit(1) - } + if workflowName == "" { + workflowName = defaultName } } @@ -332,33 +408,60 @@ func (h *handler) Execute(inputs Inputs) error { return err } + projectName := filepath.Base(projectRoot) + spinner := ui.NewSpinner() + + // Copy secrets file + spinner.Start("Copying secrets file...") if err := h.copySecretsFileIfExists(projectRoot, selectedWorkflowTemplate); err != nil { + spinner.Stop() return fmt.Errorf("failed to copy secrets file: %w", err) } - // Get project name from project root - projectName := filepath.Base(projectRoot) - + // Generate workflow template + spinner.Update("Generating workflow files...") if err := h.generateWorkflowTemplate(workflowDirectory, selectedWorkflowTemplate, projectName); err != nil { + spinner.Stop() return fmt.Errorf("failed to scaffold workflow: %w", err) } - // Generate contracts at project level if template has contracts + // Generate contracts template + spinner.Update("Generating contracts...") if err := h.generateContractsTemplate(projectRoot, selectedWorkflowTemplate, projectName); err != nil { + spinner.Stop() return fmt.Errorf("failed to scaffold contracts: %w", err) } + // Initialize Go module if needed + var installedDeps *InstalledDependencies if selectedLanguageTemplate.Lang == TemplateLangGo { - if err := initializeGoModule(h.log, projectRoot, projectName); err != nil { - return fmt.Errorf("failed to initialize Go module: %w", err) + spinner.Update("Installing Go dependencies...") + var goErr error + installedDeps, goErr = initializeGoModule(h.log, projectRoot, projectName) + if goErr != nil { + spinner.Stop() + return fmt.Errorf("failed to initialize Go module: %w", goErr) } } + // Generate workflow settings + spinner.Update("Generating workflow settings...") _, err = settings.GenerateWorkflowSettingsFile(workflowDirectory, workflowName, selectedLanguageTemplate.EntryPoint) + spinner.Stop() if err != nil { return fmt.Errorf("failed to generate %s file: %w", constants.DefaultWorkflowSettingsFileName, err) } + // Show installed dependencies in a box after spinner stops + if installedDeps != nil { + ui.Line() + depList := "Dependencies installed:" + for _, dep := range installedDeps.Deps { + depList += "\n • " + dep + } + ui.Box(depList) + } + if h.runtimeContext != nil { switch selectedLanguageTemplate.Lang { case TemplateLangGo: @@ -368,38 +471,51 @@ func (h *handler) Execute(inputs Inputs) error { } } - fmt.Println("\nWorkflow initialized successfully!") - fmt.Println("") - fmt.Println("Next steps:") + h.printSuccessMessage(projectRoot, workflowName, selectedLanguageTemplate.Lang) - if selectedLanguageTemplate.Lang == TemplateLangGo { - fmt.Println(" 1. Navigate to your project directory:") - fmt.Printf(" cd %s\n", filepath.Base(projectRoot)) - fmt.Println("") - fmt.Println(" 2. Run the workflow on your machine:") - fmt.Printf(" cre workflow simulate %s\n", workflowName) - fmt.Println("") - fmt.Printf(" 3. (Optional) Consult %s to learn more about this template:\n\n", - filepath.Join(filepath.Base(workflowDirectory), "README.md")) - fmt.Println("") + return nil +} + +func (h *handler) printSuccessMessage(projectRoot, workflowName string, lang TemplateLanguage) { + ui.Line() + ui.Success("Project created successfully!") + ui.Line() + + var steps string + if lang == TemplateLangGo { + steps = fmt.Sprintf(`%s + %s + +%s + %s`, + ui.RenderStep("1. Navigate to your project:"), + ui.RenderDim("cd "+filepath.Base(projectRoot)), + ui.RenderStep("2. Run the workflow:"), + ui.RenderDim("cre workflow simulate "+workflowName)) } else { - fmt.Println(" 1. Navigate to your project directory:") - fmt.Printf(" cd %s\n", filepath.Base(projectRoot)) - fmt.Println("") - fmt.Println(" 2. Make sure you have Bun installed:") - fmt.Println(" npm install -g bun") - fmt.Println("") - fmt.Println(" 3. Install workflow dependencies:") - fmt.Printf(" bun install --cwd ./%s\n", filepath.Base(workflowDirectory)) - fmt.Println("") - fmt.Println(" 4. Run the workflow on your machine:") - fmt.Printf(" cre workflow simulate %s\n", workflowName) - fmt.Println("") - fmt.Printf(" 5. (Optional) Consult %s to learn more about this template:\n\n", - filepath.Join(filepath.Base(workflowDirectory), "README.md")) - fmt.Println("") + steps = fmt.Sprintf(`%s + %s + +%s + %s + +%s + %s + +%s + %s`, + ui.RenderStep("1. Navigate to your project:"), + ui.RenderDim("cd "+filepath.Base(projectRoot)), + ui.RenderStep("2. Install Bun (if needed):"), + ui.RenderDim("npm install -g bun"), + ui.RenderStep("3. Install dependencies:"), + ui.RenderDim("bun install --cwd ./"+workflowName), + ui.RenderStep("4. Run the workflow:"), + ui.RenderDim("cre workflow simulate "+workflowName)) } - return nil + + ui.Box("Next steps\n\n" + steps) + ui.Line() } type TitledTemplate interface { @@ -414,28 +530,6 @@ func (l LanguageTemplate) GetTitle() string { return l.Title } -func extractTitles[T TitledTemplate](templates []T) []string { - titles := make([]string, len(templates)) - for i, template := range templates { - titles[i] = template.GetTitle() - } - return titles -} - -func (h *handler) extractLanguageTitles(templates []LanguageTemplate) []string { - return extractTitles(templates) -} - -func (h *handler) extractWorkflowTitles(templates []WorkflowTemplate) []string { - visibleTemplates := make([]WorkflowTemplate, 0, len(templates)) - for _, t := range templates { - if !t.Hidden { - visibleTemplates = append(visibleTemplates, t) - } - } - return extractTitles(visibleTemplates) -} - func (h *handler) getLanguageTemplateByTitle(title string) (LanguageTemplate, error) { for _, lang := range languageTemplates { if lang.Title == title { @@ -455,25 +549,20 @@ func (h *handler) getWorkflowTemplateByTitle(title string, workflowTemplates []W return WorkflowTemplate{}, errors.New("template not found") } -// Copy the content of the secrets file (if exists for this workflow template) to the project root func (h *handler) copySecretsFileIfExists(projectRoot string, template WorkflowTemplate) error { - // When referencing embedded template files, the path is relative and separated by forward slashes sourceSecretsFilePath := "template/workflow/" + template.Folder + "/" + SecretsFileName destinationSecretsFilePath := filepath.Join(projectRoot, SecretsFileName) - // Ensure the secrets file exists in the template directory if _, err := fs.Stat(workflowTemplatesContent, sourceSecretsFilePath); err != nil { - fmt.Println("Secrets file doesn't exist for this template, skipping") + h.log.Debug().Msg("Secrets file doesn't exist for this template, skipping") return nil } - // Read the content of the secrets file from the template secretsFileContent, err := workflowTemplatesContent.ReadFile(sourceSecretsFilePath) if err != nil { return fmt.Errorf("failed to read secrets file: %w", err) } - // Write the file content to the target path if err := os.WriteFile(destinationSecretsFilePath, []byte(secretsFileContent), 0600); err != nil { return fmt.Errorf("failed to write file: %w", err) } @@ -483,75 +572,57 @@ func (h *handler) copySecretsFileIfExists(projectRoot string, template WorkflowT return nil } -// Copy the content of template/workflow/{{templateName}} and remove "tpl" extension func (h *handler) generateWorkflowTemplate(workingDirectory string, template WorkflowTemplate, projectName string) error { + h.log.Debug().Msgf("Generating template: %s", template.Title) - fmt.Printf("Generating template: %s\n", template.Title) - - // Construct the path to the specific template directory - // When referencing embedded template files, the path is relative and separated by forward slashes templatePath := "template/workflow/" + template.Folder - // Ensure the specified template directory exists if _, err := fs.Stat(workflowTemplatesContent, templatePath); err != nil { return fmt.Errorf("template directory doesn't exist: %w", err) } - // Walk through all files & folders under templatePath walkErr := fs.WalkDir(workflowTemplatesContent, templatePath, func(path string, d fs.DirEntry, err error) error { if err != nil { - return err // propagate I/O errors + return err } - // Compute the path of this entry relative to templatePath relPath, _ := filepath.Rel(templatePath, path) - // Skip the top-level directory itself if relPath == "." { return nil } - // Skip contracts directory - it will be handled separately if strings.HasPrefix(relPath, "contracts") { return nil } - // If it's a directory, just create the matching directory in the working dir if d.IsDir() { return os.MkdirAll(filepath.Join(workingDirectory, relPath), 0o755) } - // Skip the secrets file if it exists, this one is copied separately into the project root if strings.Contains(relPath, SecretsFileName) { return nil } - // Determine the target file path var targetPath string if strings.HasSuffix(relPath, ".tpl") { - // Remove `.tpl` extension for files with `.tpl` outputFileName := strings.TrimSuffix(relPath, ".tpl") targetPath = filepath.Join(workingDirectory, outputFileName) } else { - // Copy other files as-is targetPath = filepath.Join(workingDirectory, relPath) } - // Read the file content content, err := workflowTemplatesContent.ReadFile(path) if err != nil { return fmt.Errorf("failed to read file: %w", err) } - // Replace template variables with actual values finalContent := strings.ReplaceAll(string(content), "{{projectName}}", projectName) - // Ensure the target directory exists if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { return fmt.Errorf("failed to create directory for: %w", err) } - // Write the file content to the target path if err := os.WriteFile(targetPath, []byte(finalContent), 0600); err != nil { return fmt.Errorf("failed to write file: %w", err) } @@ -560,8 +631,6 @@ func (h *handler) generateWorkflowTemplate(workingDirectory string, template Wor return nil }) - fmt.Printf("Files created in %s directory\n", workingDirectory) - return walkErr } @@ -579,13 +648,22 @@ func (h *handler) getWorkflowTemplateByID(id uint32) (WorkflowTemplate, Language func (h *handler) ensureProjectDirectoryExists(dirPath string) error { if h.pathExists(dirPath) { - overwrite, err := prompt.YesNoPrompt( - h.stdin, - fmt.Sprintf("Directory %s already exists. Overwrite?", dirPath), - ) - if err != nil { + var overwrite bool + + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title(fmt.Sprintf("Directory %s already exists. Overwrite?", dirPath)). + Affirmative("Yes"). + Negative("No"). + Value(&overwrite), + ), + ).WithTheme(chainlinkTheme) + + if err := form.Run(); err != nil { return err } + if !overwrite { return fmt.Errorf("directory creation aborted by user") } @@ -600,71 +678,54 @@ func (h *handler) ensureProjectDirectoryExists(dirPath string) error { } func (h *handler) generateContractsTemplate(projectRoot string, template WorkflowTemplate, projectName string) error { - // Construct the path to the contracts directory in the template - // When referencing embedded template files, the path is relative and separated by forward slashes templateContractsPath := "template/workflow/" + template.Folder + "/contracts" - // Check if this template has contracts if _, err := fs.Stat(workflowTemplatesContent, templateContractsPath); err != nil { - // No contracts directory in this template, skip return nil } h.log.Debug().Msgf("Generating contracts for template: %s", template.Title) - // Create contracts directory at project level contractsDirectory := filepath.Join(projectRoot, "contracts") - // Walk through all files & folders under contracts template walkErr := fs.WalkDir(workflowTemplatesContent, templateContractsPath, func(path string, d fs.DirEntry, err error) error { if err != nil { - return err // propagate I/O errors + return err } - // Compute the path of this entry relative to templateContractsPath relPath, _ := filepath.Rel(templateContractsPath, path) - // Skip the top-level directory itself if relPath == "." { return nil } - // Skip keep.tpl file used to copy empty directory if d.Name() == "keep.tpl" { return nil } - // If it's a directory, just create the matching directory in the contracts dir if d.IsDir() { return os.MkdirAll(filepath.Join(contractsDirectory, relPath), 0o755) } - // Determine the target file path var targetPath string if strings.HasSuffix(relPath, ".tpl") { - // Remove `.tpl` extension for files with `.tpl` outputFileName := strings.TrimSuffix(relPath, ".tpl") targetPath = filepath.Join(contractsDirectory, outputFileName) } else { - // Copy other files as-is targetPath = filepath.Join(contractsDirectory, relPath) } - // Read the file content content, err := workflowTemplatesContent.ReadFile(path) if err != nil { return fmt.Errorf("failed to read file: %w", err) } - // Replace template variables with actual values finalContent := strings.ReplaceAll(string(content), "{{projectName}}", projectName) - // Ensure the target directory exists if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { return fmt.Errorf("failed to create directory for: %w", err) } - // Write the file content to the target path if err := os.WriteFile(targetPath, []byte(finalContent), 0600); err != nil { return fmt.Errorf("failed to write file: %w", err) } @@ -673,8 +734,6 @@ func (h *handler) generateContractsTemplate(projectRoot string, template Workflo return nil }) - fmt.Printf("Contracts generated under %s\n", templateContractsPath) - return walkErr } diff --git a/cmd/creinit/creinit_test.go b/cmd/creinit/creinit_test.go index b1303e28..f414b1b5 100644 --- a/cmd/creinit/creinit_test.go +++ b/cmd/creinit/creinit_test.go @@ -76,97 +76,83 @@ func requireNoDirExists(t *testing.T, dirPath string) { } func TestInitExecuteFlows(t *testing.T) { + // All inputs are provided via flags to avoid interactive prompts cases := []struct { name string projectNameFlag string templateIDFlag uint32 workflowNameFlag string rpcURLFlag string - mockResponses []string expectProjectDirRel string expectWorkflowName string expectTemplateFiles []string }{ { - name: "explicit project, default template via prompt, custom workflow via prompt", - projectNameFlag: "myproj", - templateIDFlag: 0, - workflowNameFlag: "", - rpcURLFlag: "", - // "" (language default -> Golang), "" (workflow default -> PoR), "" (RPC URL accept default), "myworkflow" - mockResponses: []string{"", "", "", "myworkflow"}, + name: "Go PoR template with all flags", + projectNameFlag: "myproj", + templateIDFlag: 1, // Golang PoR + workflowNameFlag: "myworkflow", + rpcURLFlag: "https://sepolia.example/rpc", expectProjectDirRel: "myproj", expectWorkflowName: "myworkflow", expectTemplateFiles: GetTemplateFileListGo(), }, { - name: "only project, default template+workflow via prompt", - projectNameFlag: "alpha", - templateIDFlag: 0, - workflowNameFlag: "", - rpcURLFlag: "", - // defaults to PoR -> include extra "" for RPC URL - mockResponses: []string{"", "", "", "default-wf"}, + name: "Go HelloWorld template with all flags", + projectNameFlag: "alpha", + templateIDFlag: 2, // Golang HelloWorld + workflowNameFlag: "default-wf", + rpcURLFlag: "", expectProjectDirRel: "alpha", expectWorkflowName: "default-wf", expectTemplateFiles: GetTemplateFileListGo(), }, { - name: "no flags: prompt project, blank template, prompt workflow", - projectNameFlag: "", - templateIDFlag: 0, - workflowNameFlag: "", - rpcURLFlag: "", - // "projX" (project), "1" (pick Golang), "2" (pick HelloWorld/blank), "workflow-X" (name) - // No RPC prompt here since PoR was NOT selected - mockResponses: []string{"projX", "1", "2", "", "workflow-X"}, + name: "Go HelloWorld with different project name", + projectNameFlag: "projX", + templateIDFlag: 2, // Golang HelloWorld + workflowNameFlag: "workflow-X", + rpcURLFlag: "", expectProjectDirRel: "projX", expectWorkflowName: "workflow-X", expectTemplateFiles: GetTemplateFileListGo(), }, { - name: "workflow-name flag only, default template, no workflow prompt", - projectNameFlag: "projFlag", - templateIDFlag: 0, - workflowNameFlag: "flagged-wf", - rpcURLFlag: "", - // defaults to PoR → include RPC URL accept - mockResponses: []string{"", "", ""}, + name: "Go PoR with workflow flag", + projectNameFlag: "projFlag", + templateIDFlag: 1, // Golang PoR + workflowNameFlag: "flagged-wf", + rpcURLFlag: "https://sepolia.example/rpc", expectProjectDirRel: "projFlag", expectWorkflowName: "flagged-wf", expectTemplateFiles: GetTemplateFileListGo(), }, { - name: "template-id flag only, no template prompt", + name: "Go HelloWorld template by ID", projectNameFlag: "tplProj", - templateIDFlag: 2, - workflowNameFlag: "", + templateIDFlag: 2, // Golang HelloWorld + workflowNameFlag: "workflow-Tpl", rpcURLFlag: "", - mockResponses: []string{"workflow-Tpl"}, expectProjectDirRel: "tplProj", expectWorkflowName: "workflow-Tpl", expectTemplateFiles: GetTemplateFileListGo(), }, { - name: "PoR template via flag with rpc-url provided (skips RPC prompt)", - projectNameFlag: "porWithFlag", - templateIDFlag: 1, // Golang PoR - workflowNameFlag: "", - rpcURLFlag: "https://sepolia.example/rpc", - // Only needs a workflow name prompt - mockResponses: []string{"por-wf-01"}, + name: "Go PoR template with rpc-url", + projectNameFlag: "porWithFlag", + templateIDFlag: 1, // Golang PoR + workflowNameFlag: "por-wf-01", + rpcURLFlag: "https://sepolia.example/rpc", expectProjectDirRel: "porWithFlag", expectWorkflowName: "por-wf-01", expectTemplateFiles: GetTemplateFileListGo(), }, { - name: "TS template with rpc-url provided (flag ignored; no RPC prompt needed)", - projectNameFlag: "tsWithRpcFlag", - templateIDFlag: 3, // TypeScript HelloWorld - workflowNameFlag: "", - rpcURLFlag: "https://sepolia.example/rpc", - // Just the workflow name prompt - mockResponses: []string{"ts-wf-flag"}, + name: "TS HelloWorld template with rpc-url (ignored)", + projectNameFlag: "tsWithRpcFlag", + templateIDFlag: 3, // TypeScript HelloWorld + workflowNameFlag: "ts-wf-flag", + rpcURLFlag: "https://sepolia.example/rpc", expectProjectDirRel: "tsWithRpcFlag", expectWorkflowName: "ts-wf-flag", expectTemplateFiles: GetTemplateFileListTS(), @@ -191,8 +177,7 @@ func TestInitExecuteFlows(t *testing.T) { } ctx := sim.NewRuntimeContext() - mockStdin := testutil.NewMockStdinReader(tc.mockResponses) - h := newHandler(ctx, mockStdin) + h := newHandler(ctx) require.NoError(t, h.ValidateInputs(inputs)) require.NoError(t, h.Execute(inputs)) @@ -223,12 +208,11 @@ func TestInsideExistingProjectAddsWorkflow(t *testing.T) { inputs := Inputs{ ProjectName: "", - TemplateID: 2, - WorkflowName: "", + TemplateID: 2, // Golang HelloWorld + WorkflowName: "wf-inside-existing-project", } - mockStdin := testutil.NewMockStdinReader([]string{"wf-inside-existing-project", ""}) - h := newHandler(sim.NewRuntimeContext(), mockStdin) + h := newHandler(sim.NewRuntimeContext()) require.NoError(t, h.ValidateInputs(inputs)) require.NoError(t, h.Execute(inputs)) @@ -256,12 +240,10 @@ func TestInitWithTypescriptTemplateSkipsGoScaffold(t *testing.T) { inputs := Inputs{ ProjectName: "tsProj", TemplateID: 3, // TypeScript template - WorkflowName: "", + WorkflowName: "ts-workflow-01", } - // Ensure workflow name meets 10-char minimum - mockStdin := testutil.NewMockStdinReader([]string{"ts-workflow-01"}) - h := newHandler(sim.NewRuntimeContext(), mockStdin) + h := newHandler(sim.NewRuntimeContext()) require.NoError(t, h.ValidateInputs(inputs)) require.NoError(t, h.Execute(inputs)) @@ -295,12 +277,11 @@ func TestInsideExistingProjectAddsTypescriptWorkflowSkipsGoScaffold(t *testing.T inputs := Inputs{ ProjectName: "", - TemplateID: 3, - WorkflowName: "", + TemplateID: 3, // TypeScript HelloWorld + WorkflowName: "ts-wf-existing", } - mockStdin := testutil.NewMockStdinReader([]string{"ts-wf-existing"}) - h := newHandler(sim.NewRuntimeContext(), mockStdin) + h := newHandler(sim.NewRuntimeContext()) require.NoError(t, h.ValidateInputs(inputs)) require.NoError(t, h.Execute(inputs)) diff --git a/cmd/creinit/go_module_init.go b/cmd/creinit/go_module_init.go index e198e696..c442a89c 100644 --- a/cmd/creinit/go_module_init.go +++ b/cmd/creinit/go_module_init.go @@ -2,11 +2,9 @@ package creinit import ( "errors" - "fmt" "os" "os/exec" "path/filepath" - "strings" "github.com/rs/zerolog" ) @@ -18,47 +16,46 @@ const ( CronCapabilitiesVersion = "v1.0.0-beta.0" ) -func initializeGoModule(logger *zerolog.Logger, workingDirectory, moduleName string) error { - var deps []string +// InstalledDependencies contains info about installed Go dependencies +type InstalledDependencies struct { + ModuleName string + Deps []string +} - if shouldInitGoProject(workingDirectory) { - err := runCommand(logger, workingDirectory, "go", "mod", "init", moduleName) - if err != nil { - return err - } - fmt.Printf("→ Module initialized: %s\n", moduleName) +func initializeGoModule(logger *zerolog.Logger, workingDirectory, moduleName string) (*InstalledDependencies, error) { + result := &InstalledDependencies{ + ModuleName: moduleName, + Deps: []string{ + "cre-sdk-go@" + SdkVersion, + "capabilities/blockchain/evm@" + EVMCapabilitiesVersion, + "capabilities/networking/http@" + HTTPCapabilitiesVersion, + "capabilities/scheduler/cron@" + CronCapabilitiesVersion, + }, } - captureDep := func(args ...string) error { - output, err := runCommandCaptureOutput(logger, workingDirectory, args...) + if shouldInitGoProject(workingDirectory) { + err := runCommand(logger, workingDirectory, "go", "mod", "init", moduleName) if err != nil { - return err + return nil, err } - deps = append(deps, parseAddedModules(string(output))...) - return nil } - if err := captureDep("go", "get", "github.com/smartcontractkit/cre-sdk-go@"+SdkVersion); err != nil { - return err + if err := runCommand(logger, workingDirectory, "go", "get", "github.com/smartcontractkit/cre-sdk-go@"+SdkVersion); err != nil { + return nil, err } - if err := captureDep("go", "get", "github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/evm@"+EVMCapabilitiesVersion); err != nil { - return err + if err := runCommand(logger, workingDirectory, "go", "get", "github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/evm@"+EVMCapabilitiesVersion); err != nil { + return nil, err } - if err := captureDep("go", "get", "github.com/smartcontractkit/cre-sdk-go/capabilities/networking/http@"+HTTPCapabilitiesVersion); err != nil { - return err + if err := runCommand(logger, workingDirectory, "go", "get", "github.com/smartcontractkit/cre-sdk-go/capabilities/networking/http@"+HTTPCapabilitiesVersion); err != nil { + return nil, err } - if err := captureDep("go", "get", "github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron@"+CronCapabilitiesVersion); err != nil { - return err + if err := runCommand(logger, workingDirectory, "go", "get", "github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron@"+CronCapabilitiesVersion); err != nil { + return nil, err } _ = runCommand(logger, workingDirectory, "go", "mod", "tidy") - fmt.Printf("→ Dependencies installed: \n") - for _, dep := range deps { - fmt.Printf("\t•\t%s\n", dep) - } - - return nil + return result, nil } func shouldInitGoProject(directory string) bool { @@ -85,32 +82,3 @@ func runCommand(logger *zerolog.Logger, dir, command string, args ...string) err logger.Debug().Msgf("Command succeeded: %s %v", command, args) return nil } - -func runCommandCaptureOutput(logger *zerolog.Logger, dir string, args ...string) ([]byte, error) { - logger.Debug().Msgf("Running command: %v in directory: %s", args, dir) - - // #nosec G204 -- args are internal and validated - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = dir - - output, err := cmd.CombinedOutput() - if err != nil { - logger.Error().Err(err).Msgf("Command failed: %v\nOutput:\n%s", args, output) - return output, err - } - - logger.Debug().Msgf("Command succeeded: %v", args) - return output, nil -} - -func parseAddedModules(output string) []string { - var modules []string - lines := strings.Split(output, "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "go: added ") { - modules = append(modules, strings.TrimPrefix(line, "go: added ")) - } - } - return modules -} diff --git a/cmd/creinit/go_module_init_test.go b/cmd/creinit/go_module_init_test.go index 260ce437..00fa9bbd 100644 --- a/cmd/creinit/go_module_init_test.go +++ b/cmd/creinit/go_module_init_test.go @@ -40,7 +40,7 @@ func TestInitializeGoModule_InEmptyProject(t *testing.T) { tempDir := prepareTempDirWithMainFile(t) moduleName := "testmodule" - err := initializeGoModule(logger, tempDir, moduleName) + _, err := initializeGoModule(logger, tempDir, moduleName) assert.NoError(t, err) // Check go.mod file was generated @@ -70,7 +70,7 @@ func TestInitializeGoModule_InExistingProject(t *testing.T) { goModFilePath := createGoModFile(t, tempDir, "module oldmodule") - err := initializeGoModule(logger, tempDir, moduleName) + _, err := initializeGoModule(logger, tempDir, moduleName) assert.NoError(t, err) // Check go.mod file was not changed @@ -103,7 +103,7 @@ func TestInitializeGoModule_GoModInitFails(t *testing.T) { assert.NoError(t, err) // Attempt to initialize Go module - err = initializeGoModule(logger, tempDir, moduleName) + _, err = initializeGoModule(logger, tempDir, moduleName) assert.Error(t, err) assert.Contains(t, err.Error(), "exit status 1") diff --git a/cmd/generate-bindings/generate-bindings.go b/cmd/generate-bindings/generate-bindings.go index 7da55c94..47b6fcab 100644 --- a/cmd/generate-bindings/generate-bindings.go +++ b/cmd/generate-bindings/generate-bindings.go @@ -13,6 +13,7 @@ import ( "github.com/smartcontractkit/cre-cli/cmd/creinit" "github.com/smartcontractkit/cre-cli/cmd/generate-bindings/bindings" "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/ui" "github.com/smartcontractkit/cre-cli/internal/validation" ) @@ -229,7 +230,7 @@ func (h *handler) processAbiDirectory(inputs Inputs) error { // Create output file path in contract-specific directory outputFile := filepath.Join(contractOutDir, contractName+".go") - fmt.Printf("Processing ABI file: %s, contract: %s, package: %s, output: %s\n", abiFile, contractName, packageName, outputFile) + ui.Dim(fmt.Sprintf("Processing: %s -> %s", contractName, outputFile)) err = bindings.GenerateBindings( "", // combinedJSONPath - empty for now @@ -265,7 +266,7 @@ func (h *handler) processSingleAbi(inputs Inputs) error { // Create output file path in contract-specific directory outputFile := filepath.Join(contractOutDir, contractName+".go") - fmt.Printf("Processing single ABI file: %s, contract: %s, package: %s, output: %s\n", inputs.AbiPath, contractName, packageName, outputFile) + ui.Dim(fmt.Sprintf("Processing: %s -> %s", contractName, outputFile)) return bindings.GenerateBindings( "", // combinedJSONPath - empty for now @@ -277,7 +278,7 @@ func (h *handler) processSingleAbi(inputs Inputs) error { } func (h *handler) Execute(inputs Inputs) error { - fmt.Printf("GenerateBindings would be called here: projectRoot=%s, chainFamily=%s, language=%s, abiPath=%s, pkgName=%s, outPath=%s\n", inputs.ProjectRoot, inputs.ChainFamily, inputs.Language, inputs.AbiPath, inputs.PkgName, inputs.OutPath) + ui.Dim(fmt.Sprintf("Project: %s, Chain: %s, Language: %s", inputs.ProjectRoot, inputs.ChainFamily, inputs.Language)) // Validate language switch inputs.Language { @@ -311,17 +312,26 @@ func (h *handler) Execute(inputs Inputs) error { } } + spinner := ui.NewSpinner() + spinner.Start("Installing dependencies...") + err = runCommand(inputs.ProjectRoot, "go", "get", "github.com/smartcontractkit/cre-sdk-go@"+creinit.SdkVersion) if err != nil { + spinner.Stop() return err } err = runCommand(inputs.ProjectRoot, "go", "get", "github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/evm@"+creinit.EVMCapabilitiesVersion) if err != nil { + spinner.Stop() return err } if err = runCommand(inputs.ProjectRoot, "go", "mod", "tidy"); err != nil { + spinner.Stop() return err } + + spinner.Stop() + ui.Success("Bindings generated successfully") return nil default: return fmt.Errorf("unsupported chain family: %s", inputs.ChainFamily) diff --git a/cmd/login/login.go b/cmd/login/login.go index b0f41f80..29de47ea 100644 --- a/cmd/login/login.go +++ b/cmd/login/login.go @@ -24,6 +24,7 @@ import ( "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/environments" "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/ui" ) var ( @@ -58,12 +59,20 @@ func New(runtimeCtx *runtime.Context) *cobra.Command { return cmd } +// Run executes the login flow directly without going through Cobra. +// This is useful for prompting login from other commands when auth is required. +func Run(runtimeCtx *runtime.Context) error { + h := newHandler(runtimeCtx) + return h.execute() +} + type handler struct { environmentSet *environments.EnvironmentSet log *zerolog.Logger lastPKCEVerifier string lastState string retryCount int + spinner *ui.Spinner } const maxOrgNotFoundRetries = 3 @@ -72,36 +81,65 @@ func newHandler(ctx *runtime.Context) *handler { return &handler{ log: ctx.Logger, environmentSet: ctx.EnvironmentSet, + spinner: ui.NewSpinner(), } } func (h *handler) execute() error { + // Welcome message (no spinner yet) + ui.Title("CRE Login") + ui.Line() + ui.Dim("Authenticate with your Chainlink account") + ui.Line() + code, err := h.startAuthFlow() if err != nil { + h.spinner.StopAll() return err } + // Use spinner for the token exchange + h.spinner.Start("Exchanging authorization code...") tokenSet, err := h.exchangeCodeForTokens(context.Background(), code) if err != nil { + h.spinner.StopAll() h.log.Error().Err(err).Msg("code exchange failed") return err } + h.spinner.Update("Saving credentials...") if err := credentials.SaveCredentials(tokenSet); err != nil { + h.spinner.StopAll() h.log.Error().Err(err).Msg("failed to save credentials") return err } - fmt.Println("Login completed successfully") - fmt.Println("To get started, run: cre init") + // Stop spinner before final output + h.spinner.Stop() + + ui.Line() + ui.Success("Login completed successfully!") + ui.Line() + + // Show next steps in a styled box + nextSteps := ui.RenderBold("Next steps:") + "\n" + + " " + ui.RenderCommand("cre init") + " Create a new CRE project\n" + + " " + ui.RenderCommand("cre whoami") + " View your account info" + ui.Box(nextSteps) + ui.Line() + return nil } func (h *handler) startAuthFlow() (string, error) { codeCh := make(chan string, 1) + // Use spinner while setting up server + h.spinner.Start("Preparing authentication...") + server, listener, err := h.setupServer(codeCh) if err != nil { + h.spinner.Stop() return "", err } defer func() { @@ -118,19 +156,34 @@ func (h *handler) startAuthFlow() (string, error) { verifier, challenge, err := generatePKCE() if err != nil { + h.spinner.Stop() return "", err } h.lastPKCEVerifier = verifier h.lastState = randomState() authURL := h.buildAuthURL(challenge, h.lastState) - fmt.Printf("Opening browser to %s\n", authURL) + + // Stop spinner before showing URL (static content) + h.spinner.Stop() + + // Show URL - this stays visible while user authenticates in browser + ui.Step("Opening browser to:") + ui.URL(authURL) + ui.Line() + if err := openBrowser(authURL, rt.GOOS); err != nil { - h.log.Warn().Err(err).Msg("could not open browser, please navigate manually") + ui.Warning("Could not open browser automatically") + ui.Dim("Please open the URL above in your browser") + ui.Line() } + // Static waiting message (no spinner - user will see this when they return) + ui.Dim("Waiting for authentication... (Press Ctrl+C to cancel)") + select { case code := <-codeCh: + ui.Line() return code, nil case <-time.After(500 * time.Second): return "", fmt.Errorf("timeout waiting for authorization code") @@ -182,7 +235,7 @@ func (h *handler) callbackHandler(codeCh chan string) http.HandlerFunc { // Build the new auth URL for redirect authURL := h.buildAuthURL(challenge, h.lastState) - fmt.Printf("Your organization is being created, please wait (attempt %d/%d)...\n", h.retryCount, maxOrgNotFoundRetries) + h.log.Debug().Int("attempt", h.retryCount).Int("max", maxOrgNotFoundRetries).Msg("organization setup in progress, retrying") h.serveWaitingPage(w, authURL) return } diff --git a/cmd/login/login_test.go b/cmd/login/login_test.go index 405b7024..782f2d18 100644 --- a/cmd/login/login_test.go +++ b/cmd/login/login_test.go @@ -14,6 +14,7 @@ import ( "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/ui" ) func TestSaveCredentials_WritesYAML(t *testing.T) { @@ -78,7 +79,7 @@ func TestOpenBrowser_UnsupportedOS(t *testing.T) { } func TestServeEmbeddedHTML_ErrorOnMissingFile(t *testing.T) { - h := &handler{log: &zerolog.Logger{}} + h := &handler{log: &zerolog.Logger{}, spinner: ui.NewSpinner()} w := httptest.NewRecorder() h.serveEmbeddedHTML(w, "htmlPages/doesnotexist.html", http.StatusOK) resp := w.Result() @@ -143,6 +144,7 @@ func TestCallbackHandler_OrgMembershipError(t *testing.T) { log: &logger, lastState: "test-state", retryCount: 0, + spinner: ui.NewSpinner(), environmentSet: &environments.EnvironmentSet{ ClientID: "test-client-id", AuthBase: "https://auth.example.com", @@ -195,6 +197,7 @@ func TestCallbackHandler_OrgMembershipError_MaxRetries(t *testing.T) { log: &logger, lastState: "test-state", retryCount: maxOrgNotFoundRetries, // Already at max retries + spinner: ui.NewSpinner(), environmentSet: &environments.EnvironmentSet{ ClientID: "test-client-id", AuthBase: "https://auth.example.com", @@ -234,6 +237,7 @@ func TestCallbackHandler_GenericAuth0Error(t *testing.T) { h := &handler{ log: &logger, lastState: "test-state", + spinner: ui.NewSpinner(), environmentSet: &environments.EnvironmentSet{ ClientID: "test-client-id", AuthBase: "https://auth.example.com", @@ -270,7 +274,7 @@ func TestCallbackHandler_GenericAuth0Error(t *testing.T) { func TestServeWaitingPage(t *testing.T) { logger := zerolog.Nop() - h := &handler{log: &logger} + h := &handler{log: &logger, spinner: ui.NewSpinner()} w := httptest.NewRecorder() redirectURL := "https://auth.example.com/authorize?client_id=test&state=abc123" diff --git a/cmd/logout/logout.go b/cmd/logout/logout.go index 64a8cc0f..36429cf3 100644 --- a/cmd/logout/logout.go +++ b/cmd/logout/logout.go @@ -14,6 +14,7 @@ import ( "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/environments" "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/ui" ) var ( @@ -36,14 +37,12 @@ func New(runtimeCtx *runtime.Context) *cobra.Command { type handler struct { log *zerolog.Logger - credentials *credentials.Credentials environmentSet *environments.EnvironmentSet } func newHandler(ctx *runtime.Context) *handler { return &handler{ log: ctx.Logger, - credentials: ctx.Credentials, environmentSet: ctx.EnvironmentSet, } } @@ -55,15 +54,20 @@ func (h *handler) execute() error { } credPath := filepath.Join(home, credentials.ConfigDir, credentials.ConfigFile) - if h.credentials.Tokens == nil { - fmt.Println("user not logged in") + // Load credentials directly (logout is excluded from global credential loading) + creds, err := credentials.New(h.log) + if err != nil || creds == nil || creds.Tokens == nil { + ui.Warning("You are not logged in") return nil } - if h.credentials.AuthType == credentials.AuthTypeBearer && h.credentials.Tokens.RefreshToken != "" { + spinner := ui.NewSpinner() + spinner.Start("Logging out...") + + if creds.AuthType == credentials.AuthTypeBearer && creds.Tokens.RefreshToken != "" { h.log.Debug().Msg("Revoking refresh token") form := url.Values{} - form.Set("token", h.credentials.Tokens.RefreshToken) + form.Set("token", creds.Tokens.RefreshToken) form.Set("client_id", h.environmentSet.ClientID) if revokeURL == "" { @@ -84,9 +88,11 @@ func (h *handler) execute() error { } if err := os.Remove(credPath); err != nil && !os.IsNotExist(err) { + spinner.Stop() return fmt.Errorf("failed to delete credentials file: %w", err) } - fmt.Println("Logged out successfully") + spinner.Stop() + ui.Success("Logged out successfully") return nil } diff --git a/cmd/root.go b/cmd/root.go index 51af5fb8..c6bb594c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/charmbracelet/huh" "github.com/rs/zerolog" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -29,6 +30,7 @@ import ( "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/telemetry" + "github.com/smartcontractkit/cre-cli/internal/ui" intupdate "github.com/smartcontractkit/cre-cli/internal/update" ) @@ -49,6 +51,7 @@ func Execute() { exitCode := 0 if err != nil { + ui.Error(err.Error()) exitCode = 1 } @@ -85,20 +88,36 @@ func newRootCommand() *cobra.Command { // remove autogenerated string that contains this comment: "Auto generated by spf13/cobra on DD-Mon-YYYY" // timestamps can cause docs to keep regenerating on each new PR for no good reason DisableAutoGenTag: true, + // Silence Cobra's default error display - we use styled ui.Error() instead + SilenceErrors: true, // this will be inherited by all submodules and all their commands RunE: helpRunE, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // Silence usage for runtime errors - at this point flag parsing succeeded, + // so any errors from here are runtime errors, not usage errors + cmd.SilenceUsage = true + executingCommand = cmd executingArgs = args log := runtimeContext.Logger v := runtimeContext.Viper + // Start the global spinner for commands that do initialization work + spinner := ui.GlobalSpinner() + showSpinner := shouldShowSpinner(cmd) + if showSpinner { + spinner.Start("Initializing...") + } + // add binding for all existing command flags via Viper // this step has to run first because flags have higher precedence over configuration parameters and defaults values if err := v.BindPFlags(cmd.Flags()); err != nil { + if showSpinner { + spinner.Stop() + } return fmt.Errorf("failed to bind flags: %w", err) } @@ -112,22 +131,69 @@ func newRootCommand() *cobra.Command { runtimeContext.ClientFactory = client.NewFactory(&newLogger, v) } + if showSpinner { + spinner.Update("Loading environment...") + } err := runtimeContext.AttachEnvironmentSet() if err != nil { + if showSpinner { + spinner.Stop() + } return fmt.Errorf("failed to load environment details: %w", err) } if isLoadCredentials(cmd) { + if showSpinner { + spinner.Update("Validating credentials...") + } skipValidation := shouldSkipValidation(cmd) err := runtimeContext.AttachCredentials(cmd.Context(), skipValidation) if err != nil { - return fmt.Errorf("authentication required: %w", err) + if showSpinner { + spinner.Stop() + } + + // Prompt user to login + ui.Line() + ui.Warning("You are not logged in") + ui.Line() + + var runLogin bool + confirmForm := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Would you like to login now?"). + Affirmative("Yes, login"). + Negative("No, cancel"). + Value(&runLogin), + ), + ).WithTheme(ui.ChainlinkTheme()) + + if formErr := confirmForm.Run(); formErr != nil { + return fmt.Errorf("authentication required: %w", err) + } + + if !runLogin { + return fmt.Errorf("authentication required: %w", err) + } + + // Run login flow + ui.Line() + if loginErr := login.Run(runtimeContext); loginErr != nil { + return fmt.Errorf("login failed: %w", loginErr) + } + + // Exit after successful login - user can re-run their command + os.Exit(0) } // Check if organization is ungated for commands that require it cmdPath := cmd.CommandPath() if cmdPath == "cre account link-key" || cmdPath == "cre workflow deploy" { if err := runtimeContext.Credentials.CheckIsUngatedOrganization(); err != nil { + if showSpinner { + spinner.Stop() + } return err } } @@ -135,18 +201,32 @@ func newRootCommand() *cobra.Command { // load settings from yaml files if isLoadSettings(cmd) { + if showSpinner { + spinner.Update("Loading settings...") + } // Set execution context (project root + workflow directory if applicable) projectRootFlag := runtimeContext.Viper.GetString(settings.Flags.ProjectRoot.Name) if err := context.SetExecutionContext(cmd, args, projectRootFlag, rootLogger); err != nil { + if showSpinner { + spinner.Stop() + } return err } err := runtimeContext.AttachSettings(cmd, isLoadDeploymentRPC(cmd)) if err != nil { + if showSpinner { + spinner.Stop() + } return fmt.Errorf("%w", err) } } + // Stop the initialization spinner - commands can start their own if needed + if showSpinner { + spinner.Stop() + } + return nil }, @@ -174,6 +254,29 @@ func newRootCommand() *cobra.Command { return false }) + // Lipgloss-styled template functions for help (using Chainlink brand colors) + cobra.AddTemplateFunc("styleTitle", func(s string) string { + return ui.TitleStyle.Render(s) + }) + cobra.AddTemplateFunc("styleSection", func(s string) string { + return ui.TitleStyle.Render(s) + }) + cobra.AddTemplateFunc("styleCommand", func(s string) string { + return ui.CommandStyle.Render(s) // Light Blue - prominent + }) + cobra.AddTemplateFunc("styleDim", func(s string) string { + return ui.DimStyle.Render(s) // Gray - less important + }) + cobra.AddTemplateFunc("styleSuccess", func(s string) string { + return ui.SuccessStyle.Render(s) // Green + }) + cobra.AddTemplateFunc("styleCode", func(s string) string { + return ui.CodeStyle.Render(s) // Light Blue - visible + }) + cobra.AddTemplateFunc("styleURL", func(s string) string { + return ui.URLStyle.Render(s) // Chainlink Blue, underlined + }) + rootCmd.SetHelpTemplate(helpTemplate) // Definition of global flags: @@ -285,6 +388,7 @@ func isLoadCredentials(cmd *cobra.Command) bool { var excludedCommands = map[string]struct{}{ "cre version": {}, "cre login": {}, + "cre logout": {}, "cre completion bash": {}, "cre completion fish": {}, "cre completion powershell": {}, @@ -342,6 +446,30 @@ func shouldCheckForUpdates(cmd *cobra.Command) bool { return !exists } +func shouldShowSpinner(cmd *cobra.Command) bool { + // Don't show spinner for commands that don't do async work + // or commands that have their own interactive UI (like init) + var excludedCommands = map[string]struct{}{ + "cre": {}, + "cre version": {}, + "cre help": {}, + "cre completion bash": {}, + "cre completion fish": {}, + "cre completion powershell": {}, + "cre completion zsh": {}, + "cre init": {}, // Has its own Huh forms UI + "cre login": {}, // Has its own interactive flow + "cre logout": {}, + "cre update": {}, + "cre workflow": {}, // Just shows help + "cre account": {}, // Just shows help + "cre secrets": {}, // Just shows help + } + + _, exists := excludedCommands[cmd.CommandPath()] + return !exists +} + func createLogger() *zerolog.Logger { // Set default Seth log level if not set if _, found := os.LookupEnv("SETH_LOG_LEVEL"); !found { diff --git a/cmd/secrets/common/gateway.go b/cmd/secrets/common/gateway.go index cc84b392..83fd2ea3 100644 --- a/cmd/secrets/common/gateway.go +++ b/cmd/secrets/common/gateway.go @@ -8,6 +8,8 @@ import ( "time" "github.com/avast/retry-go/v4" + + "github.com/smartcontractkit/cre-cli/internal/ui" ) type GatewayClient interface { @@ -61,7 +63,7 @@ func (g *HTTPClient) Post(body []byte) ([]byte, int, error) { retry.Delay(delay), retry.LastErrorOnly(true), retry.OnRetry(func(n uint, err error) { - fmt.Printf("Waiting for on-chain allowlist finalization... (attempt %d/%d): %v\n", n+1, attempts, err) + ui.Dim(fmt.Sprintf("Waiting for on-chain allowlist finalization... (attempt %d/%d): %v", n+1, attempts, err)) }), ) diff --git a/cmd/secrets/common/handler.go b/cmd/secrets/common/handler.go index 84be7bbe..d409cad5 100644 --- a/cmd/secrets/common/handler.go +++ b/cmd/secrets/common/handler.go @@ -37,6 +37,7 @@ import ( "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/types" + "github.com/smartcontractkit/cre-cli/internal/ui" "github.com/smartcontractkit/cre-cli/internal/validation" ) @@ -195,27 +196,27 @@ func (h *Handler) PackAllowlistRequestTxData(reqDigest [32]byte, duration time.D } func (h *Handler) LogMSIGNextSteps(txData string, digest [32]byte, bundlePath string) error { - fmt.Println("") - fmt.Println("MSIG transaction prepared!") - fmt.Println("") - fmt.Println("Next steps:") - fmt.Println("") - fmt.Println(" 1. Submit the following transaction on the target chain:") - fmt.Printf(" Chain: %s\n", h.EnvironmentSet.WorkflowRegistryChainName) - fmt.Printf(" Contract Address: %s\n", h.EnvironmentSet.WorkflowRegistryAddress) - fmt.Println("") - fmt.Println(" 2. Use the following transaction data:") - fmt.Println("") - fmt.Printf(" %s\n", txData) - fmt.Println("") - fmt.Println(" 3. Save this bundle file; you will need it on the second run:") - fmt.Printf(" Bundle Path: %s\n", bundlePath) - fmt.Printf(" Digest: 0x%s\n", hex.EncodeToString(digest[:])) - fmt.Println("") - fmt.Println(" 4. After the transaction is finalized on-chain, run:") - fmt.Println("") - fmt.Println(" cre secrets execute", bundlePath, "--unsigned") - fmt.Println("") + ui.Line() + ui.Success("MSIG transaction prepared!") + ui.Line() + ui.Bold("Next steps:") + ui.Line() + ui.Print(" 1. Submit the following transaction on the target chain:") + ui.Printf(" Chain: %s\n", h.EnvironmentSet.WorkflowRegistryChainName) + ui.Printf(" Contract Address: %s\n", h.EnvironmentSet.WorkflowRegistryAddress) + ui.Line() + ui.Print(" 2. Use the following transaction data:") + ui.Line() + ui.Code(txData) + ui.Line() + ui.Print(" 3. Save this bundle file; you will need it on the second run:") + ui.Printf(" Bundle Path: %s\n", bundlePath) + ui.Printf(" Digest: 0x%s\n", hex.EncodeToString(digest[:])) + ui.Line() + ui.Print(" 4. After the transaction is finalized on-chain, run:") + ui.Line() + ui.Code(fmt.Sprintf("cre secrets execute %s --unsigned", bundlePath)) + ui.Line() return nil } @@ -352,7 +353,7 @@ func (h *Handler) Execute( duration time.Duration, ownerType string, ) error { - fmt.Println("Verifying ownership...") + ui.Dim("Verifying ownership...") if err := h.EnsureOwnerLinkedOrFail(); err != nil { return err } @@ -433,7 +434,7 @@ func (h *Handler) Execute( } if txOut == nil && allowlisted { - fmt.Printf("Digest already allowlisted; proceeding to gateway POST: owner=%s, digest=0x%x\n", ownerAddr.Hex(), digest) + ui.Dim(fmt.Sprintf("Digest already allowlisted; proceeding to gateway POST: owner=%s, digest=0x%x", ownerAddr.Hex(), digest)) return gatewayPost() } @@ -451,9 +452,10 @@ func (h *Handler) Execute( switch txOut.Type { case client.Regular: - fmt.Println("Transaction confirmed") - fmt.Printf("Digest allowlisted; proceeding to gateway POST: owner=%s, digest=0x%x\n", ownerAddr.Hex(), digest) - fmt.Printf("View on explorer: \033]8;;%s/tx/%s\033\\%s/tx/%s\033]8;;\033\\\n", h.EnvironmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash, h.EnvironmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash) + ui.Success("Transaction confirmed") + ui.Dim(fmt.Sprintf("Digest allowlisted; proceeding to gateway POST: owner=%s, digest=0x%x", ownerAddr.Hex(), digest)) + explorerURL := fmt.Sprintf("%s/tx/%s", h.EnvironmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash) + ui.URL(explorerURL) return gatewayPost() case client.Raw: if err := SaveBundle(bundlePath, ub); err != nil { @@ -472,7 +474,7 @@ func (h *Handler) Execute( } mcmsConfig, err := settings.GetMCMSConfig(h.Settings, chainSelector) if err != nil { - fmt.Println("\nMCMS config not found or is incorrect, skipping MCMS config in changeset") + ui.Warning("MCMS config not found or is incorrect, skipping MCMS config in changeset") } cldSettings := h.Settings.CLDSettings changesets := []types.Changeset{ @@ -546,11 +548,10 @@ func (h *Handler) ParseVaultGatewayResponse(method string, respBody []byte) erro key, owner, ns = id.GetKey(), id.GetOwner(), id.GetNamespace() } if r.GetSuccess() { - fmt.Printf("Secret created: secret_id=%s, owner=%s, namespace=%s\n", key, owner, ns) + ui.Success(fmt.Sprintf("Secret created: secret_id=%s, owner=%s, namespace=%s", key, owner, ns)) } else { - fmt.Printf("Secret create failed: secret_id=%s owner=%s namespace=%s success=%t error=%s\n", - key, owner, ns, false, r.GetError(), - ) + ui.Error(fmt.Sprintf("Secret create failed: secret_id=%s owner=%s namespace=%s error=%s", + key, owner, ns, r.GetError())) } } case vaulttypes.MethodSecretsUpdate: @@ -565,11 +566,10 @@ func (h *Handler) ParseVaultGatewayResponse(method string, respBody []byte) erro key, owner, ns = id.GetKey(), id.GetOwner(), id.GetNamespace() } if r.GetSuccess() { - fmt.Printf("Secret updated: secret_id=%s, owner=%s, namespace=%s\n", key, owner, ns) + ui.Success(fmt.Sprintf("Secret updated: secret_id=%s, owner=%s, namespace=%s", key, owner, ns)) } else { - fmt.Printf("Secret update failed: secret_id=%s owner=%s namespace=%s success=%t error=%s\n", - key, owner, ns, false, r.GetError(), - ) + ui.Error(fmt.Sprintf("Secret update failed: secret_id=%s owner=%s namespace=%s error=%s", + key, owner, ns, r.GetError())) } } case vaulttypes.MethodSecretsDelete: @@ -584,11 +584,10 @@ func (h *Handler) ParseVaultGatewayResponse(method string, respBody []byte) erro key, owner, ns = id.GetKey(), id.GetOwner(), id.GetNamespace() } if r.GetSuccess() { - fmt.Printf("Secret deleted: secret_id=%s, owner=%s, namespace=%s\n", key, owner, ns) + ui.Success(fmt.Sprintf("Secret deleted: secret_id=%s, owner=%s, namespace=%s", key, owner, ns)) } else { - fmt.Printf("Secret delete failed: secret_id=%s owner=%s namespace=%s success=%t error=%s\n", - key, owner, ns, false, r.GetError(), - ) + ui.Error(fmt.Sprintf("Secret delete failed: secret_id=%s owner=%s namespace=%s error=%s", + key, owner, ns, r.GetError())) } } case vaulttypes.MethodSecretsList: @@ -598,15 +597,13 @@ func (h *Handler) ParseVaultGatewayResponse(method string, respBody []byte) erro } if !p.GetSuccess() { - fmt.Printf("secret list failed: success=%t error=%s\n", - false, p.GetError(), - ) + ui.Error(fmt.Sprintf("Secret list failed: error=%s", p.GetError())) break } ids := p.GetIdentifiers() if len(ids) == 0 { - fmt.Println("No secrets found") + ui.Dim("No secrets found") break } for _, id := range ids { @@ -614,7 +611,7 @@ func (h *Handler) ParseVaultGatewayResponse(method string, respBody []byte) erro if id != nil { key, owner, ns = id.GetKey(), id.GetOwner(), id.GetNamespace() } - fmt.Printf("Secret identifier: secret_id=%s, owner=%s, namespace=%s\n", key, owner, ns) + ui.Print(fmt.Sprintf("Secret identifier: secret_id=%s, owner=%s, namespace=%s", key, owner, ns)) } default: // Unknown/unsupported method — don’t fail, just surface it explicitly @@ -635,7 +632,7 @@ func (h *Handler) EnsureOwnerLinkedOrFail() error { return fmt.Errorf("failed to check owner link status: %w", err) } - fmt.Printf("Workflow owner link status: owner=%s, linked=%v\n", ownerAddr.Hex(), linked) + ui.Dim(fmt.Sprintf("Workflow owner link status: owner=%s, linked=%v", ownerAddr.Hex(), linked)) if linked { // Owner is linked on contract, now verify it's linked to the current user's account @@ -648,7 +645,7 @@ func (h *Handler) EnsureOwnerLinkedOrFail() error { return fmt.Errorf("key %s is linked to another account. Please use a different owner address", ownerAddr.Hex()) } - fmt.Println("Key ownership verified") + ui.Success("Key ownership verified") return nil } diff --git a/cmd/secrets/common/parse_response_test.go b/cmd/secrets/common/parse_response_test.go index 1e0b80cb..9335a84e 100644 --- a/cmd/secrets/common/parse_response_test.go +++ b/cmd/secrets/common/parse_response_test.go @@ -400,8 +400,8 @@ func TestParseVaultGatewayResponse_List_Failure(t *testing.T) { out := output.String() - // With fmt.Printf, the summary error is now on stdout - if !strings.Contains(out, "secret list failed") { + // With ui.Error, the summary error is now on stdout with ✗ prefix + if !strings.Contains(strings.ToLower(out), "secret list failed") { t.Fatalf("expected summary error line 'secret list failed' on stdout, got:\n%s", out) } // And the error text should be present there too diff --git a/cmd/secrets/delete/delete.go b/cmd/secrets/delete/delete.go index f2cdaa92..46d63b8b 100644 --- a/cmd/secrets/delete/delete.go +++ b/cmd/secrets/delete/delete.go @@ -27,6 +27,7 @@ import ( "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/types" + "github.com/smartcontractkit/cre-cli/internal/ui" "github.com/smartcontractkit/cre-cli/internal/validation" ) @@ -101,10 +102,13 @@ func New(ctx *runtime.Context) *cobra.Command { // - MSIG step 1: build request, compute digest, write bundle, print steps // - EOA: allowlist if needed, then POST to gateway func Execute(h *common.Handler, inputs DeleteSecretsInputs, duration time.Duration, ownerType string) error { - fmt.Println("Verifying ownership...") + spinner := ui.NewSpinner() + spinner.Start("Verifying ownership...") if err := h.EnsureOwnerLinkedOrFail(); err != nil { + spinner.Stop() return err } + spinner.Stop() // Validate and canonicalize owner address owner := strings.TrimSpace(h.OwnerAddress) @@ -171,7 +175,7 @@ func Execute(h *common.Handler, inputs DeleteSecretsInputs, duration time.Durati return fmt.Errorf("allowlist request failed: %w", err) } } else { - fmt.Printf("Digest already allowlisted; proceeding to gateway POST: owner=%s, digest=0x%x\n", ownerAddr.Hex(), digest) + ui.Dim(fmt.Sprintf("Digest already allowlisted; proceeding to gateway POST: owner=%s, digest=0x%x", ownerAddr.Hex(), digest)) return gatewayPost() } @@ -189,9 +193,9 @@ func Execute(h *common.Handler, inputs DeleteSecretsInputs, duration time.Durati switch txOut.Type { case client.Regular: - fmt.Println("Transaction confirmed") - fmt.Printf("Digest allowlisted; proceeding to gateway POST: owner=%s, digest=0x%x\n", ownerAddr.Hex(), digest) - fmt.Printf("View on explorer: \033]8;;%s/tx/%s\033\\%s/tx/%s\033]8;;\033\\\n", h.EnvironmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash, h.EnvironmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash) + ui.Success("Transaction confirmed") + ui.Dim(fmt.Sprintf("Digest allowlisted; proceeding to gateway POST: owner=%s, digest=0x%x", ownerAddr.Hex(), digest)) + ui.URL(fmt.Sprintf("%s/tx/%s", h.EnvironmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash)) return gatewayPost() case client.Raw: @@ -212,7 +216,7 @@ func Execute(h *common.Handler, inputs DeleteSecretsInputs, duration time.Durati } mcmsConfig, err := settings.GetMCMSConfig(h.Settings, chainSelector) if err != nil { - fmt.Println("\nMCMS config not found or is incorrect, skipping MCMS config in changeset") + ui.Warning("MCMS config not found or is incorrect, skipping MCMS config in changeset") } cldSettings := h.Settings.CLDSettings changesets := []types.Changeset{ diff --git a/cmd/secrets/list/list.go b/cmd/secrets/list/list.go index 69457ed2..f9c3e433 100644 --- a/cmd/secrets/list/list.go +++ b/cmd/secrets/list/list.go @@ -25,6 +25,7 @@ import ( "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/types" + "github.com/smartcontractkit/cre-cli/internal/ui" ) // cre secrets list --timeout 1h @@ -75,10 +76,13 @@ func New(ctx *runtime.Context) *cobra.Command { // Execute performs: build request → (MSIG step 1 bundle OR EOA allowlist+post) → parse. func Execute(h *common.Handler, namespace string, duration time.Duration, ownerType string) error { - fmt.Println("Verifying ownership...") + spinner := ui.NewSpinner() + spinner.Start("Verifying ownership...") if err := h.EnsureOwnerLinkedOrFail(); err != nil { + spinner.Stop() return err } + spinner.Stop() if namespace == "" { namespace = "main" @@ -140,7 +144,7 @@ func Execute(h *common.Handler, namespace string, duration time.Duration, ownerT } if txOut == nil && allowlisted { - fmt.Printf("Digest already allowlisted; proceeding to gateway POST: owner=%s, digest=0x%x\n", ownerAddr.Hex(), digest) + ui.Dim(fmt.Sprintf("Digest already allowlisted; proceeding to gateway POST: owner=%s, digest=0x%x", ownerAddr.Hex(), digest)) return gatewayPost() } @@ -162,9 +166,9 @@ func Execute(h *common.Handler, namespace string, duration time.Duration, ownerT switch txOut.Type { case client.Regular: - fmt.Println("Transaction confirmed") - fmt.Printf("Digest allowlisted; proceeding to gateway POST: owner=%s, digest=0x%x\n", ownerAddr.Hex(), digest) - fmt.Printf("View on explorer: \033]8;;%s/tx/%s\033\\%s/tx/%s\033]8;;\033\\\n", h.EnvironmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash, h.EnvironmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash) + ui.Success("Transaction confirmed") + ui.Dim(fmt.Sprintf("Digest allowlisted; proceeding to gateway POST: owner=%s, digest=0x%x", ownerAddr.Hex(), digest)) + ui.URL(fmt.Sprintf("%s/tx/%s", h.EnvironmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash)) return gatewayPost() case client.Raw: @@ -184,7 +188,7 @@ func Execute(h *common.Handler, namespace string, duration time.Duration, ownerT } mcmsConfig, err := settings.GetMCMSConfig(h.Settings, chainSelector) if err != nil { - fmt.Println("\nMCMS config not found or is incorrect, skipping MCMS config in changeset") + ui.Warning("MCMS config not found or is incorrect, skipping MCMS config in changeset") } cldSettings := h.Settings.CLDSettings changesets := []types.Changeset{ diff --git a/cmd/template/help_template.tpl b/cmd/template/help_template.tpl index 2e55d2d3..f91585f6 100644 --- a/cmd/template/help_template.tpl +++ b/cmd/template/help_template.tpl @@ -1,6 +1,6 @@ {{- with (or .Long .Short)}}{{.}}{{end}} -Usage: +{{styleSection "Usage:"}} {{- if .HasAvailableSubCommands}} {{.CommandPath}} [command]{{if .HasAvailableFlags}} [flags]{{end}} {{- else}} @@ -13,7 +13,7 @@ Usage: {{- /* ============================================ */}} {{- if .HasAvailableSubCommands}} -Available Commands: +{{styleSection "Available Commands:"}} {{- $groupsUsed := false -}} {{- $firstGroup := true -}} @@ -24,17 +24,17 @@ Available Commands: {{- $has = true}} {{- end}} {{- end}} - + {{- if $has}} {{- $groupsUsed = true -}} {{- if $firstGroup}}{{- $firstGroup = false -}}{{else}} {{- end}} - {{printf "%s:" $grp.Title}} + {{styleDim $grp.Title}} {{- range $.Commands}} {{- if (and (not .Hidden) (.IsAvailableCommand) (eq .GroupID $grp.ID))}} - {{rpad .Name .NamePadding}} {{.Short}} + {{styleCommand (rpad .Name .NamePadding)}} {{.Short}} {{- end}} {{- end}} {{- end}} @@ -44,10 +44,10 @@ Available Commands: {{- /* Groups are in use; show ungrouped as "Other" if any */}} {{- if hasUngrouped .}} - Other: + {{styleDim "Other"}} {{- range .Commands}} {{- if (and (not .Hidden) (.IsAvailableCommand) (eq .GroupID ""))}} - {{rpad .Name .NamePadding}} {{.Short}} + {{styleCommand (rpad .Name .NamePadding)}} {{.Short}} {{- end}} {{- end}} {{- end}} @@ -55,7 +55,7 @@ Available Commands: {{- /* No groups at this level; show a flat list with no "Other" header */}} {{- range .Commands}} {{- if (and (not .Hidden) (.IsAvailableCommand))}} - {{rpad .Name .NamePadding}} {{.Short}} + {{styleCommand (rpad .Name .NamePadding)}} {{.Short}} {{- end}} {{- end}} {{- end }} @@ -63,35 +63,35 @@ Available Commands: {{- if .HasExample}} -Examples: -{{.Example}} +{{styleSection "Examples:"}} +{{styleCode .Example}} {{- end }} {{- $local := (.LocalFlags.FlagUsagesWrapped 100 | trimTrailingWhitespaces) -}} {{- if $local }} -Flags: +{{styleSection "Flags:"}} {{$local}} {{- end }} {{- $inherited := (.InheritedFlags.FlagUsagesWrapped 100 | trimTrailingWhitespaces) -}} {{- if $inherited }} -Global Flags: +{{styleSection "Global Flags:"}} {{$inherited}} {{- end }} {{- if .HasAvailableSubCommands }} -Use "{{.CommandPath}} [command] --help" for more information about a command. +{{styleDim (printf "Use \"%s [command] --help\" for more information about a command." .CommandPath)}} {{- end }} -💡 Tip: New here? Run: - $ cre login +{{styleSuccess "Tip:"}} New here? Run: + {{styleCode "$ cre login"}} to login into your cre account, then: - $ cre init + {{styleCode "$ cre init"}} to create your first cre project. -📘 Need more help? - Visit https://docs.chain.link/cre +{{styleSection "Need more help?"}} + Visit {{styleURL "https://docs.chain.link/cre"}} diff --git a/cmd/update/update.go b/cmd/update/update.go index c6fb9b39..a15e2a6b 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -21,6 +21,7 @@ import ( "github.com/smartcontractkit/cre-cli/cmd/version" "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/ui" ) const ( @@ -43,7 +44,7 @@ func getLatestTag() (string, error) { defer func(Body io.ReadCloser) { err := Body.Close() if err != nil { - fmt.Println("Error closing response body:", err) + ui.Warning("Error closing response body: " + err.Error()) } }(resp.Body) var info releaseInfo @@ -90,29 +91,29 @@ func getAssetName() (asset string, platform string, err error) { return asset, platform, nil } -func downloadFile(url, dest string) error { +func downloadFile(url, dest, message string) error { resp, err := httpClient.Get(url) if err != nil { return err } defer func(Body io.ReadCloser) { - err := Body.Close() - if err != nil { - fmt.Println("Error closing response body:", err) - } + _ = Body.Close() }(resp.Body) + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("bad status: %s", resp.Status) + } + out, err := os.Create(dest) if err != nil { return err } defer func(out *os.File) { - err := out.Close() - if err != nil { - fmt.Println("Error closing out file:", err) - } + _ = out.Close() }(out) - _, err = io.Copy(out, resp.Body) - return err + + // Use progress bar for download + return ui.DownloadWithProgress(resp.Body, resp.ContentLength, out, message) } func extractBinary(assetPath string) (string, error) { @@ -134,7 +135,7 @@ func untar(assetPath string) (string, error) { defer func(f *os.File) { err := f.Close() if err != nil { - fmt.Println("Error closing file:", err) + ui.Warning("Error closing file: " + err.Error()) } }(f) gz, err := gzip.NewReader(f) @@ -144,7 +145,7 @@ func untar(assetPath string) (string, error) { defer func(gz *gzip.Reader) { err := gz.Close() if err != nil { - fmt.Println("Error closing gzip reader:", err) + ui.Warning("Error closing gzip reader: " + err.Error()) } }(gz) // Untar @@ -218,7 +219,7 @@ func unzip(assetPath string) (string, error) { defer func(zr *zip.ReadCloser) { err := zr.Close() if err != nil { - fmt.Println("Error closing zip reader:", err) + ui.Warning("Error closing zip reader: " + err.Error()) } }(zr) for _, f := range zr.File { @@ -288,8 +289,11 @@ func replaceSelf(newBin string) error { } // On Windows, need to move after process exit if osruntime.GOOS == "windows" { - fmt.Println("Please close all running cre processes and manually replace the binary at:", self) - fmt.Println("New binary downloaded at:", newBin) + ui.Warning("Automatic replacement not supported on Windows") + ui.Dim("Please close all running cre processes and manually replace the binary at:") + ui.Code(self) + ui.Dim("New binary downloaded at:") + ui.Code(newBin) return fmt.Errorf("automatic replacement not supported on Windows") } // On Unix, can replace in-place @@ -298,13 +302,15 @@ func replaceSelf(newBin string) error { // Run accepts the currentVersion string func Run(currentVersion string) error { - fmt.Println("Checking for updates...") + spinner := ui.NewSpinner() + spinner.Start("Checking for updates...") + tag, err := getLatestTag() if err != nil { + spinner.Stop() return fmt.Errorf("error fetching latest version: %w", err) } - // --- New Update Check Logic --- // Clean the current version string (e.g., "version v1.2.3" -> "v1.2.3") cleanedCurrent := strings.Replace(currentVersion, "version", "", 1) cleanedCurrent = strings.TrimSpace(cleanedCurrent) @@ -317,59 +323,70 @@ func Run(currentVersion string) error { if errCurrent != nil || errLatest != nil { // If we can't parse either version, fall back to just updating. - // Print a warning to stderr. - fmt.Fprintf(os.Stderr, "Warning: could not compare versions (current: '%s', latest: '%s'). Proceeding with update.\n", cleanedCurrent, cleanedLatest) - if errCurrent != nil { - fmt.Fprintf(os.Stderr, "Current version parse error: %v\n", errCurrent) - } - if errLatest != nil { - fmt.Fprintf(os.Stderr, "Latest version parse error: %v\n", errLatest) - } + spinner.Stop() + ui.Warning(fmt.Sprintf("Could not compare versions (current: '%s', latest: '%s'). Proceeding with update.", cleanedCurrent, cleanedLatest)) + spinner.Start("Updating...") } else { // Compare versions if latestSemVer.LessThan(currentSemVer) || latestSemVer.Equal(currentSemVer) { - fmt.Printf("You are already using the latest version %s\n", currentSemVer.String()) - return nil // Skip the update + spinner.Stop() + ui.Success(fmt.Sprintf("You are already using the latest version %s", currentSemVer.String())) + return nil } } - // --- End of New Logic --- // If we're here, an update is needed. - fmt.Println("Updating cre CLI...") - asset, _, err := getAssetName() if err != nil { + spinner.Stop() return fmt.Errorf("error determining asset name: %w", err) } url := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", repo, tag, asset) tmpDir, err := os.MkdirTemp("", "cre_update_") if err != nil { + spinner.Stop() return fmt.Errorf("error creating temp dir: %w", err) } + defer func(path string) { + _ = os.RemoveAll(path) + }(tmpDir) + + // Stop spinner before showing progress bar + spinner.Stop() + assetPath := filepath.Join(tmpDir, asset) - fmt.Println("Downloading:", url) - if err := downloadFile(url, assetPath); err != nil { + downloadMsg := fmt.Sprintf("Downloading %s...", tag) + if err := downloadFile(url, assetPath, downloadMsg); err != nil { return fmt.Errorf("download failed: %w", err) } + + // Start new spinner for extraction and installation + spinner.Start("Extracting...") binPath, err := extractBinary(assetPath) if err != nil { + spinner.Stop() return fmt.Errorf("extraction failed: %w", err) } + + spinner.Update("Installing...") if err := os.Chmod(binPath, 0755); err != nil { + spinner.Stop() return fmt.Errorf("failed to set permissions: %w", err) } if err := replaceSelf(binPath); err != nil { + spinner.Stop() return fmt.Errorf("failed to replace binary: %w", err) } - defer func(path string) { - _ = os.RemoveAll(path) - }(tmpDir) - fmt.Println("cre CLI updated to", tag) + + spinner.Stop() + ui.Success(fmt.Sprintf("CRE CLI updated to %s", tag)) + ui.Line() + cmd := exec.Command(cliName, "version") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { - fmt.Println("Failed to run version command:", err) + ui.Warning("Failed to verify version: " + err.Error()) } return nil } diff --git a/cmd/utils/output.go b/cmd/utils/output.go index bfca7026..4b8feaf0 100644 --- a/cmd/utils/output.go +++ b/cmd/utils/output.go @@ -12,6 +12,8 @@ import ( "gopkg.in/yaml.v2" workflow_registry_wrapper "github.com/smartcontractkit/chainlink-evm/gethwrappers/workflow/generated/workflow_registry_wrapper_v2" + + "github.com/smartcontractkit/cre-cli/internal/ui" ) const ( @@ -82,7 +84,10 @@ func HandleJsonOrYamlFormat( } if outputPath == "" { - fmt.Printf("\n# Workflow metadata in %s format:\n\n%s\n", strings.ToUpper(format), string(out)) + ui.Line() + ui.Title(fmt.Sprintf("Workflow metadata in %s format:", strings.ToUpper(format))) + ui.Line() + ui.Print(string(out)) return nil } diff --git a/cmd/version/version.go b/cmd/version/version.go index 98978a1b..f1d0d727 100644 --- a/cmd/version/version.go +++ b/cmd/version/version.go @@ -1,11 +1,10 @@ package version import ( - "fmt" - "github.com/spf13/cobra" "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/ui" ) // Default placeholder value @@ -17,7 +16,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { Short: "Print the cre version", Long: "This command prints the current version of the cre", RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("cre", Version) + ui.Title("CRE CLI " + Version) return nil }, } diff --git a/cmd/version/version_test.go b/cmd/version/version_test.go index 9669516c..f2136990 100644 --- a/cmd/version/version_test.go +++ b/cmd/version/version_test.go @@ -21,12 +21,12 @@ func TestVersionCommand(t *testing.T) { { name: "Release version", version: "version v1.0.3-beta0", - expected: "cre version v1.0.3-beta0", + expected: "CRE CLI version v1.0.3-beta0", }, { name: "Local build hash", version: "build c8ab91c87c7135aa7c57669bb454e6a3287139d7", - expected: "cre build c8ab91c87c7135aa7c57669bb454e6a3287139d7", + expected: "CRE CLI build c8ab91c87c7135aa7c57669bb454e6a3287139d7", }, } diff --git a/cmd/whoami/whoami.go b/cmd/whoami/whoami.go index 6719baf8..7fa0c879 100644 --- a/cmd/whoami/whoami.go +++ b/cmd/whoami/whoami.go @@ -12,6 +12,7 @@ import ( "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/environments" "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/ui" ) func New(runtimeCtx *runtime.Context) *cobra.Command { @@ -78,19 +79,30 @@ func (h *Handler) Execute(ctx context.Context) error { } `json:"getOrganization"` } - if err := client.Execute(ctx, req, &respEnvelope); err != nil { + spinner := ui.GlobalSpinner() + spinner.Start("Fetching account details...") + err := client.Execute(ctx, req, &respEnvelope) + spinner.Stop() + + if err != nil { return fmt.Errorf("graphql request failed: %w", err) } - fmt.Println("") - fmt.Println("Account details retrieved:") - fmt.Println("") + ui.Line() + ui.Title("Account Details") + + details := fmt.Sprintf("Organization ID: %s\nOrganization Name: %s", + respEnvelope.GetOrganization.OrganizationID, + respEnvelope.GetOrganization.DisplayName) + if respEnvelope.GetAccountDetails != nil { - fmt.Printf("\tEmail: %s\n", respEnvelope.GetAccountDetails.EmailAddress) + details = fmt.Sprintf("Email: %s\n%s", + respEnvelope.GetAccountDetails.EmailAddress, + details) } - fmt.Printf("\tOrganization ID: %s\n", respEnvelope.GetOrganization.OrganizationID) - fmt.Printf("\tOrganization Name: %s\n", respEnvelope.GetOrganization.DisplayName) - fmt.Println("") + + ui.Box(details) + ui.Line() return nil } diff --git a/cmd/whoami/whoami_test.go b/cmd/whoami/whoami_test.go index d1ebe690..103c0da5 100644 --- a/cmd/whoami/whoami_test.go +++ b/cmd/whoami/whoami_test.go @@ -54,7 +54,8 @@ func TestHandlerExecute(t *testing.T) { }, wantErr: false, wantLogSnips: []string{ - "Account details retrieved:", "Email: alice@example.com", + "Account Details", + "Email: alice@example.com", "Organization ID: org-42", "Organization Name: Alice's Org", }, @@ -83,7 +84,7 @@ func TestHandlerExecute(t *testing.T) { }, wantErr: false, wantLogSnips: []string{ - "Account details retrieved:", + "Account Details", "Organization ID: org-42", "Organization Name: Alice's Org", }, diff --git a/cmd/workflow/activate/activate.go b/cmd/workflow/activate/activate.go index 4e4314d5..0eb759da 100644 --- a/cmd/workflow/activate/activate.go +++ b/cmd/workflow/activate/activate.go @@ -19,6 +19,7 @@ import ( "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/types" + "github.com/smartcontractkit/cre-cli/internal/ui" "github.com/smartcontractkit/cre-cli/internal/validation" ) @@ -171,7 +172,7 @@ func (h *handler) Execute() error { return err } - fmt.Printf("Activating workflow: Name=%s, Owner=%s, WorkflowID=%s\n", workflowName, workflowOwner, hex.EncodeToString(latest.WorkflowId[:])) + ui.Dim(fmt.Sprintf("Activating workflow: Name=%s, Owner=%s, WorkflowID=%s", workflowName, workflowOwner, hex.EncodeToString(latest.WorkflowId[:]))) txOut, err := h.wrc.ActivateWorkflow(latest.WorkflowId, h.inputs.DonFamily) if err != nil { @@ -180,29 +181,30 @@ func (h *handler) Execute() error { switch txOut.Type { case client.Regular: - fmt.Printf("Transaction confirmed: %s\n", txOut.Hash) - fmt.Printf("View on explorer: \033]8;;%s/tx/%s\033\\%s/tx/%s\033]8;;\033\\\n", h.environmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash, h.environmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash) - fmt.Println("\n[OK] Workflow activated successfully") - fmt.Printf(" Contract address:\t%s\n", h.environmentSet.WorkflowRegistryAddress) - fmt.Printf(" Transaction hash:\t%s\n", txOut.Hash) - fmt.Printf(" Workflow Name:\t%s\n", workflowName) - fmt.Printf(" Workflow ID:\t%s\n", hex.EncodeToString(latest.WorkflowId[:])) + ui.Success(fmt.Sprintf("Transaction confirmed: %s", txOut.Hash)) + ui.URL(fmt.Sprintf("%s/tx/%s", h.environmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash)) + ui.Line() + ui.Success("Workflow activated successfully") + ui.Dim(fmt.Sprintf(" Contract address: %s", h.environmentSet.WorkflowRegistryAddress)) + ui.Dim(fmt.Sprintf(" Transaction hash: %s", txOut.Hash)) + ui.Dim(fmt.Sprintf(" Workflow Name: %s", workflowName)) + ui.Dim(fmt.Sprintf(" Workflow ID: %s", hex.EncodeToString(latest.WorkflowId[:]))) case client.Raw: - fmt.Println("") - fmt.Println("MSIG workflow activation transaction prepared!") - fmt.Printf("To Activate %s with workflowID: %s\n", workflowName, hex.EncodeToString(latest.WorkflowId[:])) - fmt.Println("") - fmt.Println("Next steps:") - fmt.Println("") - fmt.Println(" 1. Submit the following transaction on the target chain:") - fmt.Printf(" Chain: %s\n", h.inputs.WorkflowRegistryContractChainName) - fmt.Printf(" Contract Address: %s\n", txOut.RawTx.To) - fmt.Println("") - fmt.Println(" 2. Use the following transaction data:") - fmt.Println("") - fmt.Printf(" %x\n", txOut.RawTx.Data) - fmt.Println("") + ui.Line() + ui.Success("MSIG workflow activation transaction prepared!") + ui.Dim(fmt.Sprintf("To Activate %s with workflowID: %s", workflowName, hex.EncodeToString(latest.WorkflowId[:]))) + ui.Line() + ui.Bold("Next steps:") + ui.Line() + ui.Print(" 1. Submit the following transaction on the target chain:") + ui.Dim(fmt.Sprintf(" Chain: %s", h.inputs.WorkflowRegistryContractChainName)) + ui.Dim(fmt.Sprintf(" Contract Address: %s", txOut.RawTx.To)) + ui.Line() + ui.Print(" 2. Use the following transaction data:") + ui.Line() + ui.Code(fmt.Sprintf(" %x", txOut.RawTx.Data)) + ui.Line() case client.Changeset: chainSelector, err := settings.GetChainSelectorByChainName(h.environmentSet.WorkflowRegistryChainName) @@ -211,7 +213,7 @@ func (h *handler) Execute() error { } mcmsConfig, err := settings.GetMCMSConfig(h.settings, chainSelector) if err != nil { - fmt.Println("\nMCMS config not found or is incorrect, skipping MCMS config in changeset") + ui.Warning("MCMS config not found or is incorrect, skipping MCMS config in changeset") } cldSettings := h.settings.CLDSettings changesets := []types.Changeset{ @@ -246,7 +248,9 @@ func (h *handler) Execute() error { } func (h *handler) displayWorkflowDetails() { - fmt.Printf("\nActivating Workflow : \t %s\n", h.inputs.WorkflowName) - fmt.Printf("Target : \t\t %s\n", h.settings.User.TargetName) - fmt.Printf("Owner Address : \t %s\n\n", h.settings.Workflow.UserWorkflowSettings.WorkflowOwnerAddress) + ui.Line() + ui.Title(fmt.Sprintf("Activating Workflow: %s", h.inputs.WorkflowName)) + ui.Dim(fmt.Sprintf("Target: %s", h.settings.User.TargetName)) + ui.Dim(fmt.Sprintf("Owner Address: %s", h.settings.Workflow.UserWorkflowSettings.WorkflowOwnerAddress)) + ui.Line() } diff --git a/cmd/workflow/delete/delete.go b/cmd/workflow/delete/delete.go index 6d43f36f..b8fcb3dc 100644 --- a/cmd/workflow/delete/delete.go +++ b/cmd/workflow/delete/delete.go @@ -9,8 +9,8 @@ import ( "sync" "time" + "github.com/charmbracelet/huh" "github.com/ethereum/go-ethereum/common" - "github.com/jedib0t/go-pretty/v6/text" "github.com/rs/zerolog" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -19,10 +19,10 @@ import ( cmdCommon "github.com/smartcontractkit/cre-cli/cmd/common" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/environments" - "github.com/smartcontractkit/cre-cli/internal/prompt" "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/types" + "github.com/smartcontractkit/cre-cli/internal/ui" "github.com/smartcontractkit/cre-cli/internal/validation" ) @@ -150,24 +150,24 @@ func (h *handler) Execute() error { return fmt.Errorf("failed to get workflow list: %w", err) } if len(allWorkflows) == 0 { - fmt.Printf("No workflows found for name: %s\n", workflowName) + ui.Warning(fmt.Sprintf("No workflows found for name: %s", workflowName)) return nil } // Note: The way deploy is set up, there will only ever be one workflow in the command for now h.runtimeContext.Workflow.ID = hex.EncodeToString(allWorkflows[0].WorkflowId[:]) - fmt.Printf("Found %d workflow(s) to delete for name: %s\n", len(allWorkflows), workflowName) + ui.Bold(fmt.Sprintf("Found %d workflow(s) to delete for name: %s", len(allWorkflows), workflowName)) for i, wf := range allWorkflows { status := map[uint8]string{0: "ACTIVE", 1: "PAUSED"}[wf.Status] - fmt.Printf(" %d. Workflow\n", i+1) - fmt.Printf(" ID: %s\n", hex.EncodeToString(wf.WorkflowId[:])) - fmt.Printf(" Owner: %s\n", wf.Owner.Hex()) - fmt.Printf(" DON Family: %s\n", wf.DonFamily) - fmt.Printf(" Tag: %s\n", wf.Tag) - fmt.Printf(" Binary URL: %s\n", wf.BinaryUrl) - fmt.Printf(" Workflow Status: %s\n", status) - fmt.Println("") + ui.Print(fmt.Sprintf(" %d. Workflow", i+1)) + ui.Dim(fmt.Sprintf(" ID: %s", hex.EncodeToString(wf.WorkflowId[:]))) + ui.Dim(fmt.Sprintf(" Owner: %s", wf.Owner.Hex())) + ui.Dim(fmt.Sprintf(" DON Family: %s", wf.DonFamily)) + ui.Dim(fmt.Sprintf(" Tag: %s", wf.Tag)) + ui.Dim(fmt.Sprintf(" Binary URL: %s", wf.BinaryUrl)) + ui.Dim(fmt.Sprintf(" Workflow Status: %s", status)) + ui.Line() } shouldDeleteWorkflow, err := h.shouldDeleteWorkflow(h.inputs.SkipConfirmation, workflowName) @@ -175,11 +175,11 @@ func (h *handler) Execute() error { return err } if !shouldDeleteWorkflow { - fmt.Println("Workflow deletion canceled") + ui.Warning("Workflow deletion canceled") return nil } - fmt.Printf("Deleting %d workflow(s)...\n", len(allWorkflows)) + ui.Dim(fmt.Sprintf("Deleting %d workflow(s)...", len(allWorkflows))) var errs []error for _, wf := range allWorkflows { txOut, err := h.wrc.DeleteWorkflow(wf.WorkflowId) @@ -193,24 +193,24 @@ func (h *handler) Execute() error { } switch txOut.Type { case client.Regular: - fmt.Println("Transaction confirmed") - fmt.Printf("View on explorer: \033]8;;%s/tx/%s\033\\%s/tx/%s\033]8;;\033\\\n", h.environmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash, h.environmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash) - fmt.Printf("[OK] Deleted workflow ID: %s\n", hex.EncodeToString(wf.WorkflowId[:])) + ui.Success("Transaction confirmed") + ui.URL(fmt.Sprintf("%s/tx/%s", h.environmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash)) + ui.Success(fmt.Sprintf("Deleted workflow ID: %s", hex.EncodeToString(wf.WorkflowId[:]))) case client.Raw: - fmt.Println("") - fmt.Println("MSIG workflow deletion transaction prepared!") - fmt.Println("") - fmt.Println("Next steps:") - fmt.Println("") - fmt.Println(" 1. Submit the following transaction on the target chain:") - fmt.Printf(" Chain: %s\n", h.inputs.WorkflowRegistryContractChainName) - fmt.Printf(" Contract Address: %s\n", txOut.RawTx.To) - fmt.Println("") - fmt.Println(" 2. Use the following transaction data:") - fmt.Println("") - fmt.Printf(" %x\n", txOut.RawTx.Data) - fmt.Println("") + ui.Line() + ui.Success("MSIG workflow deletion transaction prepared!") + ui.Line() + ui.Bold("Next steps:") + ui.Line() + ui.Print(" 1. Submit the following transaction on the target chain:") + ui.Dim(fmt.Sprintf(" Chain: %s", h.inputs.WorkflowRegistryContractChainName)) + ui.Dim(fmt.Sprintf(" Contract Address: %s", txOut.RawTx.To)) + ui.Line() + ui.Print(" 2. Use the following transaction data:") + ui.Line() + ui.Code(fmt.Sprintf(" %x", txOut.RawTx.Data)) + ui.Line() case client.Changeset: chainSelector, err := settings.GetChainSelectorByChainName(h.environmentSet.WorkflowRegistryChainName) @@ -219,7 +219,7 @@ func (h *handler) Execute() error { } mcmsConfig, err := settings.GetMCMSConfig(h.settings, chainSelector) if err != nil { - fmt.Println("\nMCMS config not found or is incorrect, skipping MCMS config in changeset") + ui.Warning("MCMS config not found or is incorrect, skipping MCMS config in changeset") } cldSettings := h.settings.CLDSettings changesets := []types.Changeset{ @@ -255,7 +255,7 @@ func (h *handler) Execute() error { if len(errs) > 0 { return fmt.Errorf("failed to delete some workflows: %w", errors.Join(errs...)) } - fmt.Println("Workflows deleted successfully.") + ui.Success("Workflows deleted successfully") return nil } @@ -272,16 +272,20 @@ func (h *handler) shouldDeleteWorkflow(skipConfirmation bool, workflowName strin } func (h *handler) askForWorkflowDeletionConfirmation(expectedWorkflowName string) (bool, error) { - promptWarning := fmt.Sprintf("Are you sure you want to delete the workflow '%s'?\n%s\n", expectedWorkflowName, text.FgRed.Sprint("This action cannot be undone.")) - fmt.Println(promptWarning) + ui.Warning(fmt.Sprintf("Are you sure you want to delete the workflow '%s'?", expectedWorkflowName)) + ui.Error("This action cannot be undone.") + ui.Line() - promptText := fmt.Sprintf("To confirm, type the workflow name: %s", expectedWorkflowName) var result string - err := prompt.SimplePrompt(h.stdin, promptText, func(input string) error { - result = input - return nil - }) - if err != nil { + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title(fmt.Sprintf("To confirm, type the workflow name: %s", expectedWorkflowName)). + Value(&result), + ), + ).WithTheme(ui.ChainlinkTheme()) + + if err := form.Run(); err != nil { return false, fmt.Errorf("failed to get workflow name confirmation: %w", err) } @@ -289,7 +293,9 @@ func (h *handler) askForWorkflowDeletionConfirmation(expectedWorkflowName string } func (h *handler) displayWorkflowDetails() { - fmt.Printf("\nDeleting Workflow : \t %s\n", h.inputs.WorkflowName) - fmt.Printf("Target : \t\t %s\n", h.settings.User.TargetName) - fmt.Printf("Owner Address : \t %s\n\n", h.settings.Workflow.UserWorkflowSettings.WorkflowOwnerAddress) + ui.Line() + ui.Title(fmt.Sprintf("Deleting Workflow: %s", h.inputs.WorkflowName)) + ui.Dim(fmt.Sprintf("Target: %s", h.settings.User.TargetName)) + ui.Dim(fmt.Sprintf("Owner Address: %s", h.settings.Workflow.UserWorkflowSettings.WorkflowOwnerAddress)) + ui.Line() } diff --git a/cmd/workflow/deploy/artifacts.go b/cmd/workflow/deploy/artifacts.go index 3e07a3f6..f5a6a838 100644 --- a/cmd/workflow/deploy/artifacts.go +++ b/cmd/workflow/deploy/artifacts.go @@ -6,6 +6,7 @@ import ( "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" "github.com/smartcontractkit/cre-cli/internal/client/storageclient" "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/internal/ui" ) func (h *handler) uploadArtifacts() error { @@ -35,22 +36,22 @@ func (h *handler) uploadArtifacts() error { storageClient.SetHTTPTimeout(h.settings.StorageSettings.CREStorage.HTTPTimeout) } - fmt.Printf("✔ Loaded binary from: %s\n", h.inputs.OutputPath) + ui.Success(fmt.Sprintf("Loaded binary from: %s", h.inputs.OutputPath)) binaryURL, err := storageClient.UploadArtifactWithRetriesAndGetURL( workflowID, storageclient.ArtifactTypeBinary, binaryData, "application/octet-stream") if err != nil { return fmt.Errorf("uploading binary artifact: %w", err) } - fmt.Printf("✔ Uploaded binary to: %s\n", binaryURL.UnsignedGetUrl) + ui.Success(fmt.Sprintf("Uploaded binary to: %s", binaryURL.UnsignedGetUrl)) h.log.Debug().Str("URL", binaryURL.UnsignedGetUrl).Msg("Successfully uploaded workflow binary to CRE Storage Service") if len(configData) > 0 { - fmt.Printf("✔ Loaded config from: %s\n", h.inputs.ConfigPath) + ui.Success(fmt.Sprintf("Loaded config from: %s", h.inputs.ConfigPath)) configURL, err = storageClient.UploadArtifactWithRetriesAndGetURL( workflowID, storageclient.ArtifactTypeConfig, configData, "text/plain") if err != nil { return fmt.Errorf("uploading config artifact: %w", err) } - fmt.Printf("✔ Uploaded config to: %s\n", configURL.UnsignedGetUrl) + ui.Success(fmt.Sprintf("Uploaded config to: %s", configURL.UnsignedGetUrl)) h.log.Debug().Str("URL", configURL.UnsignedGetUrl).Msg("Successfully uploaded workflow config to CRE Storage Service") } diff --git a/cmd/workflow/deploy/autoLink.go b/cmd/workflow/deploy/autoLink.go index 2e15af28..48eae2fa 100644 --- a/cmd/workflow/deploy/autoLink.go +++ b/cmd/workflow/deploy/autoLink.go @@ -13,6 +13,7 @@ import ( linkkey "github.com/smartcontractkit/cre-cli/cmd/account/link_key" "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/ui" ) const ( @@ -28,7 +29,7 @@ func (h *handler) ensureOwnerLinkedOrFail() error { return fmt.Errorf("failed to check owner link status: %w", err) } - fmt.Printf("Workflow owner link status: owner=%s, linked=%v\n", ownerAddr.Hex(), linked) + ui.Dim(fmt.Sprintf("Workflow owner link status: owner=%s, linked=%v", ownerAddr.Hex(), linked)) if linked { // Owner is linked on contract, now verify it's linked to the current user's account @@ -41,16 +42,16 @@ func (h *handler) ensureOwnerLinkedOrFail() error { return fmt.Errorf("key %s is linked to another account. Please use a different owner address", ownerAddr.Hex()) } - fmt.Println("Key ownership verified") + ui.Success("Key ownership verified") return nil } - fmt.Printf("Owner not linked. Attempting auto-link: owner=%s\n", ownerAddr.Hex()) + ui.Dim(fmt.Sprintf("Owner not linked. Attempting auto-link: owner=%s", ownerAddr.Hex())) if err := h.tryAutoLink(); err != nil { return fmt.Errorf("auto-link attempt failed: %w", err) } - fmt.Printf("Auto-link successful: owner=%s\n", ownerAddr.Hex()) + ui.Success(fmt.Sprintf("Auto-link successful: owner=%s", ownerAddr.Hex())) // Wait for linking process to complete if err := h.waitForBackendLinkProcessing(ownerAddr); err != nil { @@ -80,18 +81,18 @@ func (h *handler) autoLinkMSIGAndExit() (halt bool, err error) { return false, fmt.Errorf("MSIG key %s is linked to another account. Please use a different owner address", ownerAddr.Hex()) } - fmt.Printf("MSIG key ownership verified. Continuing deploy: owner=%s\n", ownerAddr.Hex()) + ui.Success(fmt.Sprintf("MSIG key ownership verified. Continuing deploy: owner=%s", ownerAddr.Hex())) return false, nil } - fmt.Printf("MSIG workflow owner link status: owner=%s, linked=%v\n", ownerAddr.Hex(), linked) - fmt.Printf("MSIG owner: attempting auto-link... owner=%s\n", ownerAddr.Hex()) + ui.Dim(fmt.Sprintf("MSIG workflow owner link status: owner=%s, linked=%v", ownerAddr.Hex(), linked)) + ui.Dim(fmt.Sprintf("MSIG owner: attempting auto-link... owner=%s", ownerAddr.Hex())) if err := h.tryAutoLink(); err != nil { return false, fmt.Errorf("MSIG auto-link attempt failed: %w", err) } - fmt.Println("MSIG auto-link initiated. Halting deploy. Submit the multisig transaction, then re-run deploy.") + ui.Warning("MSIG auto-link initiated. Halting deploy. Submit the multisig transaction, then re-run deploy.") return true, nil } @@ -174,11 +175,11 @@ func (h *handler) waitForBackendLinkProcessing(ownerAddr common.Address) error { const retryDelay = 3 * time.Second const initialBlockWait = 36 * time.Second // Wait for 3 block confirmations (~12s per block) - fmt.Println("") - fmt.Println("✓ Transaction confirmed on-chain.") - fmt.Println(" Waiting for 3 block confirmations before verification completes...") - fmt.Println(" Note: This is a one-time linking process. Future deployments from this address will not require this step.") - fmt.Println("") + ui.Line() + ui.Success("Transaction confirmed on-chain.") + ui.Dim(" Waiting for 3 block confirmations before verification completes...") + ui.Dim(" Note: This is a one-time linking process. Future deployments from this address will not require this step.") + ui.Line() // Wait for 3 block confirmations before polling time.Sleep(initialBlockWait) @@ -201,7 +202,7 @@ func (h *handler) waitForBackendLinkProcessing(ownerAddr common.Address) error { retry.LastErrorOnly(true), retry.OnRetry(func(n uint, err error) { h.log.Debug().Uint("attempt", n+1).Uint("maxAttempts", maxAttempts).Err(err).Msg("Retrying link status check") - fmt.Printf(" Waiting for verification... (attempt %d/%d)\n", n+1, maxAttempts) + ui.Dim(fmt.Sprintf(" Waiting for verification... (attempt %d/%d)", n+1, maxAttempts)) }), ) @@ -209,6 +210,6 @@ func (h *handler) waitForBackendLinkProcessing(ownerAddr common.Address) error { return fmt.Errorf("linking process timeout after %d attempts: %w", maxAttempts, err) } - fmt.Printf("✓ Linking verified: owner=%s\n", ownerAddr.Hex()) + ui.Success(fmt.Sprintf("Linking verified: owner=%s", ownerAddr.Hex())) return nil } diff --git a/cmd/workflow/deploy/compile.go b/cmd/workflow/deploy/compile.go index 51387569..d18de7d4 100644 --- a/cmd/workflow/deploy/compile.go +++ b/cmd/workflow/deploy/compile.go @@ -13,13 +13,14 @@ import ( cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/ui" ) func (h *handler) Compile() error { if !h.validated { return fmt.Errorf("handler h.inputs not validated") } - fmt.Println("Compiling workflow...") + ui.Dim("Compiling workflow...") if h.inputs.OutputPath == "" { h.inputs.OutputPath = defaultOutputPath @@ -74,13 +75,14 @@ func (h *handler) Compile() error { buildOutput, err := buildCmd.CombinedOutput() if err != nil { - fmt.Println(string(buildOutput)) + ui.Error("Build failed:") + ui.Print(string(buildOutput)) out := strings.TrimSpace(string(buildOutput)) return fmt.Errorf("failed to compile workflow: %w\nbuild output:\n%s", err, out) } h.log.Debug().Msgf("Build output: %s", buildOutput) - fmt.Println("Workflow compiled successfully") + ui.Success("Workflow compiled successfully") tmpWasmLocation := filepath.Join(workflowRootFolder, tmpWasmFileName) wasmFile, err := os.ReadFile(tmpWasmLocation) diff --git a/cmd/workflow/deploy/deploy.go b/cmd/workflow/deploy/deploy.go index 2839ae68..59c303d8 100644 --- a/cmd/workflow/deploy/deploy.go +++ b/cmd/workflow/deploy/deploy.go @@ -4,9 +4,9 @@ import ( "errors" "fmt" "io" - "os" "sync" + "github.com/charmbracelet/huh" "github.com/ethereum/go-ethereum/common" "github.com/rs/zerolog" "github.com/spf13/cobra" @@ -16,9 +16,9 @@ import ( "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/environments" - "github.com/smartcontractkit/cre-cli/internal/prompt" "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/internal/ui" "github.com/smartcontractkit/cre-cli/internal/validation" ) @@ -194,7 +194,8 @@ func (h *handler) Execute() error { return h.wrcErr } - fmt.Println("\nVerifying ownership...") + ui.Line() + ui.Dim("Verifying ownership...") if h.settings.Workflow.UserWorkflowSettings.WorkflowOwnerType == constants.WorkflowOwnerTypeMSIG { halt, err := h.autoLinkMSIGAndExit() if err != nil { @@ -212,12 +213,19 @@ func (h *handler) Execute() error { existsErr := h.workflowExists() if existsErr != nil { if existsErr.Error() == "workflow with name "+h.inputs.WorkflowName+" already exists" { - fmt.Printf("Workflow %s already exists\n", h.inputs.WorkflowName) - fmt.Println("This will update the existing workflow.") + ui.Warning(fmt.Sprintf("Workflow %s already exists", h.inputs.WorkflowName)) + ui.Dim("This will update the existing workflow.") // Ask for user confirmation before updating existing workflow if !h.inputs.SkipConfirmation { - confirm, err := prompt.YesNoPrompt(os.Stdin, "Are you sure you want to overwrite the workflow?") - if err != nil { + var confirm bool + confirmForm := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Are you sure you want to overwrite the workflow?"). + Value(&confirm), + ), + ).WithTheme(ui.ChainlinkTheme()) + if err := confirmForm.Run(); err != nil { return err } if !confirm { @@ -241,11 +249,13 @@ func (h *handler) Execute() error { return err } - fmt.Println("\nUploading files...") + ui.Line() + ui.Dim("Uploading files...") if err := h.uploadArtifacts(); err != nil { return fmt.Errorf("failed to upload workflow: %w", err) } - fmt.Println("\nPreparing deployment transaction...") + ui.Line() + ui.Dim("Preparing deployment transaction...") if err := h.upsert(); err != nil { return fmt.Errorf("failed to register workflow: %w", err) } @@ -270,7 +280,9 @@ func (h *handler) workflowExists() error { } func (h *handler) displayWorkflowDetails() { - fmt.Printf("\nDeploying Workflow : \t %s\n", h.inputs.WorkflowName) - fmt.Printf("Target : \t\t %s\n", h.settings.User.TargetName) - fmt.Printf("Owner Address : \t %s\n\n", h.settings.Workflow.UserWorkflowSettings.WorkflowOwnerAddress) + ui.Line() + ui.Title(fmt.Sprintf("Deploying Workflow: %s", h.inputs.WorkflowName)) + ui.Dim(fmt.Sprintf("Target: %s", h.settings.User.TargetName)) + ui.Dim(fmt.Sprintf("Owner Address: %s", h.settings.Workflow.UserWorkflowSettings.WorkflowOwnerAddress)) + ui.Line() } diff --git a/cmd/workflow/deploy/register.go b/cmd/workflow/deploy/register.go index 48003432..4042c9db 100644 --- a/cmd/workflow/deploy/register.go +++ b/cmd/workflow/deploy/register.go @@ -11,6 +11,7 @@ import ( cmdCommon "github.com/smartcontractkit/cre-cli/cmd/common" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/types" + "github.com/smartcontractkit/cre-cli/internal/ui" ) func (h *handler) upsert() error { @@ -42,7 +43,7 @@ func (h *handler) prepareUpsertParams() (client.RegisterWorkflowV2Parameters, er status = *h.existingWorkflowStatus } - fmt.Printf("Preparing transaction for workflowID: %s\n", workflowID) + ui.Dim(fmt.Sprintf("Preparing transaction for workflowID: %s", workflowID)) return client.RegisterWorkflowV2Parameters{ WorkflowName: workflowName, Tag: workflowTag, @@ -66,34 +67,36 @@ func (h *handler) handleUpsert(params client.RegisterWorkflowV2Parameters) error } switch txOut.Type { case client.Regular: - fmt.Println("Transaction confirmed") - fmt.Printf("View on explorer: \033]8;;%s/tx/%s\033\\%s/tx/%s\033]8;;\033\\\n", h.environmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash, h.environmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash) - fmt.Println("\n[OK] Workflow deployed successfully") - fmt.Println("\nDetails:") - fmt.Printf(" Contract address:\t%s\n", h.environmentSet.WorkflowRegistryAddress) - fmt.Printf(" Transaction hash:\t%s\n", txOut.Hash) - fmt.Printf(" Workflow Name:\t%s\n", workflowName) - fmt.Printf(" Workflow ID:\t%s\n", h.workflowArtifact.WorkflowID) - fmt.Printf(" Binary URL:\t%s\n", h.inputs.BinaryURL) + ui.Success("Transaction confirmed") + ui.URL(fmt.Sprintf("%s/tx/%s", h.environmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash)) + ui.Line() + ui.Success("Workflow deployed successfully") + ui.Line() + ui.Bold("Details:") + ui.Dim(fmt.Sprintf(" Contract address: %s", h.environmentSet.WorkflowRegistryAddress)) + ui.Dim(fmt.Sprintf(" Transaction hash: %s", txOut.Hash)) + ui.Dim(fmt.Sprintf(" Workflow Name: %s", workflowName)) + ui.Dim(fmt.Sprintf(" Workflow ID: %s", h.workflowArtifact.WorkflowID)) + ui.Dim(fmt.Sprintf(" Binary URL: %s", h.inputs.BinaryURL)) if h.inputs.ConfigURL != nil && *h.inputs.ConfigURL != "" { - fmt.Printf(" Config URL:\t%s\n", *h.inputs.ConfigURL) + ui.Dim(fmt.Sprintf(" Config URL: %s", *h.inputs.ConfigURL)) } case client.Raw: - fmt.Println("") - fmt.Println("MSIG workflow deployment transaction prepared!") - fmt.Printf("To Deploy %s:%s with workflow ID: %s\n", workflowName, workflowTag, hex.EncodeToString(params.WorkflowID[:])) - fmt.Println("") - fmt.Println("Next steps:") - fmt.Println("") - fmt.Println(" 1. Submit the following transaction on the target chain:") - fmt.Printf(" Chain: %s\n", h.inputs.WorkflowRegistryContractChainName) - fmt.Printf(" Contract Address: %s\n", txOut.RawTx.To) - fmt.Println("") - fmt.Println(" 2. Use the following transaction data:") - fmt.Println("") - fmt.Printf(" %x\n", txOut.RawTx.Data) - fmt.Println("") + ui.Line() + ui.Success("MSIG workflow deployment transaction prepared!") + ui.Dim(fmt.Sprintf("To Deploy %s:%s with workflow ID: %s", workflowName, workflowTag, hex.EncodeToString(params.WorkflowID[:]))) + ui.Line() + ui.Bold("Next steps:") + ui.Line() + ui.Print(" 1. Submit the following transaction on the target chain:") + ui.Dim(fmt.Sprintf(" Chain: %s", h.inputs.WorkflowRegistryContractChainName)) + ui.Dim(fmt.Sprintf(" Contract Address: %s", txOut.RawTx.To)) + ui.Line() + ui.Print(" 2. Use the following transaction data:") + ui.Line() + ui.Code(fmt.Sprintf(" %x", txOut.RawTx.Data)) + ui.Line() case client.Changeset: chainSelector, err := settings.GetChainSelectorByChainName(h.environmentSet.WorkflowRegistryChainName) @@ -102,7 +105,7 @@ func (h *handler) handleUpsert(params client.RegisterWorkflowV2Parameters) error } mcmsConfig, err := settings.GetMCMSConfig(h.settings, chainSelector) if err != nil { - fmt.Println("\nMCMS config not found or is incorrect, skipping MCMS config in changeset") + ui.Warning("MCMS config not found or is incorrect, skipping MCMS config in changeset") } cldSettings := h.settings.CLDSettings changesets := []types.Changeset{ diff --git a/cmd/workflow/pause/pause.go b/cmd/workflow/pause/pause.go index 1077eb85..a1564764 100644 --- a/cmd/workflow/pause/pause.go +++ b/cmd/workflow/pause/pause.go @@ -20,6 +20,7 @@ import ( "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/types" + "github.com/smartcontractkit/cre-cli/internal/ui" "github.com/smartcontractkit/cre-cli/internal/validation" ) @@ -140,7 +141,7 @@ func (h *handler) Execute() error { return h.wrcErr } - fmt.Printf("Fetching workflows to pause... Name=%s, Owner=%s\n", workflowName, workflowOwner.Hex()) + ui.Dim(fmt.Sprintf("Fetching workflows to pause... Name=%s, Owner=%s", workflowName, workflowOwner.Hex())) workflows, err := fetchAllWorkflows(h.wrc, workflowOwner, workflowName) if err != nil { @@ -165,7 +166,7 @@ func (h *handler) Execute() error { // Note: The way deploy is set up, there will only ever be one workflow in the command for now h.runtimeContext.Workflow.ID = hex.EncodeToString(activeWorkflowIDs[0][:]) - fmt.Printf("Processing batch pause... count=%d\n", len(activeWorkflowIDs)) + ui.Dim(fmt.Sprintf("Processing batch pause... count=%d", len(activeWorkflowIDs))) txOut, err := h.wrc.BatchPauseWorkflows(activeWorkflowIDs) if err != nil { @@ -174,32 +175,33 @@ func (h *handler) Execute() error { switch txOut.Type { case client.Regular: - fmt.Println("Transaction confirmed") - fmt.Printf("View on explorer: \033]8;;%s/tx/%s\033\\%s/tx/%s\033]8;;\033\\\n", h.environmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash, h.environmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash) - fmt.Println("[OK] Workflows paused successfully") - fmt.Println("\nDetails:") - fmt.Printf(" Contract address:\t%s\n", h.environmentSet.WorkflowRegistryAddress) - fmt.Printf(" Transaction hash:\t%s\n", txOut.Hash) - fmt.Printf(" Workflow Name:\t%s\n", workflowName) + ui.Success("Transaction confirmed") + ui.URL(fmt.Sprintf("%s/tx/%s", h.environmentSet.WorkflowRegistryChainExplorerURL, txOut.Hash)) + ui.Success("Workflows paused successfully") + ui.Line() + ui.Bold("Details:") + ui.Dim(fmt.Sprintf(" Contract address: %s", h.environmentSet.WorkflowRegistryAddress)) + ui.Dim(fmt.Sprintf(" Transaction hash: %s", txOut.Hash)) + ui.Dim(fmt.Sprintf(" Workflow Name: %s", workflowName)) for _, w := range activeWorkflowIDs { - fmt.Printf(" Workflow ID:\t%s\n", hex.EncodeToString(w[:])) + ui.Dim(fmt.Sprintf(" Workflow ID: %s", hex.EncodeToString(w[:]))) } case client.Raw: - fmt.Println("") - fmt.Println("MSIG workflow pause transaction prepared!") - fmt.Printf("To Pause %s\n", workflowName) - fmt.Println("") - fmt.Println("Next steps:") - fmt.Println("") - fmt.Println(" 1. Submit the following transaction on the target chain:") - fmt.Printf(" Chain: %s\n", h.inputs.WorkflowRegistryContractChainName) - fmt.Printf(" Contract Address: %s\n", txOut.RawTx.To) - fmt.Println("") - fmt.Println(" 2. Use the following transaction data:") - fmt.Println("") - fmt.Printf(" %x\n", txOut.RawTx.Data) - fmt.Println("") + ui.Line() + ui.Success("MSIG workflow pause transaction prepared!") + ui.Dim(fmt.Sprintf("To Pause %s", workflowName)) + ui.Line() + ui.Bold("Next steps:") + ui.Line() + ui.Print(" 1. Submit the following transaction on the target chain:") + ui.Dim(fmt.Sprintf(" Chain: %s", h.inputs.WorkflowRegistryContractChainName)) + ui.Dim(fmt.Sprintf(" Contract Address: %s", txOut.RawTx.To)) + ui.Line() + ui.Print(" 2. Use the following transaction data:") + ui.Line() + ui.Code(fmt.Sprintf(" %x", txOut.RawTx.Data)) + ui.Line() case client.Changeset: chainSelector, err := settings.GetChainSelectorByChainName(h.environmentSet.WorkflowRegistryChainName) @@ -208,7 +210,7 @@ func (h *handler) Execute() error { } mcmsConfig, err := settings.GetMCMSConfig(h.settings, chainSelector) if err != nil { - fmt.Println("\nMCMS config not found or is incorrect, skipping MCMS config in changeset") + ui.Warning("MCMS config not found or is incorrect, skipping MCMS config in changeset") } cldSettings := h.settings.CLDSettings changesets := []types.Changeset{ @@ -276,7 +278,9 @@ func fetchAllWorkflows( } func (h *handler) displayWorkflowDetails() { - fmt.Printf("\nPausing Workflow : \t %s\n", h.inputs.WorkflowName) - fmt.Printf("Target : \t\t %s\n", h.settings.User.TargetName) - fmt.Printf("Owner Address : \t %s\n\n", h.settings.Workflow.UserWorkflowSettings.WorkflowOwnerAddress) + ui.Line() + ui.Title(fmt.Sprintf("Pausing Workflow: %s", h.inputs.WorkflowName)) + ui.Dim(fmt.Sprintf("Target: %s", h.settings.User.TargetName)) + ui.Dim(fmt.Sprintf("Owner Address: %s", h.settings.Workflow.UserWorkflowSettings.WorkflowOwnerAddress)) + ui.Line() } diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index bd62e471..373a82bf 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -1,7 +1,6 @@ package simulate import ( - "bufio" "context" "crypto/ecdsa" "encoding/json" @@ -17,6 +16,7 @@ import ( "syscall" "time" + "github.com/charmbracelet/huh" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient" @@ -43,6 +43,7 @@ import ( "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/ui" "github.com/smartcontractkit/cre-cli/internal/validation" ) @@ -129,7 +130,7 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) c, err := ethclient.Dial(rpcURL) if err != nil { - fmt.Printf("failed to create eth client for %s: %v\n", chainName, err) + ui.Warning(fmt.Sprintf("Failed to create eth client for %s: %v", chainName, err)) continue } @@ -175,7 +176,7 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) } // Different forwarder - respect user's config, warn about override - fmt.Printf("Warning: experimental chain %d overrides supported chain forwarder (supported: %s, experimental: %s)\n", ec.ChainID, supportedForwarder, ec.Forwarder) + ui.Warning(fmt.Sprintf("Experimental chain %d overrides supported chain forwarder (supported: %s, experimental: %s)", ec.ChainID, supportedForwarder, ec.Forwarder)) // Use existing client but override the forwarder experimentalForwarders[ec.ChainID] = expFwd @@ -190,7 +191,7 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) clients[ec.ChainID] = c experimentalForwarders[ec.ChainID] = common.HexToAddress(ec.Forwarder) - fmt.Printf("Added experimental chain (chain-id: %d)\n", ec.ChainID) + ui.Dim(fmt.Sprintf("Added experimental chain (chain-id: %d)", ec.ChainID)) } if len(clients) == 0 { @@ -207,7 +208,7 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) if err != nil { return Inputs{}, fmt.Errorf("failed to parse default private key. Please set CRE_ETH_PRIVATE_KEY in your .env file or system environment: %w", err) } - fmt.Println("Warning: using default private key for chain write simulation. To use your own key, set CRE_ETH_PRIVATE_KEY in your .env file or system environment.") + ui.Warning("Using default private key for chain write simulation. To use your own key, set CRE_ETH_PRIVATE_KEY in your .env file or system environment.") } return Inputs{ @@ -243,10 +244,13 @@ func (h *handler) ValidateInputs(inputs Inputs) error { return fmt.Errorf("you must configure a valid private key to perform on-chain writes. Please set your private key in the .env file before using the -–broadcast flag") } - if err := runRPCHealthCheck(inputs.EVMClients, inputs.ExperimentalForwarders); err != nil { + rpcErr := ui.WithSpinner("Checking RPC connectivity...", func() error { + return runRPCHealthCheck(inputs.EVMClients, inputs.ExperimentalForwarders) + }) + if rpcErr != nil { // we don't block execution, just show the error to the user // because some RPCs in settings might not be used in workflow and some RPCs might have hiccups - fmt.Printf("Warning: some RPCs in settings are not functioning properly, please check: %v\n", err) + ui.Warning(fmt.Sprintf("Some RPCs in settings are not functioning properly, please check: %v", rpcErr)) } h.validated = true @@ -285,15 +289,19 @@ func (h *handler) Execute(inputs Inputs) error { Str("Command", buildCmd.String()). Msg("Executing go build command") - // Execute the build command + // Execute the build command with spinner + spinner := ui.NewSpinner() + spinner.Start("Compiling workflow...") buildOutput, err := buildCmd.CombinedOutput() + spinner.Stop() + if err != nil { out := strings.TrimSpace(string(buildOutput)) h.log.Info().Msg(out) return fmt.Errorf("failed to compile workflow: %w\nbuild output:\n%s", err, out) } h.log.Debug().Msgf("Build output: %s", buildOutput) - fmt.Println("Workflow compiled") + ui.Success("Workflow compiled") // Read the compiled workflow binary tmpWasmLocation := filepath.Join(workflowRootFolder, tmpWasmFileName) @@ -374,7 +382,7 @@ func run( bs := simulator.NewBillingService(billingLggr) err := bs.Start(ctx) if err != nil { - fmt.Printf("Failed to start billing service: %v\n", err) + ui.Error(fmt.Sprintf("Failed to start billing service: %v", err)) os.Exit(1) } @@ -385,7 +393,7 @@ func run( beholderLggr := lggr.Named("Beholder") err := setupCustomBeholder(beholderLggr, verbosity, simLogger) if err != nil { - fmt.Printf("Failed to setup beholder: %v\n", err) + ui.Error(fmt.Sprintf("Failed to setup beholder: %v", err)) os.Exit(1) } } @@ -413,27 +421,27 @@ func run( var err error triggerCaps, err = NewManualTriggerCapabilities(ctx, triggerLggr, registry, manualTriggerCapConfig, !inputs.Broadcast) if err != nil { - fmt.Printf("failed to create trigger capabilities: %v\n", err) + ui.Error(fmt.Sprintf("Failed to create trigger capabilities: %v", err)) os.Exit(1) } computeLggr := lggr.Named("ActionsCapabilities") computeCaps, err := NewFakeActionCapabilities(ctx, computeLggr, registry) if err != nil { - fmt.Printf("failed to create compute capabilities: %v\n", err) + ui.Error(fmt.Sprintf("Failed to create compute capabilities: %v", err)) os.Exit(1) } // Start trigger capabilities if err := triggerCaps.Start(ctx); err != nil { - fmt.Printf("failed to start trigger: %v\n", err) + ui.Error(fmt.Sprintf("Failed to start trigger: %v", err)) os.Exit(1) } // Start compute capabilities for _, cap := range computeCaps { if err = cap.Start(ctx); err != nil { - fmt.Printf("failed to start capability: %v\n", err) + ui.Error(fmt.Sprintf("Failed to start capability: %v", err)) os.Exit(1) } } @@ -521,11 +529,12 @@ func run( os.Exit(1) } simLogger.Info("Simulator Initialized") - fmt.Println() + ui.Line() close(initializedCh) }, OnExecutionError: func(msg string) { - fmt.Println("Workflow execution failed:\n", msg) + ui.Error("Workflow execution failed:") + ui.Print(msg) os.Exit(1) }, OnResultReceived: func(result *pb.ExecutionResult) { @@ -534,32 +543,33 @@ func run( return } - fmt.Println() + ui.Line() switch r := result.Result.(type) { case *pb.ExecutionResult_Value: v, err := values.FromProto(r.Value) if err != nil { - fmt.Println("Could not decode result") + ui.Error("Could not decode result") break } uw, err := v.Unwrap() if err != nil { - fmt.Printf("Could not unwrap result: %v", err) + ui.Error(fmt.Sprintf("Could not unwrap result: %v", err)) break } j, err := json.MarshalIndent(uw, "", " ") if err != nil { - fmt.Printf("Could not json marshal the result") + ui.Error("Could not json marshal the result") break } - fmt.Println("Workflow Simulation Result:\n", string(j)) + ui.Success("Workflow Simulation Result:") + ui.Print(string(j)) case *pb.ExecutionResult_Error: - fmt.Println("Execution resulted in an error being returned: " + r.Error) + ui.Error("Execution resulted in an error being returned: " + r.Error) } - fmt.Println() + ui.Line() close(executionFinishedCh) }, }, @@ -590,21 +600,35 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs triggerSub []*pb.TriggerSubscription, ) { if len(triggerSub) == 0 { - fmt.Println("Error in simulation. No workflow triggers found, please check your workflow source code and config") + ui.Error("No workflow triggers found, please check your workflow source code and config") os.Exit(1) } var triggerIndex int if len(triggerSub) > 1 { - // Present user with options and wait for selection - fmt.Println("\n🚀 Workflow simulation ready. Please select a trigger:") + // Build options for huh select + options := make([]huh.Option[int], len(triggerSub)) for i, trigger := range triggerSub { - fmt.Printf("%d. %s %s\n", i+1, trigger.GetId(), trigger.GetMethod()) + options[i] = huh.NewOption(fmt.Sprintf("%s %s", trigger.GetId(), trigger.GetMethod()), i) + } + + ui.Line() + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[int](). + Title("Workflow simulation ready. Please select a trigger:"). + Options(options...). + Value(&triggerIndex), + ), + ).WithTheme(ui.ChainlinkTheme()) + + if err := form.Run(); err != nil { + ui.Error(fmt.Sprintf("Trigger selection failed: %v", err)) + os.Exit(1) } - fmt.Printf("\nEnter your choice (1-%d): ", len(triggerSub)) - holder.TriggerToRun, triggerIndex = getUserTriggerChoice(ctx, triggerSub) - fmt.Println() + holder.TriggerToRun = triggerSub[triggerIndex] + ui.Line() } else { holder.TriggerToRun = triggerSub[0] } @@ -621,7 +645,7 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs case trigger == "http-trigger@1.0.0-alpha": payload, err := getHTTPTriggerPayload() if err != nil { - fmt.Printf("failed to get HTTP trigger payload: %v\n", err) + ui.Error(fmt.Sprintf("Failed to get HTTP trigger payload: %v", err)) os.Exit(1) } holder.TriggerFunc = func() error { @@ -631,31 +655,31 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs // Derive the chain selector directly from the selected trigger ID. sel, ok := parseChainSelectorFromTriggerID(holder.TriggerToRun.GetId()) if !ok { - fmt.Printf("could not determine chain selector from trigger id %q\n", holder.TriggerToRun.GetId()) + ui.Error(fmt.Sprintf("Could not determine chain selector from trigger id %q", holder.TriggerToRun.GetId())) os.Exit(1) } client := inputs.EVMClients[sel] if client == nil { - fmt.Printf("no RPC configured for chain selector %d\n", sel) + ui.Error(fmt.Sprintf("No RPC configured for chain selector %d", sel)) os.Exit(1) } log, err := getEVMTriggerLog(ctx, client) if err != nil { - fmt.Printf("failed to get EVM trigger log: %v\n", err) + ui.Error(fmt.Sprintf("Failed to get EVM trigger log: %v", err)) os.Exit(1) } evmChain := triggerCaps.ManualEVMChains[sel] if evmChain == nil { - fmt.Printf("no EVM chain initialized for selector %d\n", sel) + ui.Error(fmt.Sprintf("No EVM chain initialized for selector %d", sel)) os.Exit(1) } holder.TriggerFunc = func() error { return evmChain.ManualTrigger(ctx, triggerRegistrationID, log) } default: - fmt.Printf("unsupported trigger type: %s\n", holder.TriggerToRun.Id) + ui.Error(fmt.Sprintf("Unsupported trigger type: %s", holder.TriggerToRun.Id)) os.Exit(1) } } @@ -671,15 +695,15 @@ func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inp triggerSub []*pb.TriggerSubscription, ) { if len(triggerSub) == 0 { - fmt.Println("Error in simulation. No workflow triggers found, please check your workflow source code and config") + ui.Error("No workflow triggers found, please check your workflow source code and config") os.Exit(1) } if inputs.TriggerIndex < 0 { - fmt.Println("--trigger-index is required when --non-interactive is enabled") + ui.Error("--trigger-index is required when --non-interactive is enabled") os.Exit(1) } if inputs.TriggerIndex >= len(triggerSub) { - fmt.Printf("invalid --trigger-index %d; available range: 0-%d\n", inputs.TriggerIndex, len(triggerSub)-1) + ui.Error(fmt.Sprintf("Invalid --trigger-index %d; available range: 0-%d", inputs.TriggerIndex, len(triggerSub)-1)) os.Exit(1) } @@ -695,12 +719,12 @@ func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inp } case trigger == "http-trigger@1.0.0-alpha": if strings.TrimSpace(inputs.HTTPPayload) == "" { - fmt.Println("--http-payload is required for http-trigger@1.0.0-alpha in non-interactive mode") + ui.Error("--http-payload is required for http-trigger@1.0.0-alpha in non-interactive mode") os.Exit(1) } payload, err := getHTTPTriggerPayloadFromInput(inputs.HTTPPayload) if err != nil { - fmt.Printf("failed to parse HTTP trigger payload: %v\n", err) + ui.Error(fmt.Sprintf("Failed to parse HTTP trigger payload: %v", err)) os.Exit(1) } holder.TriggerFunc = func() error { @@ -708,37 +732,37 @@ func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inp } case strings.HasPrefix(trigger, "evm") && strings.HasSuffix(trigger, "@1.0.0"): if strings.TrimSpace(inputs.EVMTxHash) == "" || inputs.EVMEventIndex < 0 { - fmt.Println("--evm-tx-hash and --evm-event-index are required for EVM triggers in non-interactive mode") + ui.Error("--evm-tx-hash and --evm-event-index are required for EVM triggers in non-interactive mode") os.Exit(1) } sel, ok := parseChainSelectorFromTriggerID(holder.TriggerToRun.GetId()) if !ok { - fmt.Printf("could not determine chain selector from trigger id %q\n", holder.TriggerToRun.GetId()) + ui.Error(fmt.Sprintf("Could not determine chain selector from trigger id %q", holder.TriggerToRun.GetId())) os.Exit(1) } client := inputs.EVMClients[sel] if client == nil { - fmt.Printf("no RPC configured for chain selector %d\n", sel) + ui.Error(fmt.Sprintf("No RPC configured for chain selector %d", sel)) os.Exit(1) } log, err := getEVMTriggerLogFromValues(ctx, client, inputs.EVMTxHash, uint64(inputs.EVMEventIndex)) if err != nil { - fmt.Printf("failed to build EVM trigger log: %v\n", err) + ui.Error(fmt.Sprintf("Failed to build EVM trigger log: %v", err)) os.Exit(1) } evmChain := triggerCaps.ManualEVMChains[sel] if evmChain == nil { - fmt.Printf("no EVM chain initialized for selector %d\n", sel) + ui.Error(fmt.Sprintf("No EVM chain initialized for selector %d", sel)) os.Exit(1) } holder.TriggerFunc = func() error { return evmChain.ManualTrigger(ctx, triggerRegistrationID, log) } default: - fmt.Printf("unsupported trigger type: %s\n", holder.TriggerToRun.Id) + ui.Error(fmt.Sprintf("Unsupported trigger type: %s", holder.TriggerToRun.Id)) os.Exit(1) } } @@ -775,54 +799,23 @@ func cleanupBeholder() error { return nil } -// getUserTriggerChoice handles user input for trigger selection -func getUserTriggerChoice(ctx context.Context, triggerSub []*pb.TriggerSubscription) (*pb.TriggerSubscription, int) { - for { - inputCh := make(chan string, 1) - errCh := make(chan error, 1) - - go func() { - // create a fresh reader for each attempt - reader := bufio.NewReader(os.Stdin) - input, err := reader.ReadString('\n') - if err != nil { - errCh <- err - return - } - inputCh <- input - }() - - select { - case <-ctx.Done(): - fmt.Println("\nReceived interrupt signal, exiting.") - os.Exit(0) - case err := <-errCh: - fmt.Printf("Error reading input: %v\n", err) - os.Exit(1) - case input := <-inputCh: - choice := strings.TrimSpace(input) - choiceNum, err := strconv.Atoi(choice) - if err != nil || choiceNum < 1 || choiceNum > len(triggerSub) { - fmt.Printf("Invalid choice. Please enter 1-%d: ", len(triggerSub)) - continue - } - return triggerSub[choiceNum-1], (choiceNum - 1) - } - } -} - // getHTTPTriggerPayload prompts user for HTTP trigger data func getHTTPTriggerPayload() (*httptypedapi.Payload, error) { - fmt.Println("\n🔍 HTTP Trigger Configuration:") - fmt.Println("Please provide JSON input for the HTTP trigger.") - fmt.Println("You can enter a file path or JSON directly.") - fmt.Print("\nEnter your input: ") - - // Create a fresh reader - reader := bufio.NewReader(os.Stdin) - input, err := reader.ReadString('\n') - if err != nil { - return nil, fmt.Errorf("failed to read input: %w", err) + var input string + + ui.Line() + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("HTTP Trigger Configuration"). + Description("Enter a file path or JSON directly for the HTTP trigger"). + Placeholder(`{"key": "value"} or ./payload.json`). + Value(&input), + ), + ).WithTheme(ui.ChainlinkTheme()) + + if err := form.Run(); err != nil { + return nil, fmt.Errorf("HTTP trigger input cancelled: %w", err) } input = strings.TrimSpace(input) @@ -842,13 +835,13 @@ func getHTTPTriggerPayload() (*httptypedapi.Payload, error) { if err := json.Unmarshal(data, &jsonData); err != nil { return nil, fmt.Errorf("failed to parse JSON from file %s: %w", input, err) } - fmt.Printf("Loaded JSON from file: %s\n", input) + ui.Success(fmt.Sprintf("Loaded JSON from file: %s", input)) } else { // It's direct JSON input if err := json.Unmarshal([]byte(input), &jsonData); err != nil { return nil, fmt.Errorf("failed to parse JSON: %w", err) } - fmt.Println("Parsed JSON input successfully") + ui.Success("Parsed JSON input successfully") } jsonDataBytes, err := json.Marshal(jsonData) @@ -861,45 +854,61 @@ func getHTTPTriggerPayload() (*httptypedapi.Payload, error) { // Key is optional for simulation } - fmt.Printf("Created HTTP trigger payload with %d fields\n", len(jsonData)) + ui.Success(fmt.Sprintf("Created HTTP trigger payload with %d fields", len(jsonData))) return payload, nil } // getEVMTriggerLog prompts user for EVM trigger data and fetches the log func getEVMTriggerLog(ctx context.Context, ethClient *ethclient.Client) (*evm.Log, error) { - fmt.Println("\n🔗 EVM Trigger Configuration:") - fmt.Println("Please provide the transaction hash and event index for the EVM log event.") - - // Create a fresh reader - reader := bufio.NewReader(os.Stdin) - - // Get transaction hash - fmt.Print("Enter transaction hash (0x...): ") - txHashInput, err := reader.ReadString('\n') - if err != nil { - return nil, fmt.Errorf("failed to read transaction hash: %w", err) - } - txHashInput = strings.TrimSpace(txHashInput) + var txHashInput string + var eventIndexInput string + + ui.Line() + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("EVM Trigger Configuration"). + Description("Transaction hash for the EVM log event"). + Placeholder("0x..."). + Value(&txHashInput). + Validate(func(s string) error { + s = strings.TrimSpace(s) + if s == "" { + return fmt.Errorf("transaction hash cannot be empty") + } + if !strings.HasPrefix(s, "0x") { + return fmt.Errorf("transaction hash must start with 0x") + } + if len(s) != 66 { + return fmt.Errorf("invalid transaction hash length: expected 66 characters, got %d", len(s)) + } + return nil + }), + huh.NewInput(). + Title("Event Index"). + Description("Log event index (0-based)"). + Placeholder("0"). + Suggestions([]string{"0"}). + Value(&eventIndexInput). + Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return fmt.Errorf("event index cannot be empty") + } + if _, err := strconv.ParseUint(strings.TrimSpace(s), 10, 32); err != nil { + return fmt.Errorf("invalid event index: must be a number") + } + return nil + }), + ), + ).WithTheme(ui.ChainlinkTheme()).WithKeyMap(ui.ChainlinkKeyMap()) - if txHashInput == "" { - return nil, fmt.Errorf("transaction hash cannot be empty") - } - if !strings.HasPrefix(txHashInput, "0x") { - return nil, fmt.Errorf("transaction hash must start with 0x") - } - if len(txHashInput) != 66 { // 0x + 64 hex chars - return nil, fmt.Errorf("invalid transaction hash length: expected 66 characters, got %d", len(txHashInput)) + if err := form.Run(); err != nil { + return nil, fmt.Errorf("EVM trigger input cancelled: %w", err) } + txHashInput = strings.TrimSpace(txHashInput) txHash := common.HexToHash(txHashInput) - // Get event index - create fresh reader - fmt.Print("Enter event index (0-based): ") - reader = bufio.NewReader(os.Stdin) - eventIndexInput, err := reader.ReadString('\n') - if err != nil { - return nil, fmt.Errorf("failed to read event index: %w", err) - } eventIndexInput = strings.TrimSpace(eventIndexInput) eventIndex, err := strconv.ParseUint(eventIndexInput, 10, 32) if err != nil { @@ -907,8 +916,10 @@ func getEVMTriggerLog(ctx context.Context, ethClient *ethclient.Client) (*evm.Lo } // Fetch the transaction receipt - fmt.Printf("Fetching transaction receipt for transaction %s...\n", txHash.Hex()) + receiptSpinner := ui.NewSpinner() + receiptSpinner.Start(fmt.Sprintf("Fetching transaction receipt for %s...", txHash.Hex())) txReceipt, err := ethClient.TransactionReceipt(ctx, txHash) + receiptSpinner.Stop() if err != nil { return nil, fmt.Errorf("failed to fetch transaction receipt: %w", err) } @@ -919,7 +930,7 @@ func getEVMTriggerLog(ctx context.Context, ethClient *ethclient.Client) (*evm.Lo } log := txReceipt.Logs[eventIndex] - fmt.Printf("Found log event at index %d: contract=%s, topics=%d\n", eventIndex, log.Address.Hex(), len(log.Topics)) + ui.Success(fmt.Sprintf("Found log event at index %d: contract=%s, topics=%d", eventIndex, log.Address.Hex(), len(log.Topics))) // Check for potential uint32 overflow (prevents noisy linter warnings) var txIndex, logIndex uint32 @@ -955,7 +966,7 @@ func getEVMTriggerLog(ctx context.Context, ethClient *ethclient.Client) (*evm.Lo pbLog.EventSig = log.Topics[0].Bytes() } - fmt.Printf("Created EVM trigger log for transaction %s, event %d\n", txHash.Hex(), eventIndex) + ui.Success(fmt.Sprintf("Created EVM trigger log for transaction %s, event %d", txHash.Hex(), eventIndex)) return pbLog, nil } @@ -1013,7 +1024,10 @@ func getEVMTriggerLogFromValues(ctx context.Context, ethClient *ethclient.Client } txHash := common.HexToHash(txHashStr) + receiptSpinner := ui.NewSpinner() + receiptSpinner.Start(fmt.Sprintf("Fetching transaction receipt for %s...", txHash.Hex())) txReceipt, err := ethClient.TransactionReceipt(ctx, txHash) + receiptSpinner.Stop() if err != nil { return nil, fmt.Errorf("failed to fetch transaction receipt: %w", err) } diff --git a/cmd/workflow/simulate/simulate_logger.go b/cmd/workflow/simulate/simulate_logger.go index 56c64906..6fae563b 100644 --- a/cmd/workflow/simulate/simulate_logger.go +++ b/cmd/workflow/simulate/simulate_logger.go @@ -2,13 +2,14 @@ package simulate import ( "fmt" - "os" "reflect" "regexp" "strings" "time" - "github.com/fatih/color" + "github.com/charmbracelet/lipgloss" + + "github.com/smartcontractkit/cre-cli/internal/ui" ) // LogLevel represents the level of a simulation log @@ -21,14 +22,14 @@ const ( LogLevelError LogLevel = "ERROR" ) -// Color instances for consistent styling +// Style instances for consistent styling (using Chainlink Blocks palette) var ( - ColorBlue = color.New(color.FgBlue) - ColorBrightCyan = color.New(color.FgCyan, color.Bold) - ColorYellow = color.New(color.FgYellow) - ColorRed = color.New(color.FgRed) - ColorGreen = color.New(color.FgGreen) - ColorMagenta = color.New(color.FgMagenta) + StyleBlue = lipgloss.NewStyle().Foreground(lipgloss.Color(ui.ColorBlue500)) + StyleBrightCyan = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ui.ColorTeal400)) + StyleYellow = lipgloss.NewStyle().Foreground(lipgloss.Color(ui.ColorYellow400)) + StyleRed = lipgloss.NewStyle().Foreground(lipgloss.Color(ui.ColorRed400)) + StyleGreen = lipgloss.NewStyle().Foreground(lipgloss.Color(ui.ColorGreen400)) + StyleMagenta = lipgloss.NewStyle().Foreground(lipgloss.Color(ui.ColorPurple400)) ) // SimulationLogger provides an easy interface for formatted simulation logs @@ -38,9 +39,6 @@ type SimulationLogger struct { // NewSimulationLogger creates a new simulation logger with verbosity control func NewSimulationLogger(verbosity bool) *SimulationLogger { - // Smart color detection for end users - enableColors := shouldEnableColors() - color.NoColor = !enableColors return &SimulationLogger{verbosity: verbosity} } @@ -86,50 +84,55 @@ func (s *SimulationLogger) formatSimulationLog(level LogLevel, message string, f } } - // Get color for the log level - var levelColor *color.Color + // Get style for the log level + var levelStyle lipgloss.Style switch level { case LogLevelDebug: - levelColor = ColorBlue + levelStyle = StyleBlue case LogLevelInfo: - levelColor = ColorBrightCyan + levelStyle = StyleBrightCyan case LogLevelWarning: - levelColor = ColorYellow + levelStyle = StyleYellow case LogLevelError: - levelColor = ColorRed + levelStyle = StyleRed default: - levelColor = ColorBrightCyan + levelStyle = StyleBrightCyan } - // Format with timestamp and level-specific color - ColorBlue.Printf("%s ", timestamp) - levelColor.Printf("[SIMULATION]") - fmt.Printf(" %s\n", formattedMessage) + // Format with timestamp and level-specific style + fmt.Printf("%s %s %s\n", + StyleBlue.Render(timestamp), + levelStyle.Render("[SIMULATION]"), + formattedMessage) } -// PrintTimestampedLog prints a log with timestamp and colored prefix -func (s *SimulationLogger) PrintTimestampedLog(timestamp, prefix, message string, prefixColor *color.Color) { - ColorBlue.Printf("%s ", timestamp) - prefixColor.Printf("[%s]", prefix) - fmt.Printf(" %s\n", message) +// PrintTimestampedLog prints a log with timestamp and styled prefix +func (s *SimulationLogger) PrintTimestampedLog(timestamp, prefix, message string, prefixStyle lipgloss.Style) { + fmt.Printf("%s %s %s\n", + StyleBlue.Render(timestamp), + prefixStyle.Render("["+prefix+"]"), + message) } -// PrintTimestampedLogWithStatus prints a log with timestamp, prefix, and colored status +// PrintTimestampedLogWithStatus prints a log with timestamp, prefix, and styled status func (s *SimulationLogger) PrintTimestampedLogWithStatus(timestamp, prefix, message, status string) { - ColorBlue.Printf("%s ", timestamp) - ColorMagenta.Printf("[%s]", prefix) - fmt.Printf(" %s", message) - statusColor := GetColor(status) - statusColor.Printf("%s\n", status) + statusStyle := GetStyle(status) + fmt.Printf("%s %s %s%s\n", + StyleBlue.Render(timestamp), + StyleMagenta.Render("["+prefix+"]"), + message, + statusStyle.Render(status)) } -// PrintStepLog prints a capability step log with timestamp and colored status +// PrintStepLog prints a capability step log with timestamp and styled status func (s *SimulationLogger) PrintStepLog(timestamp, component, stepRef, capability, status string) { - ColorBlue.Printf("%s ", timestamp) - ColorBrightCyan.Printf("[%s]", component) - fmt.Printf(" step[%s] Capability: %s - ", stepRef, capability) - statusColor := GetColor(status) - statusColor.Printf("%s\n", status) + statusStyle := GetStyle(status) + fmt.Printf("%s %s step[%s] Capability: %s - %s\n", + StyleBlue.Render(timestamp), + StyleBrightCyan.Render("["+component+"]"), + stepRef, + capability, + statusStyle.Render(status)) } // PrintWorkflowMetadata prints workflow metadata with proper indentation @@ -189,33 +192,33 @@ func isEmptyValue(v interface{}) bool { } } -// GetColor returns the appropriate color for a given status/level -func GetColor(status string) *color.Color { +// GetStyle returns the appropriate style for a given status/level +func GetStyle(status string) lipgloss.Style { switch strings.ToUpper(status) { case "SUCCESS": - return ColorGreen + return StyleGreen case "FAILED", "ERROR", "ERRORED": - return ColorRed + return StyleRed case "WARNING", "WARN": - return ColorYellow + return StyleYellow case "DEBUG": - return ColorBlue + return StyleBlue case "INFO": - return ColorBrightCyan + return StyleBrightCyan case "WORKFLOW": // Added for workflow events - return ColorMagenta + return StyleMagenta default: - return ColorBrightCyan + return StyleBrightCyan } } // HighlightLogLevels highlights INFO, WARN, ERROR in log messages -func HighlightLogLevels(msg string, levelColor *color.Color) string { - // Replace level keywords with colored versions - msg = strings.ReplaceAll(msg, "level=INFO", levelColor.Sprint("level=INFO")) - msg = strings.ReplaceAll(msg, "level=WARN", levelColor.Sprint("level=WARN")) - msg = strings.ReplaceAll(msg, "level=ERROR", levelColor.Sprint("level=ERROR")) - msg = strings.ReplaceAll(msg, "level=DEBUG", levelColor.Sprint("level=DEBUG")) +func HighlightLogLevels(msg string, levelStyle lipgloss.Style) string { + // Replace level keywords with styled versions + msg = strings.ReplaceAll(msg, "level=INFO", levelStyle.Render("level=INFO")) + msg = strings.ReplaceAll(msg, "level=WARN", levelStyle.Render("level=WARN")) + msg = strings.ReplaceAll(msg, "level=ERROR", levelStyle.Render("level=ERROR")) + msg = strings.ReplaceAll(msg, "level=DEBUG", levelStyle.Render("level=DEBUG")) return msg } @@ -296,28 +299,3 @@ func MapCapabilityStatus(status string) string { return strings.ToUpper(status) } } - -// shouldEnableColors determines if colors should be enabled based on environment -func shouldEnableColors() bool { - // Check if explicitly disabled - if os.Getenv("NO_COLOR") != "" { - return false - } - - // Check if explicitly enabled - if os.Getenv("FORCE_COLOR") != "" { - return true - } - - // Check if we're in a CI environment (usually no colors) - ciEnvs := []string{"CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS", "TRAVIS", "CIRCLECI"} - for _, env := range ciEnvs { - if os.Getenv(env) != "" { - return false - } - } - - // Default to true - always enable colors for better user experience - // Users can disable with --no-color or NO_COLOR=1 - return true -} diff --git a/cmd/workflow/simulate/telemetry_writer.go b/cmd/workflow/simulate/telemetry_writer.go index 7b71c714..fc958ba1 100644 --- a/cmd/workflow/simulate/telemetry_writer.go +++ b/cmd/workflow/simulate/telemetry_writer.go @@ -3,7 +3,6 @@ package simulate import ( "encoding/base64" "encoding/json" - "fmt" "strings" "time" @@ -11,6 +10,8 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/logger" pb "github.com/smartcontractkit/chainlink-protos/workflows/go/events" + + "github.com/smartcontractkit/cre-cli/internal/ui" ) // entity types for clarity and organization @@ -187,11 +188,11 @@ func (w *telemetryWriter) handleWorkflowEvent(telLog TelemetryLog, eventType str return } timestamp := FormatTimestamp(workflowEvent.Timestamp) - w.simLogger.PrintTimestampedLog(timestamp, "WORKFLOW", "WorkflowExecutionStarted", ColorMagenta) + w.simLogger.PrintTimestampedLog(timestamp, "WORKFLOW", "WorkflowExecutionStarted", StyleMagenta) // Display trigger information if workflowEvent.TriggerID != "" { - fmt.Printf(" TriggerID: %s\n", workflowEvent.TriggerID) + ui.Printf(" TriggerID: %s\n", workflowEvent.TriggerID) } // Display workflow metadata if available w.simLogger.PrintWorkflowMetadata(workflowEvent.M) @@ -258,13 +259,13 @@ func (w *telemetryWriter) formatUserLogs(logs *pb.UserLogs) { // Format the log message level := GetLogLevel(logLine.Message) msg := CleanLogMessage(logLine.Message) - levelColor := GetColor(level) + levelStyle := GetStyle(level) // Highlight level keywords in the message - highlightedMsg := HighlightLogLevels(msg, levelColor) + highlightedMsg := HighlightLogLevels(msg, levelStyle) // Always use current timestamp for consistency with other logs - w.simLogger.PrintTimestampedLog(time.Now().Format("2006-01-02T15:04:05Z"), "USER LOG", highlightedMsg, ColorBrightCyan) + w.simLogger.PrintTimestampedLog(time.Now().Format("2006-01-02T15:04:05Z"), "USER LOG", highlightedMsg, StyleBrightCyan) } } diff --git a/go.mod b/go.mod index 93cd2248..6a6d1511 100644 --- a/go.mod +++ b/go.mod @@ -7,11 +7,12 @@ require ( github.com/Masterminds/semver/v3 v3.4.0 github.com/andybalholm/brotli v1.2.0 github.com/avast/retry-go/v4 v4.6.1 - github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 github.com/charmbracelet/bubbletea v1.3.6 + github.com/charmbracelet/huh v0.8.0 + github.com/charmbracelet/lipgloss v1.1.0 github.com/denisbrodbeck/machineid v1.0.1 github.com/ethereum/go-ethereum v1.16.8 - github.com/fatih/color v1.18.0 github.com/go-playground/locales v0.14.1 github.com/go-playground/universal-translator v0.18.1 github.com/go-playground/validator/v10 v10.28.0 @@ -20,7 +21,6 @@ require ( github.com/jedib0t/go-pretty/v6 v6.6.5 github.com/joho/godotenv v1.5.1 github.com/machinebox/graphql v0.2.2 - github.com/manifoldco/promptui v0.9.0 github.com/pkg/errors v0.9.1 github.com/rs/zerolog v1.34.0 github.com/smartcontractkit/chain-selectors v1.0.88 @@ -41,6 +41,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/test-go/testify v1.1.4 go.uber.org/zap v1.27.0 + golang.org/x/term v0.37.0 google.golang.org/protobuf v1.36.10 gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 @@ -95,15 +96,16 @@ require ( github.com/bytecodealliance/wasmtime-go/v28 v28.0.0 // indirect github.com/bytedance/sonic v1.12.3 // indirect github.com/bytedance/sonic/loader v0.2.0 // indirect + github.com/catppuccin/go v0.3.0 // indirect github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/cenkalti/backoff/v5 v5.0.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/x/ansi v0.9.3 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/chzyer/readline v1.5.1 // indirect github.com/cloudevents/sdk-go/binding/format/protobuf/v2 v2.16.1 // indirect github.com/cloudevents/sdk-go/v2 v2.16.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect @@ -147,6 +149,7 @@ require ( github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab // indirect github.com/ethereum/go-verkle v0.2.2 // indirect github.com/expr-lang/expr v1.17.7 // indirect + github.com/fatih/color v1.18.0 // indirect github.com/fbsobreira/gotron-sdk v0.0.0-20250403083053-2943ce8c759b // indirect github.com/ferranbt/fastssz v0.1.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -247,6 +250,7 @@ require ( github.com/minio/sha256-simd v1.0.1 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/mapstructure v1.5.1-0.20220423185008-bf980b35cac4 // indirect github.com/mitchellh/pointerstructure v1.2.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -389,7 +393,6 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/telemetry v0.0.0-20251208220230-2638a1023523 // indirect - golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.39.0 // indirect diff --git a/go.sum b/go.sum index da4a295b..0e85d58a 100644 --- a/go.sum +++ b/go.sum @@ -45,6 +45,8 @@ github.com/DataDog/zstd v1.5.6 h1:LbEglqepa/ipmmQJUDnSsfvA8e8IStVcGaFWDuxvGOY= github.com/DataDog/zstd v1.5.6/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/Depado/ginprom v1.8.0 h1:zaaibRLNI1dMiiuj1MKzatm8qrcHzikMlCc1anqOdyo= github.com/Depado/ginprom v1.8.0/go.mod h1:XBaKzeNBqPF4vxJpNLincSQZeMDnZp1tIbU0FU0UKgg= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= @@ -126,6 +128,8 @@ github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59 h1:WWB576BN5zNSZc github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df h1:GSoSVRLoBaFpOOds6QyY1L8AX7uoY+Ln3BHc22W40X0= @@ -190,6 +194,8 @@ github.com/bytedance/sonic v1.12.3/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKz github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM= github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -202,29 +208,39 @@ github.com/cespare/cp v1.1.1/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= -github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= +github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= -github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= -github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= -github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= @@ -308,6 +324,8 @@ github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOV github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI= github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0= github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI= @@ -805,8 +823,6 @@ github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8S github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= -github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/manyminds/api2go v0.0.0-20171030193247-e7b693844a6f h1:tVvGiZQFjOXP+9YyGqSA6jE55x1XVxmoPYudncxrZ8U= github.com/manyminds/api2go v0.0.0-20171030193247-e7b693844a6f/go.mod h1:Z60vy0EZVSu0bOugCHdcN5ZxFMKSpjRgsnh0XKPFqqk= github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0= @@ -858,6 +874,8 @@ github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJ github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.1-0.20220423185008-bf980b35cac4 h1:BpfhmLKZf+SjVanKKhCgf3bg+511DmU9eDQTen7LLbY= github.com/mitchellh/mapstructure v1.5.1-0.20220423185008-bf980b35cac4/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= @@ -1500,7 +1518,6 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190124100055-b90733256f2e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1542,7 +1559,6 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/prompt/prompt_unix.go b/internal/prompt/prompt_unix.go deleted file mode 100644 index 7007b72c..00000000 --- a/internal/prompt/prompt_unix.go +++ /dev/null @@ -1,97 +0,0 @@ -//go:build unix - -package prompt - -import ( - "bufio" - "errors" - "io" - "os" - "strings" - - "github.com/manifoldco/promptui" -) - -// TODO - Move to a single cross-platform implementation using Bubble Tea or any other library that works on both Unix and Windows. - -func SimplePrompt(reader io.Reader, promptText string, handler func(input string) error) error { - prompt := promptui.Prompt{ - Label: promptText, - Stdin: io.NopCloser(reader), - } - - result, err := prompt.Run() - if err != nil { - return err - } - - return handler(result) -} - -func SelectPrompt(reader io.Reader, promptText string, choices []string, handler func(choice string) error) error { - prompt := promptui.Select{ - Label: promptText, - Items: choices, - Stdin: io.NopCloser(reader), - } - - _, result, err := prompt.Run() - if err != nil { - return err - } - - return handler(result) -} - -func YesNoPrompt(reader io.Reader, promptText string) (bool, error) { - prompt := promptui.Select{ - Label: promptText, - Items: []string{"Yes", "No"}, - Stdin: io.NopCloser(reader), - } - - _, result, err := prompt.Run() - if err != nil { - return false, err - } - - return result == "Yes", nil -} - -func SecretPrompt(reader io.Reader, promptText string, handler func(input string) error) error { - prompt := promptui.Prompt{ - Label: promptText, - Mask: '*', // Mask input with '*' - Stdin: io.NopCloser(reader), - } - - // Run the prompt and get the result - result, err := prompt.Run() - if err != nil { - return err - } - - // Call the handler with the result - return handler(result) -} - -func UserPromptYesOrNoResponse() (bool, error) { - reader := bufio.NewReader(os.Stdin) - - input, err := reader.ReadString('\n') - if err != nil { - return false, err - } - - input = strings.TrimSpace(input) - input = strings.ToLower(input) - - switch input { - case "y", "yes", "": - return true, nil - case "n", "no": - return false, nil - default: - return false, errors.New("invalid input, please enter Y to continue or N to abort") - } -} diff --git a/internal/prompt/secret_windows.go b/internal/prompt/secret_windows.go deleted file mode 100644 index f577061c..00000000 --- a/internal/prompt/secret_windows.go +++ /dev/null @@ -1,31 +0,0 @@ -//go:build windows - -package prompt - -import ( - "io" - - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" -) - -// SecretPrompt using Bubble Tea -func SecretPrompt(reader io.Reader, promptText string, handler func(input string) error) error { - input := textinput.New() - input.Placeholder = promptText - input.Focus() - input.CharLimit = 256 - input.Width = 40 - input.EchoMode = textinput.EchoPassword - input.EchoCharacter = '*' - - model := &simplePromptModel{ - input: input, - promptText: promptText, - } - p := tea.NewProgram(model, tea.WithInput(reader)) - if _, err := p.Run(); err != nil { - return err - } - return handler(model.result) -} diff --git a/internal/prompt/select_windows.go b/internal/prompt/select_windows.go deleted file mode 100644 index 258621bf..00000000 --- a/internal/prompt/select_windows.go +++ /dev/null @@ -1,87 +0,0 @@ -//go:build windows - -package prompt - -import ( - "io" - "strings" - - tea "github.com/charmbracelet/bubbletea" -) - -type selectPromptModel struct { - choices []string - cursor int - promptText string - quitting bool -} - -func (m *selectPromptModel) Init() tea.Cmd { return nil } - -func (m *selectPromptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "up", "k": - if m.cursor > 0 { - m.cursor-- - } - case "down", "j": - if m.cursor < len(m.choices)-1 { - m.cursor++ - } - case "enter": - m.quitting = true - return m, tea.Quit - case "ctrl+c", "esc": - m.quitting = true - return m, tea.Quit - } - } - return m, nil -} - -func (m *selectPromptModel) View() string { - if m.quitting { - return "" - } - var b strings.Builder - b.WriteString(m.promptText + "\n") - for i, choice := range m.choices { - cursor := " " - if m.cursor == i { - cursor = ">" - } - b.WriteString(cursor + " " + choice + "\n") - } - return b.String() -} - -// SelectPrompt using Bubble Tea -func SelectPrompt(reader io.Reader, promptText string, choices []string, handler func(choice string) error) error { - model := &selectPromptModel{ - choices: choices, - cursor: 0, - promptText: promptText, - } - p := tea.NewProgram(model, tea.WithInput(reader)) - if _, err := p.Run(); err != nil { - return err - } - return handler(model.choices[model.cursor]) -} - -// YesNoPrompt using Bubble Tea -func YesNoPrompt(reader io.Reader, promptText string) (bool, error) { - choices := []string{"Yes", "No"} - model := &selectPromptModel{ - choices: choices, - cursor: 0, - promptText: promptText, - } - p := tea.NewProgram(model, tea.WithInput(reader)) - if _, err := p.Run(); err != nil { - return false, err - } - return model.choices[model.cursor] == "Yes", nil -} diff --git a/internal/prompt/simple_windows.go b/internal/prompt/simple_windows.go deleted file mode 100644 index 7de55637..00000000 --- a/internal/prompt/simple_windows.go +++ /dev/null @@ -1,65 +0,0 @@ -//go:build windows - -package prompt - -import ( - "io" - - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" -) - -type simplePromptModel struct { - input textinput.Model - promptText string - result string - quitting bool -} - -func (m *simplePromptModel) Init() tea.Cmd { - return textinput.Blink -} - -func (m *simplePromptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.Type { - case tea.KeyEnter: - m.result = m.input.Value() - m.quitting = true - return m, tea.Quit - case tea.KeyCtrlC, tea.KeyEsc: - m.quitting = true - return m, tea.Quit - } - } - var cmd tea.Cmd - m.input, cmd = m.input.Update(msg) - return m, cmd -} - -func (m *simplePromptModel) View() string { - if m.quitting { - return "" - } - return m.promptText + ": " + m.input.View() -} - -// SimplePrompt using Bubble Tea -func SimplePrompt(reader io.Reader, promptText string, handler func(input string) error) error { - input := textinput.New() - input.Placeholder = promptText - input.Focus() - input.CharLimit = 256 - input.Width = 40 - - model := &simplePromptModel{ - input: input, - promptText: promptText, - } - p := tea.NewProgram(model, tea.WithInput(reader)) - if _, err := p.Run(); err != nil { - return err - } - return handler(model.result) -} diff --git a/internal/settings/settings_generate.go b/internal/settings/settings_generate.go index 0df40d53..a6c8d1bc 100644 --- a/internal/settings/settings_generate.go +++ b/internal/settings/settings_generate.go @@ -3,15 +3,16 @@ package settings import ( _ "embed" "fmt" - "io" "os" "path" "path/filepath" "strings" + "github.com/charmbracelet/huh" + "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/context" - "github.com/smartcontractkit/cre-cli/internal/prompt" + "github.com/smartcontractkit/cre-cli/internal/ui" ) //go:embed template/project.yaml.tpl @@ -64,16 +65,24 @@ func GenerateFileFromTemplate(outputPath string, templateContent string, replace return nil } -func GenerateProjectEnvFile(workingDirectory string, stdin io.Reader) (string, error) { +func GenerateProjectEnvFile(workingDirectory string) (string, error) { outputPath, err := filepath.Abs(path.Join(workingDirectory, constants.DefaultEnvFileName)) if err != nil { return "", fmt.Errorf("failed to resolve absolute path for writing file: %w", err) } if _, err := os.Stat(outputPath); err == nil { - msg := fmt.Sprintf("A project environment file already exists at %s. Continuing will overwrite this file. Do you want to proceed?", outputPath) - shouldContinue, err := prompt.YesNoPrompt(stdin, msg) - if err != nil { + var shouldContinue bool + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title(fmt.Sprintf("A project environment file already exists at %s. Continuing will overwrite this file.", outputPath)). + Description("Do you want to proceed?"). + Value(&shouldContinue), + ), + ).WithTheme(ui.ChainlinkTheme()) + + if err := form.Run(); err != nil { return "", fmt.Errorf("failed to prompt for file overwrite confirmation: %w", err) } if !shouldContinue { @@ -98,7 +107,7 @@ func GenerateProjectEnvFile(workingDirectory string, stdin io.Reader) (string, e return outputPath, nil } -func GenerateProjectSettingsFile(workingDirectory string, stdin io.Reader) (string, bool, error) { +func GenerateProjectSettingsFile(workingDirectory string) (string, bool, error) { replacements := GetDefaultReplacements() outputPath, err := filepath.Abs(path.Join(workingDirectory, constants.DefaultProjectSettingsFileName)) @@ -107,9 +116,17 @@ func GenerateProjectSettingsFile(workingDirectory string, stdin io.Reader) (stri } if _, err := os.Stat(outputPath); err == nil { - msg := fmt.Sprintf("A project settings file already exists at %s. Continuing will overwrite this file. Do you want to proceed?", outputPath) - shouldContinue, err := prompt.YesNoPrompt(stdin, msg) - if err != nil { + var shouldContinue bool + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title(fmt.Sprintf("A project settings file already exists at %s. Continuing will overwrite this file.", outputPath)). + Description("Do you want to proceed?"). + Value(&shouldContinue), + ), + ).WithTheme(ui.ChainlinkTheme()) + + if err := form.Run(); err != nil { return "", false, fmt.Errorf("failed to prompt for file overwrite confirmation: %w", err) } if !shouldContinue { diff --git a/internal/ui/output.go b/internal/ui/output.go new file mode 100644 index 00000000..fb5eaf26 --- /dev/null +++ b/internal/ui/output.go @@ -0,0 +1,159 @@ +package ui + +import "fmt" + +// Output helpers - use these for consistent styled output across commands. +// These functions make it easy to migrate from raw fmt.Println calls. + +// Title prints a styled title/header (high visibility - Chainlink Blue) +func Title(text string) { + fmt.Println(TitleStyle.Render(text)) +} + +// Success prints a success message with checkmark (Green) +func Success(text string) { + fmt.Println(SuccessStyle.Render("✓ " + text)) +} + +// Error prints an error message (Orange - high contrast) +func Error(text string) { + fmt.Println(ErrorStyle.Render("✗ " + text)) +} + +// ErrorWithHelp prints an error message with a helpful suggestion +func ErrorWithHelp(text, suggestion string) { + fmt.Println(ErrorStyle.Render("✗ " + text)) + fmt.Println(DimStyle.Render(" → " + suggestion)) +} + +// ErrorWithSuggestions prints an error message with multiple suggestions +func ErrorWithSuggestions(text string, suggestions []string) { + fmt.Println(ErrorStyle.Render("✗ " + text)) + for _, suggestion := range suggestions { + fmt.Println(DimStyle.Render(" → " + suggestion)) + } +} + +// Warning prints a warning message (Yellow) +func Warning(text string) { + fmt.Println(WarningStyle.Render("! " + text)) +} + +// WarningWithHelp prints a warning message with a helpful suggestion +func WarningWithHelp(text, suggestion string) { + fmt.Println(WarningStyle.Render("! " + text)) + fmt.Println(DimStyle.Render(" → " + suggestion)) +} + +// WarningWithSuggestions prints a warning message with multiple suggestions +func WarningWithSuggestions(text string, suggestions []string) { + fmt.Println(WarningStyle.Render("! " + text)) + for _, suggestion := range suggestions { + fmt.Println(DimStyle.Render(" → " + suggestion)) + } +} + +// Dim prints dimmed/secondary text (Gray - less important) +func Dim(text string) { + fmt.Println(DimStyle.Render(" " + text)) +} + +// Step prints a step instruction (Light Blue - visible) +func Step(text string) { + fmt.Println(StepStyle.Render(text)) +} + +// Command prints a CLI command (Bold Light Blue - prominent) +func Command(text string) { + fmt.Println(CommandStyle.Render(text)) +} + +// Box prints text in a bordered box (Chainlink Blue border) +func Box(text string) { + fmt.Println(BoxStyle.Render(text)) +} + +// Bold prints bold text +func Bold(text string) { + fmt.Println(BoldStyle.Render(text)) +} + +// Code prints text styled as code (Light Blue) +func Code(text string) { + fmt.Println(CodeStyle.Render(text)) +} + +// URL prints a styled URL (Chainlink Blue, underlined) +func URL(text string) { + fmt.Println(URLStyle.Render(text)) +} + +// Line prints an empty line +func Line() { + fmt.Println() +} + +// Print prints plain text (for gradual migration - can be replaced later) +func Print(text string) { + fmt.Println(text) +} + +// Printf prints formatted plain text +func Printf(format string, args ...interface{}) { + fmt.Printf(format, args...) +} + +// Indent returns text with indentation +func Indent(text string, level int) string { + indent := "" + for i := 0; i < level; i++ { + indent += " " + } + return indent + text +} + +// Render functions - return styled string without printing (for composition) + +func RenderTitle(text string) string { + return TitleStyle.Render(text) +} + +func RenderSuccess(text string) string { + return SuccessStyle.Render(text) +} + +func RenderError(text string) string { + return ErrorStyle.Render(text) +} + +func RenderWarning(text string) string { + return WarningStyle.Render(text) +} + +func RenderDim(text string) string { + return DimStyle.Render(text) +} + +func RenderStep(text string) string { + return StepStyle.Render(text) +} + +func RenderBold(text string) string { + return BoldStyle.Render(text) +} + +func RenderCode(text string) string { + return CodeStyle.Render(text) +} + +func RenderCommand(text string) string { + return CommandStyle.Render(text) +} + +func RenderURL(text string) string { + return URLStyle.Render(text) +} + +func RenderAccent(text string) string { + return AccentStyle.Render(text) +} diff --git a/internal/ui/progress.go b/internal/ui/progress.go new file mode 100644 index 00000000..e66f59f7 --- /dev/null +++ b/internal/ui/progress.go @@ -0,0 +1,161 @@ +package ui + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/charmbracelet/bubbles/progress" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "golang.org/x/term" +) + +// progressWriter wraps an io.Writer to track download progress +type progressWriter struct { + total int64 + downloaded int64 + file *os.File + onProgress func(float64) +} + +func (pw *progressWriter) Write(p []byte) (int, error) { + n, err := pw.file.Write(p) + pw.downloaded += int64(n) + if pw.total > 0 && pw.onProgress != nil { + pw.onProgress(float64(pw.downloaded) / float64(pw.total)) + } + return n, err +} + +// progressMsg is sent when download progress updates +type progressMsg float64 + +// progressDoneMsg is sent when download completes +type progressDoneMsg struct{} + +// progressErrMsg is sent when download fails +type progressErrMsg struct{ err error } + +// downloadModel is the Bubble Tea model for download progress +type downloadModel struct { + progress progress.Model + message string + done bool + err error +} + +func (m downloadModel) Init() tea.Cmd { + return nil +} + +func (m downloadModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + case progressMsg: + cmd := m.progress.SetPercent(float64(msg)) + return m, cmd + case progressDoneMsg: + m.done = true + return m, tea.Quit + case progressErrMsg: + m.err = msg.err + return m, tea.Quit + case progress.FrameMsg: + progressModel, cmd := m.progress.Update(msg) + m.progress = progressModel.(progress.Model) + return m, cmd + } + return m, nil +} + +func (m downloadModel) View() string { + if m.done { + return "" + } + pad := strings.Repeat(" ", 2) + return "\n" + pad + DimStyle.Render(m.message) + "\n" + pad + m.progress.View() + "\n" +} + +// DownloadWithProgress downloads a file with a progress bar display. +// Returns the number of bytes downloaded and any error. +func DownloadWithProgress(resp io.ReadCloser, contentLength int64, destFile *os.File, message string) error { + // Check if we're in a TTY + if !term.IsTerminal(int(os.Stderr.Fd())) || contentLength <= 0 { + // Non-TTY or unknown size: just copy without progress bar + _, err := io.Copy(destFile, resp) + return err + } + + // Create progress bar with Chainlink theme colors + prog := progress.New( + progress.WithScaledGradient(ColorBlue600, ColorBlue300), + progress.WithWidth(40), + ) + + m := downloadModel{ + progress: prog, + message: message, + } + + // Create the Bubble Tea program + p := tea.NewProgram(m, tea.WithOutput(os.Stderr)) + + // Create progress writer + pw := &progressWriter{ + total: contentLength, + file: destFile, + onProgress: func(ratio float64) { + p.Send(progressMsg(ratio)) + }, + } + + // Start download in goroutine + errCh := make(chan error, 1) + go func() { + _, err := io.Copy(pw, resp) + if err != nil { + p.Send(progressErrMsg{err: err}) + } else { + p.Send(progressDoneMsg{}) + } + errCh <- err + }() + + // Run the UI + if _, err := p.Run(); err != nil { + return err + } + + // Wait for download to finish and get the error + return <-errCh +} + +// FormatBytes formats bytes into human readable format +func FormatBytes(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} + +// ProgressBar creates a simple styled progress bar string (for non-interactive use) +func ProgressBar(percent float64, width int) string { + filled := int(percent * float64(width)) + empty := width - filled + + bar := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBlue500)).Render(strings.Repeat("█", filled)) + bar += lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGray600)).Render(strings.Repeat("░", empty)) + + return fmt.Sprintf("%s %.0f%%", bar, percent*100) +} diff --git a/internal/ui/spinner.go b/internal/ui/spinner.go new file mode 100644 index 00000000..aff45acd --- /dev/null +++ b/internal/ui/spinner.go @@ -0,0 +1,216 @@ +package ui + +import ( + "fmt" + "os" + "sync" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "golang.org/x/term" +) + +// SpinnerStyle for the spinner character (Blue 500 - bright and visible) +var spinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBlue500)) + +// globalSpinner is the shared spinner instance for the entire CLI lifecycle +var ( + globalSpinner *Spinner + globalSpinnerOnce sync.Once +) + +// GlobalSpinner returns the shared spinner instance. +// This ensures a single spinner is used across PersistentPreRunE and command execution, +// preventing the spinner from flickering between operations. +func GlobalSpinner() *Spinner { + globalSpinnerOnce.Do(func() { + globalSpinner = NewSpinner() + }) + return globalSpinner +} + +// Spinner manages a terminal spinner for async operations using Bubble Tea. +// It uses reference counting to handle multiple concurrent operations - +// the spinner only stops when ALL operations complete. +type Spinner struct { + mu sync.Mutex + count int + message string + program *tea.Program + isRunning bool + isTTY bool + quitCh chan struct{} +} + +// spinnerModel is the Bubble Tea model for the spinner +type spinnerModel struct { + spinner spinner.Model + message string + done bool +} + +// Message types for the spinner +type msgUpdate string +type msgQuit struct{} + +func newSpinnerModel(message string) spinnerModel { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = spinnerStyle + return spinnerModel{ + spinner: s, + message: message, + } +} + +func (m spinnerModel) Init() tea.Cmd { + return m.spinner.Tick +} + +func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case msgUpdate: + m.message = string(msg) + return m, nil + case msgQuit: + m.done = true + return m, tea.Quit + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + default: + return m, nil + } +} + +func (m spinnerModel) View() string { + if m.done { + return "" + } + return fmt.Sprintf("%s %s", m.spinner.View(), DimStyle.Render(m.message)) +} + +// NewSpinner creates a new spinner instance +func NewSpinner() *Spinner { + isTTY := term.IsTerminal(int(os.Stderr.Fd())) + return &Spinner{ + isTTY: isTTY, + quitCh: make(chan struct{}), + } +} + +// Start begins or continues the spinner with the given message. +// Each call to Start must be paired with a call to Stop. +// The spinner will keep running until all Start calls have been matched with Stop calls. +func (s *Spinner) Start(message string) { + s.mu.Lock() + defer s.mu.Unlock() + + s.count++ + s.message = message + + if s.isRunning { + // Update the message on the existing spinner + if s.program != nil { + s.program.Send(msgUpdate(message)) + } + return + } + + if !s.isTTY { + // Non-TTY: just print the message once + fmt.Fprintf(os.Stderr, "%s\n", DimStyle.Render(message)) + return + } + + s.isRunning = true + s.quitCh = make(chan struct{}) + + model := newSpinnerModel(message) + s.program = tea.NewProgram(model, tea.WithOutput(os.Stderr)) + + // Run the program in a goroutine + go func() { + _, _ = s.program.Run() + close(s.quitCh) + }() +} + +// Update changes the spinner message without affecting the reference count +func (s *Spinner) Update(message string) { + s.mu.Lock() + defer s.mu.Unlock() + + s.message = message + if s.program != nil { + s.program.Send(msgUpdate(message)) + } +} + +// Stop decrements the reference count and stops the spinner if count reaches zero +func (s *Spinner) Stop() { + s.mu.Lock() + + if s.count > 0 { + s.count-- + } + + if s.count == 0 && s.isRunning { + s.isRunning = false + if s.program != nil { + s.program.Send(msgQuit{}) + s.mu.Unlock() + <-s.quitCh // Wait for program to finish + s.program = nil + return + } + } + + s.mu.Unlock() +} + +// StopAll forces the spinner to stop regardless of reference count +func (s *Spinner) StopAll() { + s.mu.Lock() + + s.count = 0 + if s.isRunning { + s.isRunning = false + if s.program != nil { + s.program.Send(msgQuit{}) + s.mu.Unlock() + <-s.quitCh + s.program = nil + return + } + } + + s.mu.Unlock() +} + +// Run executes a function while showing the spinner. +// This handles starting and stopping automatically. +func (s *Spinner) Run(message string, fn func() error) error { + s.Start(message) + err := fn() + s.Stop() + return err +} + +// WithSpinner executes a function while showing a new spinner. +// This is a convenience function for single operations. +func WithSpinner(message string, fn func() error) error { + s := NewSpinner() + return s.Run(message, fn) +} + +// WithSpinnerResult executes a function that returns a value while showing a spinner. +func WithSpinnerResult[T any](message string, fn func() (T, error)) (T, error) { + s := NewSpinner() + s.Start(message) + result, err := fn() + s.Stop() + return result, err +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go new file mode 100644 index 00000000..3a8c3fa7 --- /dev/null +++ b/internal/ui/styles.go @@ -0,0 +1,178 @@ +package ui + +import "github.com/charmbracelet/lipgloss" + +// Chainlink Blocks Color Palette +// Using high-contrast colors optimized for dark terminal backgrounds +const ( + // White + ColorWhite = "#FFFFFF" + + // Gray scale + ColorGray50 = "#FAFBFC" + ColorGray100 = "#F5F7FA" + ColorGray200 = "#E4E8ED" + ColorGray300 = "#D1D6DE" + ColorGray400 = "#9FA7B2" + ColorGray500 = "#6C7585" + ColorGray600 = "#4E5560" + ColorGray700 = "#3C414C" + ColorGray800 = "#212732" + ColorGray900 = "#141921" + ColorGray950 = "#0E1119" + + // Blue + ColorBlue50 = "#EFF6FF" + ColorBlue100 = "#DCEBFF" + ColorBlue200 = "#C1DBFF" + ColorBlue300 = "#97C1FF" + ColorBlue400 = "#639CFF" + ColorBlue500 = "#2E7BFF" + ColorBlue600 = "#0D5DFF" + ColorBlue700 = "#0847F7" + ColorBlue800 = "#0036C9" + ColorBlue900 = "#00299A" + ColorBlue950 = "#001A62" + + // Green + ColorGreen50 = "#F1FCF5" + ColorGreen100 = "#DDF8E6" + ColorGreen200 = "#B9F1CC" + ColorGreen300 = "#95E5B0" + ColorGreen400 = "#63D78E" + ColorGreen500 = "#3CC274" + ColorGreen600 = "#30A059" + ColorGreen700 = "#267E46" + ColorGreen800 = "#1E633A" + ColorGreen900 = "#195232" + ColorGreen950 = "#0B2D1B" + + // Red + ColorRed50 = "#FEF2F2" + ColorRed100 = "#FEE2E2" + ColorRed200 = "#FECACA" + ColorRed300 = "#FCA5A5" + ColorRed400 = "#F87171" + ColorRed500 = "#EF4444" + ColorRed600 = "#DC2626" + ColorRed700 = "#B91C1C" + ColorRed800 = "#991B1B" + ColorRed900 = "#7F1D1D" + ColorRed950 = "#450A0A" + + // Orange + ColorOrange50 = "#FEF5EF" + ColorOrange100 = "#FCE9DA" + ColorOrange200 = "#FAD3B6" + ColorOrange300 = "#F6B484" + ColorOrange400 = "#EF894F" + ColorOrange500 = "#E86832" + ColorOrange600 = "#DF4C1C" + ColorOrange700 = "#B53C19" + ColorOrange800 = "#913118" + ColorOrange900 = "#7A2914" + ColorOrange950 = "#3E130A" + + // Yellow + ColorYellow50 = "#FFFBEB" + ColorYellow100 = "#FEF3C7" + ColorYellow200 = "#FDE68A" + ColorYellow300 = "#F8D34C" + ColorYellow400 = "#F9C424" + ColorYellow500 = "#EAAE06" + ColorYellow600 = "#CA8A04" + ColorYellow700 = "#A16207" + ColorYellow800 = "#854D0E" + ColorYellow900 = "#713F12" + ColorYellow950 = "#451A03" + + // Teal + ColorTeal50 = "#EEFBF9" + ColorTeal100 = "#DBF5F0" + ColorTeal200 = "#BFEDE4" + ColorTeal300 = "#A3E1D5" + ColorTeal400 = "#80D0C3" + ColorTeal500 = "#51B9A9" + ColorTeal600 = "#2F9589" + ColorTeal700 = "#237872" + ColorTeal800 = "#1A635E" + ColorTeal900 = "#124946" + ColorTeal950 = "#0A2F2F" + + // Purple + ColorPurple50 = "#F5F2FF" + ColorPurple100 = "#EDE8FF" + ColorPurple200 = "#DDD3FF" + ColorPurple300 = "#C5B2FF" + ColorPurple400 = "#A787FF" + ColorPurple500 = "#8657FF" + ColorPurple600 = "#6838E0" + ColorPurple700 = "#4B19C1" + ColorPurple800 = "#3F0DAB" + ColorPurple900 = "#33068D" + ColorPurple950 = "#1F005C" +) + +// Styles - using Chainlink Blocks palette with high contrast for terminal +var ( + // TitleStyle - for main headers (Blue 500 - bright and visible) + TitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(ColorBlue500)) + + // SuccessStyle - for success messages (Green 400 - bright green) + SuccessStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(ColorGreen400)) + + // ErrorStyle - for error messages (Red 400 - high contrast) + ErrorStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(ColorRed400)) + + // WarningStyle - for warnings (Yellow 400 - bright yellow) + WarningStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(ColorYellow400)) + + // BoxStyle - for bordered content boxes (Blue 500 border) + BoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(ColorBlue500)). + Padding(0, 1) + + // DimStyle - for less important/secondary text (Gray 500) + DimStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(ColorGray500)) + + // StepStyle - for step instructions (Blue 400 - lighter, visible) + StepStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(ColorBlue400)) + + // BoldStyle - plain bold + BoldStyle = lipgloss.NewStyle(). + Bold(true) + + // CodeStyle - for code/command snippets (Blue 300 - very visible) + CodeStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(ColorBlue300)) + + // CommandStyle - for CLI commands (Blue 400 - prominent) + CommandStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(ColorBlue400)) + + // AccentStyle - for highlighted/accent text (Purple 400) + AccentStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(ColorPurple400)) + + // URLStyle - for links (Teal 400 - distinct, underlined) + URLStyle = lipgloss.NewStyle(). + Underline(true). + Foreground(lipgloss.Color(ColorTeal400)) + + // HighlightStyle - for important highlights (Yellow 300) + HighlightStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(ColorYellow300)) +) diff --git a/internal/ui/theme.go b/internal/ui/theme.go new file mode 100644 index 00000000..7c10537e --- /dev/null +++ b/internal/ui/theme.go @@ -0,0 +1,58 @@ +package ui + +import ( + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) + +// ChainlinkTheme returns a Huh theme using Chainlink Blocks palette +func ChainlinkTheme() *huh.Theme { + t := huh.ThemeBase() + + // Focused state (when item is selected/active) + t.Focused.Base = t.Focused.Base.BorderForeground(lipgloss.Color(ColorBlue500)) + t.Focused.Title = t.Focused.Title.Foreground(lipgloss.Color(ColorBlue400)).Bold(true) + t.Focused.Description = t.Focused.Description.Foreground(lipgloss.Color(ColorGray500)) + t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(lipgloss.Color(ColorBlue500)) + t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(lipgloss.Color(ColorBlue300)) + t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(lipgloss.Color(ColorGray500)) + t.Focused.FocusedButton = t.Focused.FocusedButton. + Foreground(lipgloss.Color(ColorWhite)). + Background(lipgloss.Color(ColorBlue600)) + t.Focused.BlurredButton = t.Focused.BlurredButton. + Foreground(lipgloss.Color(ColorGray500)). + Background(lipgloss.Color(ColorGray800)) + t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(lipgloss.Color(ColorBlue500)) + t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(lipgloss.Color(ColorGray500)) + t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(lipgloss.Color(ColorBlue500)) + + // Blurred state (when not focused) + t.Blurred.Base = t.Blurred.Base.BorderForeground(lipgloss.Color(ColorGray600)) + t.Blurred.Title = t.Blurred.Title.Foreground(lipgloss.Color(ColorGray500)) + t.Blurred.Description = t.Blurred.Description.Foreground(lipgloss.Color(ColorGray600)) + t.Blurred.SelectSelector = t.Blurred.SelectSelector.Foreground(lipgloss.Color(ColorGray600)) + t.Blurred.SelectedOption = t.Blurred.SelectedOption.Foreground(lipgloss.Color(ColorGray500)) + t.Blurred.UnselectedOption = t.Blurred.UnselectedOption.Foreground(lipgloss.Color(ColorGray600)) + + return t +} + +// ChainlinkKeyMap returns a custom keymap that uses Tab for autocomplete +func ChainlinkKeyMap() *huh.KeyMap { + km := huh.NewDefaultKeyMap() + + // Change AcceptSuggestion from ctrl+e to tab + km.Input.AcceptSuggestion = key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "complete"), + ) + + // Remove tab from Next (keep only enter) + km.Input.Next = key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "next"), + ) + + return km +} diff --git a/test/multi_command_flows/account_happy_path.go b/test/multi_command_flows/account_happy_path.go index 46d48e06..03cfde61 100644 --- a/test/multi_command_flows/account_happy_path.go +++ b/test/multi_command_flows/account_happy_path.go @@ -277,7 +277,7 @@ func RunAccountHappyPath(t *testing.T, tc TestConfig, testEthURL, chainName stri // Check for linked owner (if link succeeded) or empty list (if link failed at contract level) if isOwnerLinked { - require.Contains(t, out, "Linked Owners:", "should show linked owners section") + require.Contains(t, out, "Linked Owners", "should show linked owners section") require.Contains(t, out, "owner-label-1", "should show the owner label") require.Contains(t, out, constants.TestAddress4, "should show owner address") require.Contains(t, out, "Chain Selector:", "should show chain selector") diff --git a/test/multi_command_flows/workflow_happy_path_3.go b/test/multi_command_flows/workflow_happy_path_3.go index 28b0175a..9d3fc7c7 100644 --- a/test/multi_command_flows/workflow_happy_path_3.go +++ b/test/multi_command_flows/workflow_happy_path_3.go @@ -370,7 +370,7 @@ func RunHappyPath3aWorkflow(t *testing.T, tc TestConfig, projectName, ownerAddre // Step 1: Initialize new project with workflow initOut, gqlURL := workflowInit(t, tc.GetProjectRootFlag(), projectName, workflowName) - require.Contains(t, initOut, "Workflow initialized successfully", "expected init to succeed.\nCLI OUTPUT:\n%s", initOut) + require.Contains(t, initOut, "Project created successfully", "expected init to succeed.\nCLI OUTPUT:\n%s", initOut) // Build the project root flag pointing to the newly created project parts := strings.Split(tc.GetProjectRootFlag(), "=")