Skip to content

Commit 635da08

Browse files
add diff-contract command
1 parent a3170ba commit 635da08

5 files changed

Lines changed: 594 additions & 7 deletions

File tree

README.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,13 @@ Usage:
5454
transactions Build, sign, send and retrieve transactions
5555
5656
🔨 Flow Tools
57-
cadence Execute Cadence code
58-
dev-wallet Run a development wallet
59-
emulator Run Flow network for development
60-
flix execute, generate, package
61-
flowser Run Flowser project explorer
62-
test Run Cadence tests
57+
cadence Execute Cadence code
58+
dev-wallet Run a development wallet
59+
diff-contract Diff a local contract against a deployed one
60+
emulator Run Flow network for development
61+
flix execute, generate, package
62+
flowser Run Flowser project explorer
63+
test Run Cadence tests
6364
6465
🏄 Flow Project
6566
deploy Deploy all project contracts

cmd/flow/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
"github.com/onflow/flow-cli/internal/command"
3232
"github.com/onflow/flow-cli/internal/config"
3333
"github.com/onflow/flow-cli/internal/dependencymanager"
34+
"github.com/onflow/flow-cli/internal/diffcontract"
3435
"github.com/onflow/flow-cli/internal/emulator"
3536
"github.com/onflow/flow-cli/internal/events"
3637
evm "github.com/onflow/flow-cli/internal/evm"
@@ -67,6 +68,7 @@ func main() {
6768
tools.DevWallet.AddToParent(cmd)
6869
tools.Flowser.AddToParent(cmd)
6970
test.TestCommand.AddToParent(cmd)
71+
diffcontract.DiffContractCommand.AddToParent(cmd)
7072

7173
// super commands
7274
super.InitCommand.AddToParent(cmd)

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ require (
3030
github.com/onflowser/flowser/v3 v3.2.1-0.20240131200229-7d4d22715f48
3131
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
3232
github.com/pkg/errors v0.9.1
33+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
3334
github.com/psiemens/sconfig v0.1.0
3435
github.com/radovskyb/watcher v1.0.7
3536
github.com/rs/zerolog v1.35.0
@@ -228,7 +229,6 @@ require (
228229
github.com/pion/transport/v2 v2.2.10 // indirect
229230
github.com/pion/transport/v3 v3.0.7 // indirect
230231
github.com/pkg/term v1.2.0-beta.2 // indirect
231-
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
232232
github.com/prometheus/client_golang v1.23.2 // indirect
233233
github.com/prometheus/client_model v0.6.2 // indirect
234234
github.com/prometheus/common v0.66.1 // indirect
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
/*
2+
* Flow CLI
3+
*
4+
* Copyright Flow Foundation
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
package diffcontract
20+
21+
import (
22+
"context"
23+
"fmt"
24+
"io"
25+
"net/http"
26+
"strings"
27+
28+
"github.com/pmezard/go-difflib/difflib"
29+
"github.com/spf13/cobra"
30+
31+
flowsdk "github.com/onflow/flow-go-sdk"
32+
33+
"github.com/onflow/flowkit/v2"
34+
"github.com/onflow/flowkit/v2/output"
35+
"github.com/onflow/flowkit/v2/project"
36+
37+
"github.com/onflow/flow-cli/internal/command"
38+
"github.com/onflow/flow-cli/internal/util"
39+
)
40+
41+
type diffContractFlags struct {
42+
Quiet bool `default:"false" flag:"quiet" info:"Exit with non-zero code if contracts differ, without output"`
43+
}
44+
45+
var diffFlags = diffContractFlags{}
46+
47+
var DiffContractCommand = &command.Command{
48+
Cmd: &cobra.Command{
49+
Use: "diff-contract <file-or-url> [address]",
50+
Short: "Diff a local contract against a deployed one",
51+
Example: "flow diff-contract ./MyContract.cdc\nflow diff-contract ./MyContract.cdc 0xf8d6e0586b0a20c7\nflow diff-contract https://example.com/MyContract.cdc my-account --network testnet",
52+
Args: cobra.RangeArgs(1, 2),
53+
GroupID: "tools",
54+
},
55+
Flags: &diffFlags,
56+
RunS: diffContract,
57+
}
58+
59+
func init() {
60+
DiffContractCommand.Cmd.Flags().BoolVarP(&diffFlags.Quiet, "quiet", "q", false, "Exit with non-zero code if contracts differ, without output")
61+
}
62+
63+
func diffContract(
64+
args []string,
65+
globalFlags command.GlobalFlags,
66+
logger output.Logger,
67+
flow flowkit.Services,
68+
state *flowkit.State,
69+
) (command.Result, error) {
70+
source := args[0]
71+
72+
// Read source code from file or URL
73+
var code []byte
74+
var location string
75+
var err error
76+
77+
if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") {
78+
code, err = fetchURL(source)
79+
if err != nil {
80+
return nil, fmt.Errorf("error fetching contract from URL: %w", err)
81+
}
82+
location = source
83+
} else {
84+
code, err = state.ReadFile(source)
85+
if err != nil {
86+
return nil, fmt.Errorf("error loading contract file: %w", err)
87+
}
88+
location = source
89+
}
90+
91+
// Extract contract name from source
92+
program, err := project.NewProgram(code, nil, location)
93+
if err != nil {
94+
return nil, fmt.Errorf("error parsing contract source: %w", err)
95+
}
96+
97+
contractName, err := program.Name()
98+
if err != nil {
99+
return nil, fmt.Errorf("error extracting contract name: %w", err)
100+
}
101+
102+
// Resolve imports in source code
103+
ctx := context.Background()
104+
resolved, err := flow.ReplaceImportsInScript(ctx, flowkit.Script{
105+
Code: code,
106+
Location: location,
107+
})
108+
if err != nil {
109+
return nil, fmt.Errorf("error resolving imports: %w", err)
110+
}
111+
112+
// Resolve target address: from argument or from flow.json deployments
113+
var address flowsdk.Address
114+
if len(args) >= 2 {
115+
address, err = util.ResolveAddressOrAccountNameForNetworks(args[1], state, []string{globalFlags.Network})
116+
if err != nil {
117+
return nil, err
118+
}
119+
} else {
120+
address, err = resolveAddressFromConfig(state, contractName, globalFlags.Network)
121+
if err != nil {
122+
return nil, err
123+
}
124+
}
125+
126+
// Fetch deployed contract
127+
logger.StartProgress(fmt.Sprintf("Fetching contract '%s' from %s...", contractName, address))
128+
defer logger.StopProgress()
129+
130+
account, err := flow.GetAccount(ctx, address)
131+
if err != nil {
132+
return nil, fmt.Errorf("error fetching account: %w", err)
133+
}
134+
135+
deployedCode, ok := account.Contracts[contractName]
136+
if !ok {
137+
return nil, fmt.Errorf("contract '%s' not found on account %s", contractName, address)
138+
}
139+
140+
// Normalize and diff
141+
localCode := util.NormalizeLineEndings(string(resolved.Code))
142+
remoteCode := util.NormalizeLineEndings(string(deployedCode))
143+
144+
identical := localCode == remoteCode
145+
146+
exitCode := 0
147+
if !identical {
148+
exitCode = 1
149+
}
150+
151+
diffText := ""
152+
if !identical {
153+
localLabel := source
154+
remoteLabel := fmt.Sprintf("0x%s/%s (deployed)", address, contractName)
155+
diff := difflib.UnifiedDiff{
156+
A: difflib.SplitLines(remoteCode),
157+
B: difflib.SplitLines(localCode),
158+
FromFile: remoteLabel,
159+
ToFile: localLabel,
160+
Context: 3,
161+
}
162+
diffText, err = difflib.GetUnifiedDiffString(diff)
163+
if err != nil {
164+
return nil, fmt.Errorf("error computing diff: %w", err)
165+
}
166+
}
167+
168+
return &diffContractResult{
169+
diff: diffText,
170+
contractName: contractName,
171+
address: address.String(),
172+
identical: identical,
173+
quiet: diffFlags.Quiet,
174+
exitCode: exitCode,
175+
}, nil
176+
}
177+
178+
// resolveAddressFromConfig looks up the address for a contract in flow.json
179+
// by checking deployments first, then contract aliases for the given network.
180+
func resolveAddressFromConfig(state *flowkit.State, contractName string, network string) (flowsdk.Address, error) {
181+
// Check deployments
182+
deployments := state.Deployments().ByNetwork(network)
183+
for _, deployment := range deployments {
184+
for _, contract := range deployment.Contracts {
185+
if contract.Name == contractName {
186+
account, err := state.Accounts().ByName(deployment.Account)
187+
if err != nil {
188+
return flowsdk.EmptyAddress, fmt.Errorf("account '%s' from deployment not found in configuration: %w", deployment.Account, err)
189+
}
190+
return account.Address, nil
191+
}
192+
}
193+
}
194+
195+
// Check contract aliases
196+
contract, err := state.Contracts().ByName(contractName)
197+
if err == nil && contract != nil {
198+
if alias := contract.Aliases.ByNetwork(network); alias != nil {
199+
return alias.Address, nil
200+
}
201+
}
202+
203+
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)
204+
}
205+
206+
func fetchURL(url string) ([]byte, error) {
207+
resp, err := http.Get(url) //nolint:gosec
208+
if err != nil {
209+
return nil, err
210+
}
211+
defer resp.Body.Close()
212+
213+
if resp.StatusCode != http.StatusOK {
214+
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
215+
}
216+
217+
return io.ReadAll(resp.Body)
218+
}
219+
220+
// diffContractResult implements command.ResultWithExitCode
221+
type diffContractResult struct {
222+
diff string
223+
contractName string
224+
address string
225+
identical bool
226+
quiet bool
227+
exitCode int
228+
}
229+
230+
var _ command.ResultWithExitCode = &diffContractResult{}
231+
232+
func (r *diffContractResult) String() string {
233+
if r.quiet {
234+
return ""
235+
}
236+
if r.identical {
237+
return fmt.Sprintf("Contract '%s' on 0x%s is up to date", r.contractName, r.address)
238+
}
239+
return r.diff
240+
}
241+
242+
func (r *diffContractResult) Oneliner() string {
243+
if r.identical {
244+
return "identical"
245+
}
246+
return "different"
247+
}
248+
249+
func (r *diffContractResult) JSON() any {
250+
result := map[string]any{
251+
"contract": r.contractName,
252+
"address": r.address,
253+
"identical": r.identical,
254+
}
255+
if !r.identical {
256+
result["diff"] = r.diff
257+
}
258+
return result
259+
}
260+
261+
func (r *diffContractResult) ExitCode() int {
262+
return r.exitCode
263+
}

0 commit comments

Comments
 (0)