Skip to content

Commit 2e055cf

Browse files
committed
[REL-13574] feedback
1 parent 6499ab4 commit 2e055cf

6 files changed

Lines changed: 403 additions & 33 deletions

File tree

cmd/setup/wizard.go

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const (
2828
stepSelectProject wizardStep = iota
2929
stepSelectEnvironment
3030
stepDetect
31+
stepSelectSDK
3132
stepInstall
3233
stepCreateFlag
3334
stepInit
@@ -59,6 +60,7 @@ type wizardModel struct {
5960
environments []envItem
6061
projectList list.Model
6162
envList list.Model
63+
sdkList list.Model
6264

6365
selectedProject string
6466
selectedEnv string
@@ -75,6 +77,16 @@ type wizardModel struct {
7577
quitting bool
7678
}
7779

80+
type sdkItem struct {
81+
id string
82+
language string
83+
name string
84+
}
85+
86+
func (s sdkItem) Title() string { return s.name }
87+
func (s sdkItem) Description() string { return s.language }
88+
func (s sdkItem) FilterValue() string { return s.name }
89+
7890
type projectItem struct {
7991
key string
8092
name string
@@ -102,6 +114,7 @@ type envDetailsFetchedMsg struct {
102114
mobileKey string
103115
}
104116
type detectDoneMsg struct{ result *setup.DetectResult }
117+
type detectFailedMsg struct{}
105118
type installDoneMsg struct{ result *setup.InstallResult }
106119
type flagCreatedMsg struct{ key string }
107120
type initDoneMsg struct{ result *setup.InitResult }
@@ -186,6 +199,18 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
186199
m.step = stepDetect
187200
return m, m.runDetect()
188201

202+
case detectFailedMsg:
203+
items := make([]list.Item, len(setup.KnownSDKs))
204+
for i, sdk := range setup.KnownSDKs {
205+
items[i] = sdkItem{id: sdk.ID, language: sdk.Language, name: sdk.Name}
206+
}
207+
delegate := list.NewDefaultDelegate()
208+
m.sdkList = list.New(items, delegate, m.width, m.height-4)
209+
m.sdkList.Title = "Select your SDK:"
210+
m.sdkList.SetShowStatusBar(false)
211+
m.step = stepSelectSDK
212+
return m, nil
213+
189214
case detectDoneMsg:
190215
m.detectResult = msg.result
191216
m.step = stepInstall
@@ -232,6 +257,8 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
232257
m.projectList, cmd = m.projectList.Update(msg)
233258
case stepSelectEnvironment:
234259
m.envList, cmd = m.envList.Update(msg)
260+
case stepSelectSDK:
261+
m.sdkList, cmd = m.sdkList.Update(msg)
235262
}
236263
return m, cmd
237264
}
@@ -261,6 +288,18 @@ func (m wizardModel) handleEnter() (tea.Model, tea.Cmd) {
261288
m.selectedEnv = selected.key
262289
return m, m.fetchEnvDetails()
263290

291+
case stepSelectSDK:
292+
selected, ok := m.sdkList.SelectedItem().(sdkItem)
293+
if !ok {
294+
return m, nil
295+
}
296+
m.detectResult = &setup.DetectResult{
297+
SDKID: selected.id,
298+
Language: selected.language,
299+
}
300+
m.step = stepInstall
301+
return m, m.runInstall()
302+
264303
case stepWaitForApp:
265304
m.step = stepVerify
266305
return m, m.runVerify()
@@ -295,6 +334,9 @@ func (m wizardModel) View() string {
295334
case stepDetect:
296335
return m.spinner.View() + " Detecting project type..."
297336

337+
case stepSelectSDK:
338+
return m.sdkList.View()
339+
298340
case stepInstall:
299341
return m.spinner.View() + " Installing SDK..."
300342

@@ -440,7 +482,7 @@ func (m wizardModel) runDetect() tea.Cmd {
440482
}
441483
result, err := m.detector.Detect(dir)
442484
if err != nil {
443-
return wizardErrMsg{err: err}
485+
return detectFailedMsg{}
444486
}
445487
return detectDoneMsg{result: result}
446488
}

cmd/setup/wizard_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package setup
2+
3+
import (
4+
"testing"
5+
6+
tea "github.com/charmbracelet/bubbletea"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/launchdarkly/ldcli/internal/setup"
11+
)
12+
13+
func TestWizard_DetectFailed_TransitionsToSDKSelection(t *testing.T) {
14+
m := wizardModel{step: stepDetect}
15+
16+
next, _ := m.Update(detectFailedMsg{})
17+
updated := next.(wizardModel)
18+
19+
assert.Equal(t, stepSelectSDK, updated.step)
20+
assert.Equal(t, len(setup.KnownSDKs), len(updated.sdkList.Items()))
21+
}
22+
23+
func TestWizard_DetectFailed_SDKListTitles(t *testing.T) {
24+
m := wizardModel{step: stepDetect}
25+
26+
next, _ := m.Update(detectFailedMsg{})
27+
updated := next.(wizardModel)
28+
29+
// Verify the list items match KnownSDKs in order
30+
for i, item := range updated.sdkList.Items() {
31+
sdk := item.(sdkItem)
32+
assert.Equal(t, setup.KnownSDKs[i].ID, sdk.id)
33+
assert.Equal(t, setup.KnownSDKs[i].Name, sdk.name)
34+
assert.Equal(t, setup.KnownSDKs[i].Language, sdk.language)
35+
}
36+
}
37+
38+
func TestWizard_SelectSDK_SetsDetectResultAndProceedsToInstall(t *testing.T) {
39+
m := wizardModel{step: stepDetect}
40+
41+
// Transition to stepSelectSDK
42+
next, _ := m.Update(detectFailedMsg{})
43+
updated := next.(wizardModel)
44+
require.Equal(t, stepSelectSDK, updated.step)
45+
46+
// Press enter — selects the first SDK in the list
47+
next, cmd := updated.Update(tea.KeyMsg{Type: tea.KeyEnter})
48+
selected := next.(wizardModel)
49+
50+
assert.Equal(t, stepInstall, selected.step)
51+
require.NotNil(t, selected.detectResult)
52+
assert.Equal(t, setup.KnownSDKs[0].ID, selected.detectResult.SDKID)
53+
assert.Equal(t, setup.KnownSDKs[0].Language, selected.detectResult.Language)
54+
// cmd is the runInstall tea.Cmd — should be non-nil
55+
assert.NotNil(t, cmd)
56+
}
57+
58+
func TestWizard_SelectSDK_EmptyList_DoesNotPanic(t *testing.T) {
59+
m := wizardModel{step: stepSelectSDK}
60+
// sdkList not initialized — SelectedItem returns nil
61+
62+
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
63+
updated := next.(wizardModel)
64+
65+
// Should stay on stepSelectSDK without panicking
66+
assert.Equal(t, stepSelectSDK, updated.step)
67+
assert.Nil(t, updated.detectResult)
68+
}

internal/setup/detector.go

Lines changed: 123 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ func (FileDetector) Detect(dir string) (*DetectResult, error) {
5252
if result := detectJava(dir); result != nil {
5353
return result, nil
5454
}
55+
if result := detectSwift(dir); result != nil {
56+
return result, nil
57+
}
58+
if result := detectDotnet(dir); result != nil {
59+
return result, nil
60+
}
5561
return nil, errors.New("could not detect project language from directory; try specifying --sdk-id manually")
5662
}
5763

@@ -93,54 +99,55 @@ func detectNode(dir string) *DetectResult {
9399
}
94100
}
95101

96-
if _, ok := allDeps["react"]; ok {
102+
if _, ok := allDeps["react-native"]; ok {
97103
return &DetectResult{
98104
Language: "JavaScript",
99-
Framework: "React",
105+
Framework: "React Native",
100106
PackageManager: pm,
101-
SDKID: "react-client-sdk",
107+
SDKID: "react-native",
102108
EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{
103109
"src/App.tsx", "src/App.jsx", "src/App.js",
104110
"src/index.tsx", "src/index.jsx", "src/index.js",
105111
"index.js",
106112
})),
107113
}
108114
}
109-
if _, ok := allDeps["react-native"]; ok {
115+
if _, ok := allDeps["react"]; ok {
110116
return &DetectResult{
111117
Language: "JavaScript",
112-
Framework: "React Native",
118+
Framework: "React",
113119
PackageManager: pm,
114-
SDKID: "react-native-client-sdk",
120+
SDKID: "react-client-sdk",
115121
EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{
116122
"src/App.tsx", "src/App.jsx", "src/App.js",
117123
"src/index.tsx", "src/index.jsx", "src/index.js",
118124
"index.js",
119125
})),
120126
}
121127
}
122-
jsClientFrameworks := map[string]string{
123-
"backbone": "Backbone",
124-
"svelte": "Svelte",
125-
"vue": "Vue",
126-
"@angular/core": "Angular",
127-
"ember-source": "Ember",
128-
}
129-
for dep, framework := range jsClientFrameworks {
130-
if _, ok := allDeps[dep]; ok {
131-
return &DetectResult{
132-
Language: "JavaScript",
133-
Framework: framework,
134-
PackageManager: pm,
135-
SDKID: "js-client-sdk",
136-
EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{
137-
"src/App.tsx", "src/App.jsx", "src/App.js",
138-
"src/index.tsx", "src/index.jsx", "src/index.js",
139-
"src/main.ts", "src/main.js","index.js",
140-
})),
141-
}
142-
}
143-
}
128+
jsClientFrameworks := []struct{ dep, framework string }{
129+
{"backbone", "Backbone"},
130+
{"svelte", "Svelte"},
131+
{"vue", "Vue"},
132+
{"@angular/core", "Angular"},
133+
{"ember-source", "Ember"},
134+
{"preact", "Preact"},
135+
}
136+
for _, fw := range jsClientFrameworks {
137+
if _, ok := allDeps[fw.dep]; ok {
138+
return &DetectResult{
139+
Language: "JavaScript",
140+
Framework: fw.framework,
141+
PackageManager: pm,
142+
SDKID: "js-client-sdk",
143+
EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{
144+
"src/App.tsx", "src/App.jsx", "src/App.js",
145+
"src/index.tsx", "src/index.jsx", "src/index.js",
146+
"src/main.ts", "src/main.js", "index.js",
147+
})),
148+
}
149+
}
150+
}
144151

