Skip to content

Commit 7330e68

Browse files
authored
Merge pull request #2044 from onflow/cf/list-deps
Add `flow dependencies list` command and explain `discover` filtering
2 parents 8b4677b + bd4973f commit 7330e68

5 files changed

Lines changed: 263 additions & 2 deletions

File tree

internal/dependencymanager/dependencies.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,6 @@ var Cmd = &cobra.Command{
3333
func init() {
3434
addCommand.AddToParent(Cmd)
3535
installCommand.AddToParent(Cmd)
36+
listCommand.AddToParent(Cmd)
3637
discoverCommand.AddToParent(Cmd)
3738
}

internal/dependencymanager/discover.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,16 @@ func PromptInstallCoreContracts(logger output.Logger, state *flowkit.State, targ
9393
contractNames = append(contractNames, contract.Name)
9494
}
9595

96-
selectedContractNames, err := prompt.RunSelectOptions(contractNames, promptMessage)
96+
var footer string
97+
totalContracts := len(sc.All())
98+
availableContracts := len(contractNames)
99+
installedCount := totalContracts - availableContracts
100+
101+
if installedCount > 0 {
102+
footer = fmt.Sprintf("ℹ️ Note: %d core contracts already installed. Use 'flow dependencies list' to view them.", installedCount)
103+
}
104+
105+
selectedContractNames, err := prompt.RunSelectOptionsWithFooter(contractNames, promptMessage, footer)
97106
if err != nil {
98107
return fmt.Errorf("error running dependency selection: %v\n", err)
99108
}

internal/dependencymanager/list.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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 dependencymanager
20+
21+
import (
22+
"fmt"
23+
"strings"
24+
25+
"github.com/spf13/cobra"
26+
27+
"github.com/onflow/flowkit/v2"
28+
"github.com/onflow/flowkit/v2/output"
29+
30+
"github.com/onflow/flow-cli/common/branding"
31+
"github.com/onflow/flow-cli/internal/command"
32+
)
33+
34+
type ListResult struct {
35+
Dependencies []DependencyInfo `json:"dependencies"`
36+
}
37+
38+
type DependencyInfo struct {
39+
Name string `json:"name"`
40+
NetworkName string `json:"network"`
41+
Address string `json:"address"`
42+
Contract string `json:"contract"`
43+
}
44+
45+
var listCommand = &command.Command{
46+
Cmd: &cobra.Command{
47+
Use: "list",
48+
Short: "List installed dependencies",
49+
Example: "flow dependencies list",
50+
Args: cobra.NoArgs,
51+
},
52+
RunS: list,
53+
Flags: &struct{}{},
54+
}
55+
56+
func list(
57+
_ []string,
58+
globalFlags command.GlobalFlags,
59+
logger output.Logger,
60+
flow flowkit.Services,
61+
state *flowkit.State,
62+
) (command.Result, error) {
63+
installedDeps := state.Dependencies()
64+
if installedDeps == nil || len(*installedDeps) == 0 {
65+
return &ListResult{Dependencies: []DependencyInfo{}}, nil
66+
}
67+
68+
var dependencies []DependencyInfo
69+
for _, dep := range *installedDeps {
70+
dependencies = append(dependencies, DependencyInfo{
71+
Name: dep.Name,
72+
NetworkName: dep.Source.NetworkName,
73+
Address: dep.Source.Address.String(),
74+
Contract: dep.Source.ContractName,
75+
})
76+
}
77+
78+
return &ListResult{Dependencies: dependencies}, nil
79+
}
80+
81+
func (r *ListResult) String() string {
82+
if len(r.Dependencies) == 0 {
83+
return branding.GrayStyle.Render("📦 No dependencies installed")
84+
}
85+
86+
var result strings.Builder
87+
88+
header := fmt.Sprintf("📦 Installed dependencies (%d):", len(r.Dependencies))
89+
result.WriteString(branding.PurpleStyle.Render(header) + "\n\n")
90+
91+
// Find max widths for alignment
92+
maxNameWidth := 4 // "NAME"
93+
maxNetworkWidth := 7 // "NETWORK"
94+
maxAddressWidth := 7 // "ADDRESS"
95+
96+
for _, dep := range r.Dependencies {
97+
if len(dep.Name) > maxNameWidth {
98+
maxNameWidth = len(dep.Name)
99+
}
100+
if len(dep.NetworkName) > maxNetworkWidth {
101+
maxNetworkWidth = len(dep.NetworkName)
102+
}
103+
if len(dep.Address) > maxAddressWidth {
104+
maxAddressWidth = len(dep.Address)
105+
}
106+
}
107+
108+
result.WriteString(fmt.Sprintf("%s %s %s %s\n",
109+
branding.GreenStyle.Render(fmt.Sprintf("%-*s", maxNameWidth, "NAME")),
110+
branding.GreenStyle.Render(fmt.Sprintf("%-*s", maxNetworkWidth, "NETWORK")),
111+
branding.GreenStyle.Render(fmt.Sprintf("%-*s", maxAddressWidth, "ADDRESS")),
112+
branding.GreenStyle.Render("CONTRACT")))
113+
114+
result.WriteString(branding.GrayStyle.Render(strings.Repeat("─", maxNameWidth+maxNetworkWidth+maxAddressWidth+20)) + "\n")
115+
116+
for _, dep := range r.Dependencies {
117+
118+
contractName := branding.GreenStyle.Render(fmt.Sprintf("%-*s", maxNameWidth, dep.Name))
119+
network := branding.PurpleStyle.Render(fmt.Sprintf("%-*s", maxNetworkWidth, dep.NetworkName))
120+
address := branding.GrayStyle.Render(fmt.Sprintf("%-*s", maxAddressWidth, dep.Address))
121+
contract := dep.Contract
122+
123+
result.WriteString(fmt.Sprintf("%s %s %s %s\n",
124+
contractName, network, address, contract))
125+
}
126+
127+
return result.String()
128+
}
129+
130+
func (r *ListResult) Oneliner() string {
131+
return fmt.Sprintf("Found %d installed dependencies", len(r.Dependencies))
132+
}
133+
134+
func (r *ListResult) JSON() any {
135+
return r
136+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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 dependencymanager
20+
21+
import (
22+
"testing"
23+
24+
"github.com/stretchr/testify/assert"
25+
26+
"github.com/onflow/flowkit/v2/config"
27+
"github.com/onflow/flowkit/v2/output"
28+
29+
"github.com/onflow/flow-cli/internal/command"
30+
"github.com/onflow/flow-cli/internal/util"
31+
)
32+
33+
func TestListDependencies(t *testing.T) {
34+
t.Run("Empty dependencies", func(t *testing.T) {
35+
logger := output.NewStdoutLogger(output.NoneLog)
36+
_, state, _ := util.TestMocks(t)
37+
38+
result, err := list([]string{}, command.GlobalFlags{}, logger, nil, state)
39+
40+
assert.NoError(t, err)
41+
listResult, ok := result.(*ListResult)
42+
assert.True(t, ok)
43+
assert.Equal(t, 0, len(listResult.Dependencies))
44+
})
45+
46+
t.Run("With dependencies", func(t *testing.T) {
47+
logger := output.NewStdoutLogger(output.NoneLog)
48+
_, state, _ := util.TestMocks(t)
49+
50+
serviceAcc, _ := state.EmulatorServiceAccount()
51+
dep := config.Dependency{
52+
Name: "TestContract",
53+
Source: config.Source{
54+
NetworkName: "emulator",
55+
Address: serviceAcc.Address,
56+
ContractName: "TestContract",
57+
},
58+
}
59+
60+
state.Dependencies().AddOrUpdate(dep)
61+
62+
result, err := list([]string{}, command.GlobalFlags{}, logger, nil, state)
63+
64+
assert.NoError(t, err)
65+
listResult, ok := result.(*ListResult)
66+
assert.True(t, ok)
67+
assert.Equal(t, 1, len(listResult.Dependencies))
68+
69+
depInfo := listResult.Dependencies[0]
70+
assert.Equal(t, "TestContract", depInfo.Name)
71+
assert.Equal(t, "emulator", depInfo.NetworkName)
72+
assert.Equal(t, serviceAcc.Address.String(), depInfo.Address)
73+
assert.Equal(t, "TestContract", depInfo.Contract)
74+
})
75+
}
76+
77+
func TestListResult_JSON(t *testing.T) {
78+
t.Run("JSON output", func(t *testing.T) {
79+
result := &ListResult{
80+
Dependencies: []DependencyInfo{
81+
{
82+
Name: "TestContract",
83+
NetworkName: "emulator",
84+
Address: "0x01cf0e2f2f715450",
85+
Contract: "TestContract",
86+
},
87+
},
88+
}
89+
90+
jsonOutput := result.JSON()
91+
assert.Equal(t, result, jsonOutput)
92+
})
93+
}

internal/prompt/select.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ type optionSelectModel struct {
3535
cursor int // position of the cursor
3636
choices []string // items on the list
3737
selected map[int]struct{} // which items are selected
38+
footer string // optional footer message
3839
}
3940

4041
// selectOptions creates a prompt for selecting multiple options but is now private
@@ -46,6 +47,16 @@ func selectOptions(options []string, message string) optionSelectModel {
4647
}
4748
}
4849

50+
// selectOptionsWithFooter creates a prompt for selecting multiple options with footer message
51+
func selectOptionsWithFooter(options []string, message string, footer string) optionSelectModel {
52+
return optionSelectModel{
53+
message: message,
54+
choices: options,
55+
selected: make(map[int]struct{}),
56+
footer: footer,
57+
}
58+
}
59+
4960
func (m optionSelectModel) Init() tea.Cmd {
5061
return nil // No initial command
5162
}
@@ -107,16 +118,27 @@ func (m optionSelectModel) View() string {
107118
b.WriteString(choice + "\n")
108119
}
109120
}
121+
122+
// Add footer message if present
123+
if m.footer != "" {
124+
b.WriteString("\n" + branding.GrayStyle.Render(m.footer))
125+
}
126+
110127
return b.String()
111128
}
112129

113130
// RunSelectOptions remains public and is the interface for external usage.
114131
func RunSelectOptions(options []string, message string) ([]string, error) {
132+
return RunSelectOptionsWithFooter(options, message, "")
133+
}
134+
135+
// RunSelectOptionsWithFooter runs the selection prompt with an optional footer message.
136+
func RunSelectOptionsWithFooter(options []string, message string, footer string) ([]string, error) {
115137
// Non-interactive fallback for CI: return no selection
116138
if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
117139
return []string{}, nil
118140
}
119-
model := selectOptions(options, message)
141+
model := selectOptionsWithFooter(options, message, footer)
120142
p := tea.NewProgram(model)
121143
finalModel, err := p.Run()
122144
if err != nil {

0 commit comments

Comments
 (0)