|
| 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