From 635da08f1b1de07ae046c429c4db08e25f412304 Mon Sep 17 00:00:00 2001 From: Janez Podhostnik Date: Tue, 14 Apr 2026 20:13:17 +0200 Subject: [PATCH] add diff-contract command --- README.md | 13 +- cmd/flow/main.go | 2 + go.mod | 2 +- internal/diffcontract/diff-contract.go | 263 ++++++++++++++++ internal/diffcontract/diff_contract_test.go | 321 ++++++++++++++++++++ 5 files changed, 594 insertions(+), 7 deletions(-) create mode 100644 internal/diffcontract/diff-contract.go create mode 100644 internal/diffcontract/diff_contract_test.go diff --git a/README.md b/README.md index 94278973b..7d306dcd1 100644 --- a/README.md +++ b/README.md @@ -54,12 +54,13 @@ Usage: transactions Build, sign, send and retrieve transactions 🔨 Flow Tools - cadence Execute Cadence code - dev-wallet Run a development wallet - emulator Run Flow network for development - flix execute, generate, package - flowser Run Flowser project explorer - test Run Cadence tests + cadence Execute Cadence code + dev-wallet Run a development wallet + diff-contract Diff a local contract against a deployed one + emulator Run Flow network for development + flix execute, generate, package + flowser Run Flowser project explorer + test Run Cadence tests 🏄 Flow Project deploy Deploy all project contracts diff --git a/cmd/flow/main.go b/cmd/flow/main.go index cdf71871a..362a268b5 100644 --- a/cmd/flow/main.go +++ b/cmd/flow/main.go @@ -31,6 +31,7 @@ import ( "github.com/onflow/flow-cli/internal/command" "github.com/onflow/flow-cli/internal/config" "github.com/onflow/flow-cli/internal/dependencymanager" + "github.com/onflow/flow-cli/internal/diffcontract" "github.com/onflow/flow-cli/internal/emulator" "github.com/onflow/flow-cli/internal/events" evm "github.com/onflow/flow-cli/internal/evm" @@ -67,6 +68,7 @@ func main() { tools.DevWallet.AddToParent(cmd) tools.Flowser.AddToParent(cmd) test.TestCommand.AddToParent(cmd) + diffcontract.DiffContractCommand.AddToParent(cmd) // super commands super.InitCommand.AddToParent(cmd) diff --git a/go.mod b/go.mod index 5a0d69133..d3fb67b48 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/onflowser/flowser/v3 v3.2.1-0.20240131200229-7d4d22715f48 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pkg/errors v0.9.1 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 github.com/psiemens/sconfig v0.1.0 github.com/radovskyb/watcher v1.0.7 github.com/rs/zerolog v1.35.0 @@ -228,7 +229,6 @@ require ( github.com/pion/transport/v2 v2.2.10 // indirect github.com/pion/transport/v3 v3.0.7 // indirect github.com/pkg/term v1.2.0-beta.2 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect diff --git a/internal/diffcontract/diff-contract.go b/internal/diffcontract/diff-contract.go new file mode 100644 index 000000000..f0aa35e3d --- /dev/null +++ b/internal/diffcontract/diff-contract.go @@ -0,0 +1,263 @@ +/* + * Flow CLI + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package diffcontract + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + + "github.com/pmezard/go-difflib/difflib" + "github.com/spf13/cobra" + + flowsdk "github.com/onflow/flow-go-sdk" + + "github.com/onflow/flowkit/v2" + "github.com/onflow/flowkit/v2/output" + "github.com/onflow/flowkit/v2/project" + + "github.com/onflow/flow-cli/internal/command" + "github.com/onflow/flow-cli/internal/util" +) + +type diffContractFlags struct { + Quiet bool `default:"false" flag:"quiet" info:"Exit with non-zero code if contracts differ, without output"` +} + +var diffFlags = diffContractFlags{} + +var DiffContractCommand = &command.Command{ + Cmd: &cobra.Command{ + Use: "diff-contract [address]", + Short: "Diff a local contract against a deployed one", + Example: "flow diff-contract ./MyContract.cdc\nflow diff-contract ./MyContract.cdc 0xf8d6e0586b0a20c7\nflow diff-contract https://example.com/MyContract.cdc my-account --network testnet", + Args: cobra.RangeArgs(1, 2), + GroupID: "tools", + }, + Flags: &diffFlags, + RunS: diffContract, +} + +func init() { + DiffContractCommand.Cmd.Flags().BoolVarP(&diffFlags.Quiet, "quiet", "q", false, "Exit with non-zero code if contracts differ, without output") +} + +func diffContract( + args []string, + globalFlags command.GlobalFlags, + logger output.Logger, + flow flowkit.Services, + state *flowkit.State, +) (command.Result, error) { + source := args[0] + + // Read source code from file or URL + var code []byte + var location string + var err error + + if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") { + code, err = fetchURL(source) + if err != nil { + return nil, fmt.Errorf("error fetching contract from URL: %w", err) + } + location = source + } else { + code, err = state.ReadFile(source) + if err != nil { + return nil, fmt.Errorf("error loading contract file: %w", err) + } + location = source + } + + // Extract contract name from source + program, err := project.NewProgram(code, nil, location) + if err != nil { + return nil, fmt.Errorf("error parsing contract source: %w", err) + } + + contractName, err := program.Name() + if err != nil { + return nil, fmt.Errorf("error extracting contract name: %w", err) + } + + // Resolve imports in source code + ctx := context.Background() + resolved, err := flow.ReplaceImportsInScript(ctx, flowkit.Script{ + Code: code, + Location: location, + }) + if err != nil { + return nil, fmt.Errorf("error resolving imports: %w", err) + } + + // Resolve target address: from argument or from flow.json deployments + var address flowsdk.Address + if len(args) >= 2 { + address, err = util.ResolveAddressOrAccountNameForNetworks(args[1], state, []string{globalFlags.Network}) + if err != nil { + return nil, err + } + } else { + address, err = resolveAddressFromConfig(state, contractName, globalFlags.Network) + if err != nil { + return nil, err + } + } + + // Fetch deployed contract + logger.StartProgress(fmt.Sprintf("Fetching contract '%s' from %s...", contractName, address)) + defer logger.StopProgress() + + account, err := flow.GetAccount(ctx, address) + if err != nil { + return nil, fmt.Errorf("error fetching account: %w", err) + } + + deployedCode, ok := account.Contracts[contractName] + if !ok { + return nil, fmt.Errorf("contract '%s' not found on account %s", contractName, address) + } + + // Normalize and diff + localCode := util.NormalizeLineEndings(string(resolved.Code)) + remoteCode := util.NormalizeLineEndings(string(deployedCode)) + + identical := localCode == remoteCode + + exitCode := 0 + if !identical { + exitCode = 1 + } + + diffText := "" + if !identical { + localLabel := source + remoteLabel := fmt.Sprintf("0x%s/%s (deployed)", address, contractName) + diff := difflib.UnifiedDiff{ + A: difflib.SplitLines(remoteCode), + B: difflib.SplitLines(localCode), + FromFile: remoteLabel, + ToFile: localLabel, + Context: 3, + } + diffText, err = difflib.GetUnifiedDiffString(diff) + if err != nil { + return nil, fmt.Errorf("error computing diff: %w", err) + } + } + + return &diffContractResult{ + diff: diffText, + contractName: contractName, + address: address.String(), + identical: identical, + quiet: diffFlags.Quiet, + exitCode: exitCode, + }, nil +} + +// resolveAddressFromConfig looks up the address for a contract in flow.json +// by checking deployments first, then contract aliases for the given network. +func resolveAddressFromConfig(state *flowkit.State, contractName string, network string) (flowsdk.Address, error) { + // Check deployments + deployments := state.Deployments().ByNetwork(network) + for _, deployment := range deployments { + for _, contract := range deployment.Contracts { + if contract.Name == contractName { + account, err := state.Accounts().ByName(deployment.Account) + if err != nil { + return flowsdk.EmptyAddress, fmt.Errorf("account '%s' from deployment not found in configuration: %w", deployment.Account, err) + } + return account.Address, nil + } + } + } + + // Check contract aliases + contract, err := state.Contracts().ByName(contractName) + if err == nil && contract != nil { + if alias := contract.Aliases.ByNetwork(network); alias != nil { + return alias.Address, nil + } + } + + return flowsdk.EmptyAddress, fmt.Errorf("contract '%s' not found in deployments or aliases for network '%s' in flow.json, specify an address explicitly", contractName, network) +} + +func fetchURL(url string) ([]byte, error) { + resp, err := http.Get(url) //nolint:gosec + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) + } + + return io.ReadAll(resp.Body) +} + +// diffContractResult implements command.ResultWithExitCode +type diffContractResult struct { + diff string + contractName string + address string + identical bool + quiet bool + exitCode int +} + +var _ command.ResultWithExitCode = &diffContractResult{} + +func (r *diffContractResult) String() string { + if r.quiet { + return "" + } + if r.identical { + return fmt.Sprintf("Contract '%s' on 0x%s is up to date", r.contractName, r.address) + } + return r.diff +} + +func (r *diffContractResult) Oneliner() string { + if r.identical { + return "identical" + } + return "different" +} + +func (r *diffContractResult) JSON() any { + result := map[string]any{ + "contract": r.contractName, + "address": r.address, + "identical": r.identical, + } + if !r.identical { + result["diff"] = r.diff + } + return result +} + +func (r *diffContractResult) ExitCode() int { + return r.exitCode +} diff --git a/internal/diffcontract/diff_contract_test.go b/internal/diffcontract/diff_contract_test.go new file mode 100644 index 000000000..ca579be34 --- /dev/null +++ b/internal/diffcontract/diff_contract_test.go @@ -0,0 +1,321 @@ +/* + * Flow CLI + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package diffcontract + +import ( + "context" + "testing" + + "github.com/onflow/flow-go-sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/onflow/flowkit/v2" + "github.com/onflow/flowkit/v2/config" + "github.com/onflow/flowkit/v2/mocks" + + "github.com/onflow/flow-cli/internal/command" + "github.com/onflow/flow-cli/internal/util" +) + +const testContractCode = `access(all) contract TestContract { + access(all) fun hello(): String { + return "Hello" + } +} +` + +const testContractCodeModified = `access(all) contract TestContract { + access(all) fun hello(): String { + return "Hello, World!" + } +} +` + +// passthrough is a mock return function that returns the script unchanged +func passthrough(_ context.Context, s flowkit.Script) (flowkit.Script, error) { + return s, nil +} + +func setupMocks(t *testing.T, deployedCode []byte, localCode []byte) (*mocks.MockServices, *flowkit.State) { + srv, state, rw := util.TestMocks(t) + + // Write contract file to mock filesystem + err := rw.WriteFile("TestContract.cdc", localCode, 0644) + require.NoError(t, err) + + // Mock ReplaceImportsInScript to return code as-is (no imports to resolve) + srv.Mock.On( + "ReplaceImportsInScript", + mock.Anything, + mock.AnythingOfType("flowkit.Script"), + ).Return(passthrough) + + // Mock GetAccount to return account with deployed contract + account := &flow.Account{ + Address: flow.HexToAddress("f8d6e0586b0a20c7"), + Contracts: map[string][]byte{"TestContract": deployedCode}, + } + srv.GetAccount.Run(func(args mock.Arguments) { + srv.GetAccount.Return(account, nil) + }) + + return srv, state +} + +func Test_DiffContract(t *testing.T) { + t.Run("Identical contracts", func(t *testing.T) { + srv, state := setupMocks(t, []byte(testContractCode), []byte(testContractCode)) + diffFlags.Quiet = false + + result, err := diffContract( + []string{"TestContract.cdc", "f8d6e0586b0a20c7"}, + command.GlobalFlags{Network: "emulator"}, + util.NoLogger, + srv.Mock, + state, + ) + + require.NoError(t, err) + require.NotNil(t, result) + + r := result.(*diffContractResult) + assert.True(t, r.identical) + assert.Equal(t, 0, r.ExitCode()) + assert.Contains(t, r.String(), "up to date") + assert.Equal(t, "identical", r.Oneliner()) + }) + + t.Run("Different contracts", func(t *testing.T) { + srv, state := setupMocks(t, []byte(testContractCode), []byte(testContractCodeModified)) + diffFlags.Quiet = false + + result, err := diffContract( + []string{"TestContract.cdc", "f8d6e0586b0a20c7"}, + command.GlobalFlags{Network: "emulator"}, + util.NoLogger, + srv.Mock, + state, + ) + + require.NoError(t, err) + require.NotNil(t, result) + + r := result.(*diffContractResult) + assert.False(t, r.identical) + assert.Equal(t, 1, r.ExitCode()) + assert.NotEmpty(t, r.String()) + assert.Contains(t, r.String(), "---") + assert.Contains(t, r.String(), "+++") + assert.Contains(t, r.String(), "@@") + assert.Equal(t, "different", r.Oneliner()) + }) + + t.Run("Quiet mode identical", func(t *testing.T) { + srv, state := setupMocks(t, []byte(testContractCode), []byte(testContractCode)) + diffFlags.Quiet = true + + result, err := diffContract( + []string{"TestContract.cdc", "f8d6e0586b0a20c7"}, + command.GlobalFlags{Network: "emulator"}, + util.NoLogger, + srv.Mock, + state, + ) + + require.NoError(t, err) + require.NotNil(t, result) + + r := result.(*diffContractResult) + assert.Equal(t, 0, r.ExitCode()) + assert.Equal(t, "", r.String()) + }) + + t.Run("Quiet mode different", func(t *testing.T) { + srv, state := setupMocks(t, []byte(testContractCode), []byte(testContractCodeModified)) + diffFlags.Quiet = true + + result, err := diffContract( + []string{"TestContract.cdc", "f8d6e0586b0a20c7"}, + command.GlobalFlags{Network: "emulator"}, + util.NoLogger, + srv.Mock, + state, + ) + + require.NoError(t, err) + require.NotNil(t, result) + + r := result.(*diffContractResult) + assert.Equal(t, 1, r.ExitCode()) + assert.Equal(t, "", r.String()) + }) + + t.Run("Contract not found on account", func(t *testing.T) { + srv, state, rw := util.TestMocks(t) + + err := rw.WriteFile("TestContract.cdc", []byte(testContractCode), 0644) + require.NoError(t, err) + + srv.Mock.On( + "ReplaceImportsInScript", + mock.Anything, + mock.AnythingOfType("flowkit.Script"), + ).Return(passthrough) + + // Account with no contracts + account := &flow.Account{ + Address: flow.HexToAddress("f8d6e0586b0a20c7"), + Contracts: map[string][]byte{}, + } + srv.GetAccount.Run(func(args mock.Arguments) { + srv.GetAccount.Return(account, nil) + }) + + result, err := diffContract( + []string{"TestContract.cdc", "f8d6e0586b0a20c7"}, + command.GlobalFlags{Network: "emulator"}, + util.NoLogger, + srv.Mock, + state, + ) + + assert.Nil(t, result) + assert.EqualError(t, err, "contract 'TestContract' not found on account f8d6e0586b0a20c7") + }) + + t.Run("Non-existing file", func(t *testing.T) { + srv, state, _ := util.TestMocks(t) + + result, err := diffContract( + []string{"non-existing.cdc", "f8d6e0586b0a20c7"}, + command.GlobalFlags{Network: "emulator"}, + util.NoLogger, + srv.Mock, + state, + ) + + assert.Nil(t, result) + assert.Contains(t, err.Error(), "error loading contract file") + }) + + t.Run("Resolve address from flow.json", func(t *testing.T) { + srv, state := setupMocks(t, []byte(testContractCode), []byte(testContractCode)) + diffFlags.Quiet = false + + // Add deployment config: emulator-account deploys TestContract on emulator + state.Deployments().AddOrUpdate(config.Deployment{ + Network: "emulator", + Account: "emulator-account", + Contracts: []config.ContractDeployment{ + {Name: "TestContract"}, + }, + }) + + // No address argument — should resolve from flow.json + result, err := diffContract( + []string{"TestContract.cdc"}, + command.GlobalFlags{Network: "emulator"}, + util.NoLogger, + srv.Mock, + state, + ) + + require.NoError(t, err) + require.NotNil(t, result) + + r := result.(*diffContractResult) + assert.True(t, r.identical) + assert.Equal(t, 0, r.ExitCode()) + }) + + t.Run("Resolve address from contract alias", func(t *testing.T) { + srv, state := setupMocks(t, []byte(testContractCode), []byte(testContractCode)) + diffFlags.Quiet = false + + // Add contract with alias instead of deployment + state.Contracts().AddOrUpdate(config.Contract{ + Name: "TestContract", + Location: "TestContract.cdc", + Aliases: config.Aliases{{Network: "emulator", Address: flow.HexToAddress("f8d6e0586b0a20c7")}}, + }) + + // No address argument — should resolve from alias + result, err := diffContract( + []string{"TestContract.cdc"}, + command.GlobalFlags{Network: "emulator"}, + util.NoLogger, + srv.Mock, + state, + ) + + require.NoError(t, err) + require.NotNil(t, result) + + r := result.(*diffContractResult) + assert.True(t, r.identical) + assert.Equal(t, 0, r.ExitCode()) + }) + + t.Run("No address and not in flow.json", func(t *testing.T) { + srv, state, rw := util.TestMocks(t) + + err := rw.WriteFile("TestContract.cdc", []byte(testContractCode), 0644) + require.NoError(t, err) + + srv.Mock.On( + "ReplaceImportsInScript", + mock.Anything, + mock.AnythingOfType("flowkit.Script"), + ).Return(passthrough) + + // No deployment config, no address argument + result, err := diffContract( + []string{"TestContract.cdc"}, + command.GlobalFlags{Network: "emulator"}, + util.NoLogger, + srv.Mock, + state, + ) + + assert.Nil(t, result) + assert.Contains(t, err.Error(), "not found in deployments or aliases") + }) + + t.Run("JSON output", func(t *testing.T) { + srv, state := setupMocks(t, []byte(testContractCode), []byte(testContractCodeModified)) + diffFlags.Quiet = false + + result, err := diffContract( + []string{"TestContract.cdc", "f8d6e0586b0a20c7"}, + command.GlobalFlags{Network: "emulator"}, + util.NoLogger, + srv.Mock, + state, + ) + + require.NoError(t, err) + + jsonResult := result.JSON().(map[string]any) + assert.Equal(t, "TestContract", jsonResult["contract"]) + assert.Equal(t, false, jsonResult["identical"]) + assert.NotEmpty(t, jsonResult["diff"]) + }) +}