github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/codespace/common.go (about) 1 package codespace 2 3 // This file defines functions common to the entire codespace command set. 4 5 import ( 6 "context" 7 "errors" 8 "fmt" 9 "io" 10 "log" 11 "os" 12 "sort" 13 14 "github.com/AlecAivazis/survey/v2" 15 "github.com/AlecAivazis/survey/v2/terminal" 16 "github.com/ungtb10d/cli/v2/internal/browser" 17 "github.com/ungtb10d/cli/v2/internal/codespaces" 18 "github.com/ungtb10d/cli/v2/internal/codespaces/api" 19 "github.com/ungtb10d/cli/v2/pkg/iostreams" 20 "github.com/ungtb10d/cli/v2/pkg/liveshare" 21 "github.com/spf13/cobra" 22 "golang.org/x/crypto/ssh" 23 "golang.org/x/term" 24 ) 25 26 type executable interface { 27 Executable() string 28 } 29 30 type App struct { 31 io *iostreams.IOStreams 32 apiClient apiClient 33 errLogger *log.Logger 34 executable executable 35 browser browser.Browser 36 } 37 38 func NewApp(io *iostreams.IOStreams, exe executable, apiClient apiClient, browser browser.Browser) *App { 39 errLogger := log.New(io.ErrOut, "", 0) 40 41 return &App{ 42 io: io, 43 apiClient: apiClient, 44 errLogger: errLogger, 45 executable: exe, 46 browser: browser, 47 } 48 } 49 50 // StartProgressIndicatorWithLabel starts a progress indicator with a message. 51 func (a *App) StartProgressIndicatorWithLabel(s string) { 52 a.io.StartProgressIndicatorWithLabel(s) 53 } 54 55 // StopProgressIndicator stops the progress indicator. 56 func (a *App) StopProgressIndicator() { 57 a.io.StopProgressIndicator() 58 } 59 60 type liveshareSession interface { 61 Close() error 62 GetSharedServers(context.Context) ([]*liveshare.Port, error) 63 KeepAlive(string) 64 OpenStreamingChannel(context.Context, liveshare.ChannelID) (ssh.Channel, error) 65 StartJupyterServer(context.Context) (int, string, error) 66 StartSharing(context.Context, string, int) (liveshare.ChannelID, error) 67 StartSSHServer(context.Context) (int, string, error) 68 StartSSHServerWithOptions(context.Context, liveshare.StartSSHServerOptions) (int, string, error) 69 RebuildContainer(context.Context, bool) error 70 } 71 72 // Connects to a codespace using Live Share and returns that session 73 func startLiveShareSession(ctx context.Context, codespace *api.Codespace, a *App, debug bool, debugFile string) (session liveshareSession, err error) { 74 liveshareLogger := noopLogger() 75 if debug { 76 debugLogger, err := newFileLogger(debugFile) 77 if err != nil { 78 return nil, fmt.Errorf("couldn't create file logger: %w", err) 79 } 80 defer safeClose(debugLogger, &err) 81 82 liveshareLogger = debugLogger.Logger 83 a.errLogger.Printf("Debug file located at: %s", debugLogger.Name()) 84 } 85 86 session, err = codespaces.ConnectToLiveshare(ctx, a, liveshareLogger, a.apiClient, codespace) 87 if err != nil { 88 return nil, fmt.Errorf("failed to connect to Live Share: %w", err) 89 } 90 91 return session, nil 92 } 93 94 //go:generate moq -fmt goimports -rm -skip-ensure -out mock_api.go . apiClient 95 type apiClient interface { 96 GetCodespace(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error) 97 GetOrgMemberCodespace(ctx context.Context, orgName string, userName string, codespaceName string) (*api.Codespace, error) 98 ListCodespaces(ctx context.Context, opts api.ListCodespacesOptions) ([]*api.Codespace, error) 99 DeleteCodespace(ctx context.Context, name string, orgName string, userName string) error 100 StartCodespace(ctx context.Context, name string) error 101 StopCodespace(ctx context.Context, name string, orgName string, userName string) error 102 CreateCodespace(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) 103 EditCodespace(ctx context.Context, codespaceName string, params *api.EditCodespaceParams) (*api.Codespace, error) 104 GetRepository(ctx context.Context, nwo string) (*api.Repository, error) 105 GetCodespacesMachines(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*api.Machine, error) 106 GetCodespaceRepositoryContents(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) 107 ListDevContainers(ctx context.Context, repoID int, branch string, limit int) (devcontainers []api.DevContainerEntry, err error) 108 GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error) 109 GetCodespaceBillableOwner(ctx context.Context, nwo string) (*api.User, error) 110 } 111 112 var errNoCodespaces = errors.New("you have no codespaces") 113 114 func chooseCodespace(ctx context.Context, apiClient apiClient) (*api.Codespace, error) { 115 codespaces, err := apiClient.ListCodespaces(ctx, api.ListCodespacesOptions{}) 116 if err != nil { 117 return nil, fmt.Errorf("error getting codespaces: %w", err) 118 } 119 return chooseCodespaceFromList(ctx, codespaces, false) 120 } 121 122 // chooseCodespaceFromList returns the codespace that the user has interactively selected from the list, or 123 // an error if there are no codespaces. 124 func chooseCodespaceFromList(ctx context.Context, codespaces []*api.Codespace, includeOwner bool) (*api.Codespace, error) { 125 if len(codespaces) == 0 { 126 return nil, errNoCodespaces 127 } 128 129 sortedCodespaces := codespaces 130 sort.Slice(sortedCodespaces, func(i, j int) bool { 131 return sortedCodespaces[i].CreatedAt > sortedCodespaces[j].CreatedAt 132 }) 133 134 csSurvey := []*survey.Question{ 135 { 136 Name: "codespace", 137 Prompt: &survey.Select{ 138 Message: "Choose codespace:", 139 Options: formatCodespacesForSelect(sortedCodespaces, includeOwner), 140 }, 141 Validate: survey.Required, 142 }, 143 } 144 145 var answers struct { 146 Codespace int 147 } 148 if err := ask(csSurvey, &answers); err != nil { 149 return nil, fmt.Errorf("error getting answers: %w", err) 150 } 151 152 return sortedCodespaces[answers.Codespace], nil 153 } 154 155 func formatCodespacesForSelect(codespaces []*api.Codespace, includeOwner bool) []string { 156 names := make([]string, len(codespaces)) 157 158 for i, apiCodespace := range codespaces { 159 cs := codespace{apiCodespace} 160 names[i] = cs.displayName(includeOwner) 161 } 162 163 return names 164 } 165 166 // getOrChooseCodespace prompts the user to choose a codespace if the codespaceName is empty. 167 // It then fetches the codespace record with full connection details. 168 // TODO(josebalius): accept a progress indicator or *App and show progress when fetching. 169 func getOrChooseCodespace(ctx context.Context, apiClient apiClient, codespaceName string) (codespace *api.Codespace, err error) { 170 if codespaceName == "" { 171 codespace, err = chooseCodespace(ctx, apiClient) 172 if err != nil { 173 if err == errNoCodespaces { 174 return nil, err 175 } 176 return nil, fmt.Errorf("choosing codespace: %w", err) 177 } 178 } else { 179 codespace, err = apiClient.GetCodespace(ctx, codespaceName, true) 180 if err != nil { 181 return nil, fmt.Errorf("getting full codespace details: %w", err) 182 } 183 } 184 185 if codespace.PendingOperation { 186 return nil, fmt.Errorf( 187 "codespace is disabled while it has a pending operation: %s", 188 codespace.PendingOperationDisabledReason, 189 ) 190 } 191 192 return codespace, nil 193 } 194 195 func safeClose(closer io.Closer, err *error) { 196 if closeErr := closer.Close(); *err == nil { 197 *err = closeErr 198 } 199 } 200 201 // hasTTY indicates whether the process connected to a terminal. 202 // It is not portable to assume stdin/stdout are fds 0 and 1. 203 var hasTTY = term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) 204 205 // ask asks survey questions on the terminal, using standard options. 206 // It fails unless hasTTY, but ideally callers should avoid calling it in that case. 207 func ask(qs []*survey.Question, response interface{}) error { 208 if !hasTTY { 209 return fmt.Errorf("no terminal") 210 } 211 err := survey.Ask(qs, response, survey.WithShowCursor(true)) 212 // The survey package temporarily clears the terminal's ISIG mode bit 213 // (see tcsetattr(3)) so the QUIT button (Ctrl-C) is reported as 214 // ASCII \x03 (ETX) instead of delivering SIGINT to the application. 215 // So we have to serve ourselves the SIGINT. 216 // 217 // https://github.com/AlecAivazis/survey/#why-isnt-ctrl-c-working 218 if err == terminal.InterruptErr { 219 self, _ := os.FindProcess(os.Getpid()) 220 _ = self.Signal(os.Interrupt) // assumes POSIX 221 222 // Suspend the goroutine, to avoid a race between 223 // return from main and async delivery of INT signal. 224 select {} 225 } 226 return err 227 } 228 229 var ErrTooManyArgs = errors.New("the command accepts no arguments") 230 231 func noArgsConstraint(cmd *cobra.Command, args []string) error { 232 if len(args) > 0 { 233 return ErrTooManyArgs 234 } 235 return nil 236 } 237 238 func noopLogger() *log.Logger { 239 return log.New(io.Discard, "", 0) 240 } 241 242 type codespace struct { 243 *api.Codespace 244 } 245 246 // displayName formats the codespace name for the interactive selector prompt. 247 func (c codespace) displayName(includeOwner bool) string { 248 branch := c.branchWithGitStatus() 249 displayName := c.DisplayName 250 251 if displayName == "" { 252 displayName = c.Name 253 } 254 255 description := fmt.Sprintf("%s (%s): %s", c.Repository.FullName, branch, displayName) 256 257 if includeOwner { 258 description = fmt.Sprintf("%-15s %s", c.Owner.Login, description) 259 } 260 261 return description 262 } 263 264 // gitStatusDirty represents an unsaved changes status. 265 const gitStatusDirty = "*" 266 267 // branchWithGitStatus returns the branch with a star 268 // if the branch is currently being worked on. 269 func (c codespace) branchWithGitStatus() string { 270 if c.hasUnsavedChanges() { 271 return c.GitStatus.Ref + gitStatusDirty 272 } 273 274 return c.GitStatus.Ref 275 } 276 277 // hasUnsavedChanges returns whether the environment has 278 // unsaved changes. 279 func (c codespace) hasUnsavedChanges() bool { 280 return c.GitStatus.HasUncommitedChanges || c.GitStatus.HasUnpushedChanges 281 } 282 283 // running returns whether the codespace environment is running. 284 func (c codespace) running() bool { 285 return c.State == api.CodespaceStateAvailable 286 }