Skip to content
32 changes: 30 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ import (
flagscmd "github.com/launchdarkly/ldcli/cmd/flags"
logincmd "github.com/launchdarkly/ldcli/cmd/login"
memberscmd "github.com/launchdarkly/ldcli/cmd/members"
sdkactivecmd "github.com/launchdarkly/ldcli/cmd/sdk_active"
resourcecmd "github.com/launchdarkly/ldcli/cmd/resources"
sdkactivecmd "github.com/launchdarkly/ldcli/cmd/sdk_active"
setupcmd "github.com/launchdarkly/ldcli/cmd/setup"
signupcmd "github.com/launchdarkly/ldcli/cmd/signup"
sourcemapscmd "github.com/launchdarkly/ldcli/cmd/sourcemaps"
symbolscmd "github.com/launchdarkly/ldcli/cmd/symbols"
Expand All @@ -37,6 +38,7 @@ import (
"github.com/launchdarkly/ldcli/internal/members"
"github.com/launchdarkly/ldcli/internal/projects"
"github.com/launchdarkly/ldcli/internal/resources"
"github.com/launchdarkly/ldcli/internal/setup"
)

type APIClients struct {
Expand All @@ -46,6 +48,8 @@ type APIClients struct {
MembersClient members.Client
ProjectsClient projects.Client
ResourcesClient resources.Client
Detector setup.Detector
Installer setup.Installer
}

type Command interface {
Expand Down Expand Up @@ -107,6 +111,7 @@ var authExemptCommands = map[string]bool{
"config": true,
"help": true,
"login": true,
"setup": true,
"signup": true,
"whoami": true,
}
Expand Down Expand Up @@ -264,7 +269,30 @@ func NewRootCommand(

configCmd := configcmd.NewConfigCmd(configService, analyticsTrackerFn)
cmd.AddCommand(configCmd.Cmd())
cmd.AddCommand(NewQuickStartCmd(analyticsTrackerFn, clients.EnvironmentsClient, clients.FlagsClient))
detector := clients.Detector
if detector == nil {
detector = setup.FileDetector{}
}
installer := clients.Installer
if installer == nil {
installer = setup.PackageInstaller{}
}
cmd.AddCommand(setupcmd.NewSetupCmd(
analyticsTrackerFn,
setup.Clients{
Projects: clients.ProjectsClient,
Environments: clients.EnvironmentsClient,
Flags: clients.FlagsClient,
Resources: clients.ResourcesClient,
},
detector,
installer,
))
quickStartCmd := NewQuickStartCmd(analyticsTrackerFn, clients.EnvironmentsClient, clients.FlagsClient)
quickStartCmd.Use = "quickstart"
quickStartCmd.Hidden = true
quickStartCmd.Deprecated = "use 'ldcli setup' for the new guided setup experience"
cmd.AddCommand(quickStartCmd)
cmd.AddCommand(logincmd.NewLoginCmd(clients.ResourcesClient))
cmd.AddCommand(signupcmd.NewSignupCmd(analyticsTrackerFn))
cmd.AddCommand(resourcecmd.NewResourcesCmd())
Expand Down
34 changes: 34 additions & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,3 +338,37 @@ func TestConfigOutputPrecedenceNonTTY(t *testing.T) {
assert.Contains(t, string(out), "Key:")
assert.Contains(t, string(out), "test-key")
}

// A rebase silently dropped the symbols registration once, so every top-level
// command the CLI ships is asserted here.
func TestNewRootCommand_RegistersTopLevelCommands(t *testing.T) {
rootCmd := newRootCmdWithTerminal(t, func() bool { return false }, nil)
c := rootCmd.Cmd()
// Execute wires this up; NewRootCommand does not.
c.InitDefaultCompletionCmd()

registered := make(map[string]bool, len(c.Commands()))
for _, sub := range c.Commands() {
registered[sub.Name()] = true
}

for _, name := range []string{
"completion",
"config",
"dev-server",
"flags",
"login",
"members",
"projects",
"quickstart",
"resources",
"segments",
"setup",
"signup",
"sourcemaps",
"symbols",
"whoami",
} {
assert.True(t, registered[name], "%s is not registered on the root command", name)
}
}
201 changes: 201 additions & 0 deletions cmd/setup/commands.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package setup

import (
"fmt"
"io"
"os"
"strings"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"
"golang.org/x/term"

"github.com/launchdarkly/ldcli/internal/setup"
)

func (m wizardModel) fetchProjects() tea.Cmd {
return func() tea.Msg {
ps, err := m.svc.ListProjects(m.auth)
if err != nil {
return wizardErrMsg{err: err}
}
projects := make([]projectItem, len(ps))
for i, p := range ps {
projects[i] = projectItem{key: p.Key, name: p.Name}
}
return projectsFetchedMsg{projects: projects}
}
}

func (m wizardModel) fetchEnvironments() tea.Cmd {
// Read the selection here rather than in the goroutine, so the message reports
// what was asked for even after the model has moved on.
project := m.selectedProject
return func() tea.Msg {
es, err := m.svc.ListEnvironments(m.auth, project)
if err != nil {
return wizardErrMsg{err: err}
}
envs := make([]envItem, len(es))
for i, e := range es {
envs[i] = envItem{key: e.Key, name: e.Name}
}
return envsFetchedMsg{project: project, environments: envs}
}
}

func (m wizardModel) fetchEnvDetails() tea.Cmd {
project, env := m.selectedProject, m.selectedEnv
return func() tea.Msg {
keys, err := m.svc.EnvKeys(m.auth, project, env)
if err != nil {
return wizardErrMsg{err: err}
}
return envDetailsFetchedMsg{
project: project,
env: env,
sdkKey: keys.SDKKey,
clientSideID: keys.ClientSideID,
mobileKey: keys.MobileKey,
}
}
}

func (m wizardModel) runDetect() tea.Cmd {
return func() tea.Msg {
dir, err := os.Getwd()
if err != nil {
return wizardErrMsg{err: err}
}
result, err := m.svc.Detect(dir)
if err != nil {
return detectFailedMsg{}
}
return detectDoneMsg{result: result}
}
}

func (m wizardModel) runInstall() tea.Cmd {
return func() tea.Msg {
dir, err := os.Getwd()
if err != nil {
return wizardErrMsg{err: err}
}
result, err := m.svc.Install(dir, m.detectResult)
if err != nil {
// Don't dead-end the interactive flow on a failed auto-install (e.g.
// Ruby gem perms, no network): surface the command to run by hand.
args, _ := setup.InstallArgs(m.detectResult.SDKID, m.detectResult.PackageManager)
return installDoneMsg{result: &setup.InstallResult{
SDKID: m.detectResult.SDKID,
Command: strings.Join(args, " "),
Failed: true,
FailureReason: err.Error(),
}}
}
return installDoneMsg{result: result}
}
}

func (m wizardModel) runCreateFlag() tea.Cmd {
return func() tea.Msg {
key, err := m.svc.CreateFlag(m.auth, m.selectedProject, "my-new-flag", "My New Flag")
if err != nil {
return wizardErrMsg{err: err}
}
return flagCreatedMsg{key: key}
}
}

func (m wizardModel) runInit() tea.Cmd {
return func() tea.Msg {
cfg := setup.InitConfig{
SDKKey: m.sdkKey,
ClientSideID: m.clientSideID,
MobileKey: m.mobileKey,
FlagKey: m.flagKey,
}
result, err := m.svc.Inject(m.detectResult.SDKID, m.detectResult.EntryPoint, cfg)
if err != nil {
return wizardErrMsg{err: err}
}
return initDoneMsg{result: result}
}
}

func (m wizardModel) runVerify() tea.Cmd {
return func() tea.Msg {
result, err := m.svc.Verify(m.auth, m.selectedProject, m.selectedEnv, m.detectResult.SDKID)
if err != nil {
return wizardErrMsg{err: err}
}
return verifyDoneMsg{result: result}
}
}

// copyableContent returns the code the current screen is asking the user to copy,
// along with the word the hint uses for it. A screen can show both an install command
// and a snippet; the snippet is the one that has to be pasted verbatim, so it wins.
// Returns false when the screen has nothing to copy.
func (m wizardModel) copyableContent() (content, label string, ok bool) {
if m.step != stepDone {
return "", "", false
}
if m.initResult != nil && !m.initResult.Success && m.initResult.Snippet != "" {
return m.initResult.Snippet, "snippet", true
}
if m.installResult != nil && m.installResult.Failed && m.installResult.Command != "" {
return m.installResult.Command, "command", true
}
return "", "", false
}

// copyToClipboard puts the content on the clipboard, preferring the operating
// system's own clipboard because it works in every terminal and reports whether it
// succeeded. OSC 52 is the fallback: it asks the terminal to do the copying, which is
// what works over SSH, where the OS clipboard belongs to the wrong machine. Not every
// terminal implements OSC 52 and support cannot be queried, so a copy that goes that
// route is reported as a request rather than a result.
func (m wizardModel) copyToClipboard(content string) tea.Cmd {
return func() tea.Msg {
// Over SSH the OS clipboard is the one on the machine running the code, not
// the one the user pastes into, and it can succeed there — so a remote
// session has to go to the terminal even though the local path would work.
if !m.remoteSession {
if err := m.nativeCopy(content); err == nil {
return copiedMsg{viaTerminal: false}
}
}
fmt.Fprint(m.clipboard, ansi.SetSystemClipboard(content))
return copiedMsg{viaTerminal: true}
}
}

// terminalWriter returns the writer to send OSC 52 to. It must not be stdout:
// Bubble Tea owns stdout for frame rendering while the wizard runs, so a
// sequence written there from a command goroutine can land in the middle of a
// frame. Stderr is preferred because it reaches the same terminal without that
// contention, but it may be redirected to a file or pipe, in which case the
// sequence would be swallowed instead of reaching the terminal — so fall back to
// the controlling terminal itself.
func terminalWriter() io.Writer {
if term.IsTerminal(int(os.Stderr.Fd())) {
return os.Stderr
}
if tty, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0); err == nil {
return tty
}
return os.Stderr
}

// isRemoteSession reports whether the CLI is running over SSH. sshd sets these for
// the session it owns, so they distinguish "the clipboard here is the user's" from
// "the user's clipboard is on the other end of the connection".
func isRemoteSession() bool {
for _, name := range []string{"SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"} {
if os.Getenv(name) != "" {
return true
}
}
return false
}
Loading
Loading