145152

146153
return &DetectResult{
@@ -177,7 +184,7 @@ func detectGo(dir string) *DetectResult {
177184
Language: "Go",
178185
PackageManager: "go",
179186
SDKID: "go-server-sdk",
180-
EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{"main.go", "cmd/main.go"})),
187+
EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{"cmd/main.go", "main.go"})),
181188
}
182189
}
183190

@@ -189,7 +196,7 @@ func detectPython(dir string) *DetectResult {
189196
PackageManager: "pip",
190197
SDKID: "python-server-sdk",
191198
EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{
192-
"main.py", "app.py", "manage.py", "src/main.py",
199+
"src/main.py", "manage.py", "app.py", "main.py",
193200
})),
194201
}
195202
}
@@ -204,6 +211,20 @@ func detectJava(dir string) *DetectResult {
204211
if indicator == "pom.xml" {
205212
pm = "mvn"
206213
}
214+
// Android projects use Gradle but are distinguished by AndroidManifest.xml.
215+
for _, manifest := range []string{
216+
"app/src/main/AndroidManifest.xml",
217+
"src/main/AndroidManifest.xml",
218+
} {
219+
if _, err := os.Stat(filepath.Join(dir, manifest)); err == nil {
220+
return &DetectResult{
221+
Language: "Java",
222+
PackageManager: "gradle",
223+
SDKID: "android-client-sdk",
224+
EntryPoint: filepath.Join(dir, "app/src/main/java/MainActivity.java"),
225+
}
226+
}
227+
}
207228
return &DetectResult{
208229
Language: "Java",
209230
PackageManager: pm,
@@ -215,6 +236,78 @@ func detectJava(dir string) *DetectResult {
215236
return nil
216237
}
217238

239+
func detectSwift(dir string) *DetectResult {
240+
pm := "spm"
241+
if _, err := os.Stat(filepath.Join(dir, "Podfile")); err == nil {
242+
pm = "cocoapods"
243+
}
244+
indicators := []string{"Package.swift", "Podfile"}
245+
for _, f := range indicators {
246+
if _, err := os.Stat(filepath.Join(dir, f)); err == nil {
247+
return &DetectResult{
248+
Language: "Swift",
249+
PackageManager: pm,
250+
SDKID: "swift-client-sdk",
251+
EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{
252+
"Sources/main.swift", "App.swift", "ContentView.swift", "AppDelegate.swift",
253+
})),
254+
}
255+
}
256+
}
257+
matches, _ := filepath.Glob(filepath.Join(dir, "*.xcodeproj"))
258+
if len(matches) > 0 {
259+
return &DetectResult{
260+
Language: "Swift",
261+
PackageManager: pm,
262+
SDKID: "swift-client-sdk",
263+
EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{
264+
"Sources/main.swift", "App.swift", "ContentView.swift", "AppDelegate.swift",
265+
})),
266+
}
267+
}
268+
return nil
269+
}
270+
271+
func detectDotnet(dir string) *DetectResult {
272+
for _, pattern := range []string{"*.csproj", "*.sln"} {
273+
matches, _ := filepath.Glob(filepath.Join(dir, pattern))
274+
if len(matches) > 0 {
275+
return &DetectResult{
276+
Language: "C#",
277+
PackageManager: "dotnet",
278+
SDKID: "dotnet-server-sdk",
279+
EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{
280+
"Program.cs", "Startup.cs", "src/Program.cs",
281+
})),
282+
}
283+
}
284+
}
285+
return nil
286+
}
287+
288+
// SDKOption describes a LaunchDarkly SDK available for use with ldcli setup.
289+
type SDKOption struct {
290+
ID string
291+
Language string
292+
Name string
293+
}
294+
295+
// KnownSDKs is the ordered list of SDKs available for manual selection when
296+
// auto-detection fails or the user wants to override the detected SDK.
297+
var KnownSDKs = []SDKOption{
298+
{ID: "node-server", Language: "JavaScript", Name: "Node.js"},
299+
{ID: "react-client-sdk", Language: "JavaScript", Name: "React"},
300+
{ID: "react-native", Language: "JavaScript", Name: "React Native"},
301+
{ID: "js-client-sdk", Language: "JavaScript", Name: "JavaScript (Browser)"},
302+
{ID: "python-server-sdk", Language: "Python", Name: "Python"},
303+
{ID: "go-server-sdk", Language: "Go", Name: "Go"},
304+
{ID: "java-server-sdk", Language: "Java", Name: "Java"},
305+
{ID: "android-client-sdk", Language: "Java", Name: "Android"},
306+
{ID: "dotnet-server-sdk", Language: "C#", Name: ".NET"},
307+
{ID: "swift-client-sdk", Language: "Swift", Name: "iOS/Swift"},
308+
{ID: "ruby-server-sdk", Language: "Ruby", Name: "Ruby"},
309+
}
310+
218311
// firstExistingIn returns the first candidate that exists as a file in dir,
219312
// or the last candidate if none exist (as a suggested path).
220313
// Returns an empty string if candidates is empty.

0 commit comments

Comments
 (0)