diff --git a/cmd/lk/agent.go b/cmd/lk/agent.go index 8959733f..ef5de4ec 100644 --- a/cmd/lk/agent.go +++ b/cmd/lk/agent.go @@ -491,8 +491,8 @@ func initAgent(ctx context.Context, cmd *cli.Command) error { }); err != nil { return fmt.Errorf("failed to create sandbox: %w", err) } else { - fmt.Println("Creating sandbox app...") - fmt.Printf("Created sandbox app [%s]\n", util.Accented(sandboxID)) + out.Status("Creating sandbox app...") + out.Statusf("Created sandbox app [%s]", util.Accented(sandboxID)) } } @@ -506,7 +506,7 @@ func initAgent(ctx context.Context, cmd *cli.Command) error { } // Deploy if requested if shouldDeploy { - fmt.Println("Deploying agent...") + out.Status("Deploying agent...") if err := createAgent(ctx, cmd); err != nil { return fmt.Errorf("failed to deploy agent: %w", err) } @@ -526,11 +526,13 @@ func createAgent(ctx context.Context, cmd *cli.Command) error { if !cmd.IsSet("project") { useProject := true if !SkipPrompts(cmd) { - if err := huh.NewForm(huh.NewGroup(huh.NewConfirm(). + if err := huh.NewForm(huh.NewGroup(util.Confirm(). Title(fmt.Sprintf("Use project [%s] (%s) to create agent deployment?", project.Name, project.URL)). Value(&useProject). - Negative("Select another"). - Inline(false). + Options( + huh.NewOption("Yes", true), + huh.NewOption("No, select another...", false), + ). WithTheme(util.Theme))). Run(); err != nil { return err @@ -540,6 +542,7 @@ func createAgent(ctx context.Context, cmd *cli.Command) error { if _, err := selectProject(ctx, cmd); err != nil { return err } + (&resolvedProject{project: project, source: sourceSelected}).announce() var err error // Recreate the client with the new project agentsClient, err = cloudagents.New(cloudagents.WithProject(project.URL, project.APIKey, project.APISecret)) @@ -565,13 +568,13 @@ func createAgent(ctx context.Context, cmd *cli.Command) error { if configExists && lkConfig.Agent != nil { if !silent { - fmt.Printf("Using agent configuration [%s]\n", util.Accented(tomlFilename)) + out.Statusf("Using agent configuration [%s]", util.Accented(tomlFilename)) } } else { lkConfig = config.NewLiveKitTOML(subdomainMatches[1]).WithDefaultAgent() } if !silent { - fmt.Printf("Creating new agent deployment\n") + out.Status("Creating new agent deployment") } secrets, err := requireSecrets(ctx, cmd, false, false) @@ -606,7 +609,7 @@ func createAgent(ctx context.Context, cmd *cli.Command) error { if err := lkConfig.SaveTOMLFile(workingDir, tomlFilename); err != nil { return err } - fmt.Printf("Created agent with ID [%s]\n", util.Accented(agentID)) + out.Statusf("Created agent with ID [%s]", util.Accented(agentID)) return deployPrebuiltImage(buildContext, agentID, imageRef, imageTar) } @@ -614,7 +617,7 @@ func createAgent(ctx context.Context, cmd *cli.Command) error { if err != nil { return noAgentError() } - fmt.Printf("Detected agent language [%s]\n", util.Accented(string(projectType))) + out.Statusf("Detected agent language [%s]", util.Accented(string(projectType))) if err := requireDockerfile(ctx, cmd, workingDir, projectType, settingsMap); err != nil { return err @@ -622,7 +625,7 @@ func createAgent(ctx context.Context, cmd *cli.Command) error { if err := agentfs.CheckSDKVersion(workingDir, projectType, settingsMap); err != nil { if cmd.Bool("skip-sdk-check") { - fmt.Printf("Error checking SDK version: %v, skipping...\n", err) + out.Warnf("Error checking SDK version: %v, skipping...", err) } else { return err } @@ -654,15 +657,15 @@ func createAgent(ctx context.Context, cmd *cli.Command) error { return err } - fmt.Printf("Created agent with ID [%s]\n", util.Accented(resp.AgentId)) + out.Statusf("Created agent with ID [%s]", util.Accented(resp.AgentId)) - fmt.Println("Build completed - You can view build logs later with `lk agent logs --log-type=build`") + out.Status("Build completed - You can view build logs later with `lk agent logs --log-type=build`") if !silent && !SkipPrompts(cmd) { viewLogs := true if err := huh.NewForm( huh.NewGroup( - huh.NewConfirm(). + util.Confirm(). Title("Agent deploying. Would you like to view logs?"). Description("You can view logs later with `lk agent logs`"). Value(&viewLogs). @@ -671,7 +674,7 @@ func createAgent(ctx context.Context, cmd *cli.Command) error { ).Run(); err != nil { return err } else if viewLogs { - fmt.Println("Tailing runtime logs...safe to exit at any time") + out.Status("Tailing runtime logs...safe to exit at any time") return agentsClient.StreamLogs(ctx, "deploy", lkConfig.Agent.ID, "", os.Stdout, resp.ServerRegions[0]) } } @@ -687,7 +690,7 @@ func createAgentConfig(ctx context.Context, cmd *cli.Command) error { var overwriteVal bool if err := huh.NewForm( huh.NewGroup( - huh.NewConfirm(). + util.Confirm(). Title( fmt.Sprintf("Config file [%s] file already exists. Overwrite?", tomlFilename), ). @@ -769,7 +772,7 @@ func deployAgent(ctx context.Context, cmd *cli.Command) error { if err := deployPrebuiltImage(buildContext, agentId, imageRef, imageTar); err != nil { return fmt.Errorf("unable to deploy prebuilt image: %w", err) } - fmt.Println("Deployed agent") + out.Status("Deployed agent") return nil } @@ -782,7 +785,7 @@ func deployAgent(ctx context.Context, cmd *cli.Command) error { if err != nil { return noAgentError() } - fmt.Printf("Detected agent language [%s]\n", util.Accented(string(projectType))) + out.Statusf("Detected agent language [%s]", util.Accented(string(projectType))) settingsMap, err := getClientSettings(ctx, cmd.Bool("silent")) if err != nil { @@ -791,7 +794,7 @@ func deployAgent(ctx context.Context, cmd *cli.Command) error { if err := agentfs.CheckSDKVersion(workingDir, projectType, settingsMap); err != nil { if cmd.Bool("skip-sdk-check") { - fmt.Printf("Error checking SDK version: %v, skipping...\n", err) + out.Warnf("Error checking SDK version: %v, skipping...", err) } else { return err } @@ -805,7 +808,7 @@ func deployAgent(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("unable to deploy agent: %w", err) } - fmt.Println("Deployed agent") + out.Status("Deployed agent") return nil } @@ -820,7 +823,7 @@ func deployPrebuiltImage(ctx context.Context, agentID, imageRef, imageTar string var img v1.Image if imageRef != "" { imageRef = strings.TrimSpace(imageRef) - fmt.Printf("Loading image from Docker daemon [%s]\n", util.Accented(imageRef)) + out.Statusf("Loading image from Docker daemon [%s]", util.Accented(imageRef)) var dockerCloser io.Closer img, dockerCloser, err = agentfs.LoadDockerDaemonImage(ctx, imageRef) if err != nil { @@ -828,7 +831,7 @@ func deployPrebuiltImage(ctx context.Context, agentID, imageRef, imageTar string } defer dockerCloser.Close() } else { - fmt.Printf("Loading image from [%s]\n", util.Accented(imageTar)) + out.Statusf("Loading image from [%s]", util.Accented(imageTar)) img, err = crane.Load(imageTar) if err != nil { return fmt.Errorf("failed to load image: %w", err) @@ -836,7 +839,7 @@ func deployPrebuiltImage(ctx context.Context, agentID, imageRef, imageTar string } proxyRef := fmt.Sprintf("%s/%s:%s", target.ProxyHost, target.Name, target.Tag) - fmt.Printf("Pushing image [%s]\n", util.Accented(proxyRef)) + out.Statusf("Pushing image [%s]", util.Accented(proxyRef)) rt := agentsClient.NewRegistryTransport() if err := crane.Push(img, proxyRef, @@ -903,7 +906,7 @@ func getAgentStatus(ctx context.Context, cmd *cli.Command) error { Headers("ID", "Version", "Region", "Status", "CPU", "Mem", "Replicas", "Deployed At"). Rows(rows...) - fmt.Println(t) + out.Result(t) return nil } @@ -923,7 +926,7 @@ func restartAgent(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("failed to restart agent: %s", resp.Message) } - fmt.Printf("Restarted agent [%s]\n", util.Accented(agentID)) + out.Statusf("Restarted agent [%s]", util.Accented(agentID)) return nil } @@ -965,7 +968,7 @@ func updateAgent(ctx context.Context, cmd *cli.Command) error { } if resp.Success { - fmt.Printf("Updated agent [%s]\n", util.Accented(lkConfig.Agent.ID)) + out.Statusf("Updated agent [%s]", util.Accented(lkConfig.Agent.ID)) err = lkConfig.SaveTOMLFile("", tomlFilename) return err } @@ -1000,7 +1003,7 @@ func rollbackAgent(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("failed to rollback agent %s", resp.Message) } - fmt.Printf("Rolled back agent [%s] to version [%s]\n", util.Accented(agentID), util.Accented(cmd.String("version"))) + out.Statusf("Rolled back agent [%s] to version [%s]", util.Accented(agentID), util.Accented(cmd.String("version"))) return nil } @@ -1036,10 +1039,9 @@ func deleteAgent(ctx context.Context, cmd *cli.Command) error { var confirmDelete bool if err := huh.NewForm( huh.NewGroup( - huh.NewConfirm(). + util.Confirm(). Title(fmt.Sprintf("Are you sure you want to delete agent [%s]?", agentID)). Value(&confirmDelete). - Inline(false). WithTheme(util.Theme), ), ).Run(); err != nil { @@ -1075,7 +1077,7 @@ func deleteAgent(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("failed to delete agent %s", res.Message) } - fmt.Printf("Deleted agent [%s]\n", util.Accented(agentID)) + out.Statusf("Deleted agent [%s]", util.Accented(agentID)) return nil } @@ -1130,7 +1132,7 @@ func listAgentVersions(ctx context.Context, cmd *cli.Command) error { table.Row(row...) } - fmt.Println(table) + out.Result(table) return nil } @@ -1164,7 +1166,7 @@ func listAgents(ctx context.Context, cmd *cli.Command) error { } if len(items) == 0 { - fmt.Println("No agents found") + out.Status("No agents found") return nil } @@ -1191,7 +1193,7 @@ func listAgents(ctx context.Context, cmd *cli.Command) error { Headers("ID", "Dispatch Name", "Regions", "Version", "Deployed At"). Rows(rows...) - fmt.Println(t) + out.Result(t) return nil } @@ -1225,7 +1227,7 @@ func listAgentSecrets(ctx context.Context, cmd *cli.Command) error { table.Row(secret.Name, secret.CreatedAt.AsTime().Format(time.RFC3339), secret.UpdatedAt.AsTime().Format(time.RFC3339)) } - fmt.Println(table) + out.Result(table) return nil } @@ -1246,10 +1248,9 @@ func updateAgentSecrets(ctx context.Context, cmd *cli.Command) error { if !SkipPrompts(cmd) { if err := huh.NewForm( huh.NewGroup( - huh.NewConfirm(). + util.Confirm(). Title(fmt.Sprintf("This will remove all existing secrets. Are you sure you want to proceed [%s]?", agentID)). Value(&confirmOverwrite). - Inline(false). WithTheme(util.Theme), ), ).Run(); err != nil { @@ -1276,7 +1277,7 @@ func updateAgentSecrets(ctx context.Context, cmd *cli.Command) error { } if resp.Success { - fmt.Println("Updated agent secrets") + out.Status("Updated agent secrets") return nil } @@ -1309,7 +1310,7 @@ func getAgentID(ctx context.Context, cmd *cli.Command, agentDir string, tomlFile return "", fmt.Errorf("agent ID or [%s] required", util.Accented(tomlFileName)) } - fmt.Printf("Using agent [%s]\n", util.Accented(agentID)) + out.Statusf("Using agent [%s]", util.Accented(agentID)) return agentID, nil } @@ -1408,7 +1409,7 @@ func requireSecrets(_ context.Context, cmd *cli.Command, required, lazy bool) ([ return nil, err } if file != "" && !silent { - fmt.Printf("Using secrets file [%s]\n", util.Accented(file)) + out.Statusf("Using secrets file [%s]", util.Accented(file)) } ignoreEmpty := cmd.Bool("ignore-empty-secrets") @@ -1438,7 +1439,7 @@ func requireSecrets(_ context.Context, cmd *cli.Command, required, lazy bool) ([ // Log skipped secrets if any (unless silent) if len(skippedEmpty) > 0 && !silent { skippedNames := strings.Join(skippedEmpty, ", ") - fmt.Printf("Skipped %d empty secret(s): %s\n", len(skippedEmpty), util.Dimmed(skippedNames)) + out.Statusf("Skipped %d empty secret(s): %s", len(skippedEmpty), util.Dimmed(skippedNames)) } } @@ -1486,7 +1487,7 @@ func requireDockerfile(ctx context.Context, cmd *cli.Command, workingDir string, if err != nil { return err } - fmt.Println("Created [" + util.Accented("Dockerfile") + "]") + out.Statusf("Created [%s]", util.Accented("Dockerfile")) } else { if err := agentfs.CreateDockerfile(workingDir, projectType, settingsMap, SkipPrompts(cmd)); err != nil { return err @@ -1494,21 +1495,21 @@ func requireDockerfile(ctx context.Context, cmd *cli.Command, workingDir string, } } else { if !cmd.Bool("silent") { - fmt.Println("Using existing Dockerfile") + out.Status("Using existing Dockerfile") } } if !dockerIgnoreExists { if !cmd.Bool("silent") { - fmt.Println("Creating .dockerignore...") + out.Status("Creating .dockerignore...") } if err := agentfs.CreateDockerIgnoreFile(workingDir, projectType); err != nil { return err } - fmt.Println("Created [" + util.Accented(".dockerignore") + "]") + out.Statusf("Created [%s]", util.Accented(".dockerignore")) } else { if !cmd.Bool("silent") { - fmt.Println("Using existing .dockerignore") + out.Status("Using existing .dockerignore") } } @@ -1587,7 +1588,7 @@ func resolveRegion(cmd *cli.Command, settingsMap map[string]string, title string Run(); err != nil { return "", err } - fmt.Fprintf(os.Stderr, "Using region [%s]\n", util.Accented(region)) + out.Statusf("Using region [%s]", util.Accented(region)) return region, nil } @@ -1620,7 +1621,7 @@ func generateAgentDockerfile(ctx context.Context, cmd *cli.Command) error { if err != nil { return noAgentError() } - fmt.Printf("Detected agent language [%s]\n", util.Accented(string(projectType))) + out.Statusf("Detected agent language [%s]", util.Accented(string(projectType))) dockerfilePath := filepath.Join(workingDir, "Dockerfile") dockerignorePath := filepath.Join(workingDir, ".dockerignore") @@ -1630,11 +1631,11 @@ func generateAgentDockerfile(ctx context.Context, cmd *cli.Command) error { writeDockerignore := true if !overwrite { if _, err := os.Stat(dockerfilePath); err == nil { - fmt.Println(util.Accented("Dockerfile") + " already exists; skipping. Use --overwrite to replace.") + out.Statusf("%s already exists; skipping. Use --overwrite to replace.", util.Accented("Dockerfile")) writeDockerfile = false } if _, err := os.Stat(dockerignorePath); err == nil { - fmt.Println(util.Accented(".dockerignore") + " already exists; skipping. Use --overwrite to replace.") + out.Statusf("%s already exists; skipping. Use --overwrite to replace.", util.Accented(".dockerignore")) writeDockerignore = false } } @@ -1654,14 +1655,14 @@ func generateAgentDockerfile(ctx context.Context, cmd *cli.Command) error { return err } - fmt.Printf("Wrote new %s\n", util.Accented("Dockerfile")) + out.Statusf("Wrote new %s", util.Accented("Dockerfile")) } if writeDockerignore { if err := os.WriteFile(dockerignorePath, dockerignoreContent, 0644); err != nil { return err } - fmt.Printf("Wrote new %s\n", util.Accented(".dockerignore")) + out.Statusf("Wrote new %s", util.Accented(".dockerignore")) } return nil diff --git a/cmd/lk/agent_private_link.go b/cmd/lk/agent_private_link.go index 76e141aa..477dd24e 100644 --- a/cmd/lk/agent_private_link.go +++ b/cmd/lk/agent_private_link.go @@ -209,19 +209,19 @@ func createPrivateLink(ctx context.Context, cmd *cli.Command) error { } if resp.PrivateLink == nil { - fmt.Println("Private link created") + out.Status("Private link created") return nil } - fmt.Printf("Created private link [%s]\n", util.Accented(resp.PrivateLink.PrivateLinkId)) + out.Statusf("Created private link [%s]", util.Accented(resp.PrivateLink.PrivateLinkId)) if resp.PrivateLink.Endpoint != "" { - fmt.Printf("Endpoint [%s]\n", util.Accented(resp.PrivateLink.Endpoint)) + out.Statusf("Endpoint [%s]", util.Accented(resp.PrivateLink.Endpoint)) } if resp.PrivateLink.ConnectionEndpoint != "" { - fmt.Printf("Gateway DNS [%s]\n", util.Accented(resp.PrivateLink.ConnectionEndpoint)) + out.Statusf("Gateway DNS [%s]", util.Accented(resp.PrivateLink.ConnectionEndpoint)) } if resp.PrivateLink.CloudRegion != "" { - fmt.Printf("Cloud Region [%s]\n", util.Accented(resp.PrivateLink.CloudRegion)) + out.Statusf("Cloud Region [%s]", util.Accented(resp.PrivateLink.CloudRegion)) } return nil } @@ -275,13 +275,13 @@ func listPrivateLinks(ctx context.Context, cmd *cli.Command) error { } if len(resp.Items) == 0 { - fmt.Println("No private links found") + out.Status("No private links found") return nil } rows := buildPrivateLinkListRows(resp.Items, healthByID, healthErrByID) table := util.CreateTable().Headers("ID", "Name", "Region", "Cloud Region", "Port", "Endpoint", "DNS", "Health", "Updated At", "Reason").Rows(rows...) - fmt.Println(table) + out.Result(table) return nil } @@ -298,7 +298,7 @@ func deletePrivateLink(ctx context.Context, cmd *cli.Command) error { util.PrintJSON(resp) return nil } - fmt.Printf("Deleted private link [%s]\n", util.Accented(privateLinkID)) + out.Statusf("Deleted private link [%s]", util.Accented(privateLinkID)) return nil } @@ -328,6 +328,6 @@ func getPrivateLinkHealthStatus(ctx context.Context, cmd *cli.Command) error { table := util.CreateTable(). Headers("ID", "Health", "Updated At", "Reason"). Row(privateLinkID, formatPrivateLinkHealthStatus(resp.Value.Status), updatedAt, reason) - fmt.Println(table) + out.Result(table) return nil } diff --git a/cmd/lk/agent_reload.go b/cmd/lk/agent_reload.go index 9115a21d..3ab60116 100644 --- a/cmd/lk/agent_reload.go +++ b/cmd/lk/agent_reload.go @@ -46,13 +46,13 @@ func (rs *reloadServer) captureJobs(conn net.Conn) { }, } if err := ipc.WriteProto(conn, req); err != nil { - fmt.Printf("reload: failed to send capture request: %v\n", err) + out.Warnf("reload: failed to send capture request: %v", err) return } resp := &agent.AgentDevMessage{} if err := ipc.ReadProto(conn, resp); err != nil { - fmt.Printf("reload: failed to read capture response: %v\n", err) + out.Warnf("reload: failed to read capture response: %v", err) return } @@ -60,7 +60,7 @@ func (rs *reloadServer) captureJobs(conn net.Conn) { rs.mu.Lock() rs.savedJobs = jobs rs.mu.Unlock() - fmt.Printf("reload: captured %d running job(s)\n", len(jobs.Jobs)) + out.Statusf("reload: captured %d running job(s)", len(jobs.Jobs)) } } @@ -90,9 +90,9 @@ func (rs *reloadServer) serveNewProcess(conn net.Conn) { }, } if err := ipc.WriteProto(conn, resp); err != nil { - fmt.Printf("reload: failed to send restore response: %v\n", err) + out.Warnf("reload: failed to send restore response: %v", err) } else if len(saved.Jobs) > 0 { - fmt.Printf("reload: restored %d job(s) to new process\n", len(saved.Jobs)) + out.Statusf("reload: restored %d job(s) to new process", len(saved.Jobs)) } } diff --git a/cmd/lk/agent_run.go b/cmd/lk/agent_run.go index 7f2eadaf..669805cc 100644 --- a/cmd/lk/agent_run.go +++ b/cmd/lk/agent_run.go @@ -19,7 +19,6 @@ package main import ( "context" "fmt" - "io" "os" "os/signal" "path/filepath" @@ -35,15 +34,6 @@ func init() { AgentCommands[0].Commands = append(AgentCommands[0].Commands, startCommand, devCommand) } -var ( - outputToStderr = func(p *loadParams) { - p.output = os.Stderr - } - quietOutput = func(p *loadParams) { - p.output = io.Discard - } -) - var agentRunFlags = []cli.Flag{ &cli.StringFlag{ Name: "log-level", @@ -166,9 +156,9 @@ func runAgentStart(ctx context.Context, cmd *cli.Command) error { if err != nil { return err } - fmt.Fprintf(os.Stderr, "Detected %s agent (%s in %s)\n", projectType.Lang(), entrypoint, projectDir) + out.Statusf("Detected %s agent (%s in %s)", projectType.Lang(), entrypoint, projectDir) - cliArgs, err := buildCLIArgs("start", cmd, quietOutput) + cliArgs, err := buildCLIArgs("start", cmd) if err != nil { return err } @@ -209,7 +199,7 @@ func runAgentDev(ctx context.Context, cmd *cli.Command) error { return err } - cliArgs, err := buildCLIArgs("start", cmd, outputToStderr) + cliArgs, err := buildCLIArgs("start", cmd) if err != nil { return err } @@ -226,7 +216,7 @@ func runAgentDev(ctx context.Context, cmd *cli.Command) error { ForwardOutput: os.Stdout, } - fmt.Fprintf(os.Stderr, "Detected %s agent (%s in %s)\n", projectType.Lang(), entrypoint, projectDir) + out.Statusf("Detected %s agent (%s in %s)", projectType.Lang(), entrypoint, projectDir) // Take over signal handling from the global NotifyContext. signal.Reset(syscall.SIGINT, syscall.SIGTERM) diff --git a/cmd/lk/agent_watcher.go b/cmd/lk/agent_watcher.go index d81f9e5f..8014a40c 100644 --- a/cmd/lk/agent_watcher.go +++ b/cmd/lk/agent_watcher.go @@ -135,7 +135,7 @@ func (aw *agentWatcher) restart() error { aw.agent.Kill() } - fmt.Fprintln(os.Stderr, "Reloading agent...") + out.Status("Reloading agent...") // 3. Start new process agent, err := startAgent(aw.config) @@ -227,10 +227,10 @@ func (aw *agentWatcher) Run(done <-chan struct{}) error { case <-debounceCh: debounceTimer = nil debounceCh = nil - fmt.Fprintf(os.Stderr, "File changed: %s\n", changedFile) + out.Statusf("File changed: %s", changedFile) if err := aw.restart(); err != nil { - fmt.Fprintf(os.Stderr, "Failed to restart agent: %v\n", err) - fmt.Fprintln(os.Stderr, "Waiting for file changes...") + out.Warnf("Failed to restart agent: %v", err) + out.Status("Waiting for file changes...") } else { exitCh = aw.agent.exitCh } @@ -239,7 +239,7 @@ func (aw *agentWatcher) Run(done <-chan struct{}) error { if !ok { return nil } - fmt.Fprintf(os.Stderr, "Watcher error: %v\n", err) + out.Warnf("Watcher error: %v", err) case <-exitCh: // Nil the channel so this case won't fire again (nil channels block forever) @@ -250,7 +250,7 @@ func (aw *agentWatcher) Run(done <-chan struct{}) error { debounceTimer = nil debounceCh = nil } - fmt.Fprintln(os.Stderr, "Agent exited. Waiting for file changes to restart...") + out.Status("Agent exited. Waiting for file changes to restart...") } } } diff --git a/cmd/lk/app.go b/cmd/lk/app.go index 7b81deaf..0b985b71 100644 --- a/cmd/lk/app.go +++ b/cmd/lk/app.go @@ -130,26 +130,66 @@ func requireProject(ctx context.Context, cmd *cli.Command) (context.Context, err } func requireProjectWithOpts(ctx context.Context, cmd *cli.Command, opts ...loadOption) (context.Context, error) { - var err error if project != nil { + // already resolved (and announced) earlier in this command return ctx, nil } + var err error if ctx, err = loadProjectConfig(ctx, cmd); err != nil { // something is wrong with CLI config file return ctx, err } - if project, err = loadProjectDetails(cmd, opts...); err != nil { - // something is wrong with project config file - if errors.Is(err, config.ErrInvalidConfig) { + + p := loadParams{requireURL: true} + for _, opt := range opts { + opt(&p) + } + + rp, err := resolveProject(cmd, p) + switch { + case errors.Is(err, config.ErrInvalidConfig): + // something is wrong with the project config file + return ctx, err + case err != nil: + // no project could be resolved automatically; choose from existing + // credentials or authenticate, then announce once below. + if ctx, err = selectProject(ctx, cmd); err != nil { return ctx, err } - // choose from existing credentials or authenticate - return selectProject(ctx, cmd) + rp = &resolvedProject{project: project, source: sourceSelected} + default: + // when asked to confirm, let the user accept the resolved default or pick another + if p.confirmProject && rp.source == sourceDefault && !SkipPrompts(cmd) && + !cmd.Bool("silent") && cliConfig != nil && len(cliConfig.Projects) > 1 { + useDefault := true + if err = huh.NewForm(huh.NewGroup(util.Confirm(). + Title(fmt.Sprintf("Use project [%s] (%s)?", rp.project.Name, rp.project.URL)). + Value(&useDefault). + Options( + huh.NewOption("Yes", true), + huh.NewOption("No, select another...", false), + ). + WithTheme(util.Theme))). + Run(); err != nil { + return ctx, fmt.Errorf("failed to confirm project: %w", err) + } + if !useDefault { + if ctx, err = selectProject(ctx, cmd); err != nil { + return ctx, err + } + rp = &resolvedProject{project: project, source: sourceSelected} + } + } + project = rp.project } - return ctx, err + rp.announce() + return ctx, nil } +// selectProject resolves the package-level `project` interactively: it picks from the +// configured projects, or (when none exist) offers to authenticate one via `lk cloud auth`. +// It does not print a confirmation; the caller announces the result exactly once. func selectProject(ctx context.Context, cmd *cli.Command) (context.Context, error) { var err error @@ -157,7 +197,6 @@ func selectProject(ctx context.Context, cmd *cli.Command) (context.Context, erro if SkipPrompts(cmd) { if len(cliConfig.Projects) == 1 { project = &cliConfig.Projects[0] - fmt.Fprintf(os.Stderr, "Using project [%s]\n", util.Accented(project.Name)) return ctx, nil } return nil, fmt.Errorf("multiple projects configured; set --project in non-interactive mode") @@ -176,31 +215,33 @@ func selectProject(ctx context.Context, cmd *cli.Command) (context.Context, erro Run(); err != nil { return nil, fmt.Errorf("no project selected: %w", err) } - fmt.Fprintf(os.Stderr, "Using project [%s]\n", util.Accented(project.Name)) - } else { - if SkipPrompts(cmd) { - return nil, fmt.Errorf("no projects configured; run `lk cloud auth` in an interactive terminal or set --project") - } - shouldAuth := true - if err = huh.NewForm(huh.NewGroup(huh.NewConfirm(). - Title("No local projects found. Authenticate one?"). - Inline(true). - Value(&shouldAuth). - WithTheme(util.Theme))). - Run(); err != nil { - return nil, fmt.Errorf("no project selected: %w", err) - } - if shouldAuth { - initAuth(ctx, cmd) - if err = tryAuthIfNeeded(ctx, cmd); err != nil { - return nil, fmt.Errorf("authentication failed: %w", err) - } - return requireProject(ctx, cmd) - } else { - return nil, ErrNoProjectSelected - } + return ctx, nil } + if SkipPrompts(cmd) { + return nil, fmt.Errorf("no projects configured; run `lk cloud auth` in an interactive terminal or set --project") + } + shouldAuth := true + if err = huh.NewForm(huh.NewGroup(util.Confirm(). + Title("No local projects found. Authenticate one?"). + Value(&shouldAuth). + WithTheme(util.Theme))). + Run(); err != nil { + return nil, fmt.Errorf("no project selected: %w", err) + } + if !shouldAuth { + return nil, ErrNoProjectSelected + } + initAuth(ctx, cmd) + if err = tryAuthIfNeeded(ctx, cmd); err != nil { + return nil, fmt.Errorf("authentication failed: %w", err) + } + // pick up the project just added by `lk cloud auth` + dp, err := config.LoadDefaultProject() + if err != nil { + return nil, ErrNoProjectSelected + } + project = dp return ctx, nil } @@ -224,7 +265,7 @@ func listTemplates(ctx context.Context, cmd *cli.Command) error { desc+"\n\n"+url+"\n"+tags, ) } - fmt.Println(table) + out.Result(table) } return nil } @@ -347,7 +388,7 @@ func setupTemplate(ctx context.Context, cmd *cli.Command) error { os.Setenv("LIVEKIT_AGENT_NAME", appName) os.Setenv("LIVEKIT_PROJECT_ID", project.ProjectId) - fmt.Println("Cloning template...") + out.Status("Cloning template...") if err := cloneTemplate(ctx, cmd, templateURL, appName); err != nil { return err } @@ -357,7 +398,7 @@ func setupTemplate(ctx context.Context, cmd *cli.Command) error { return err } - fmt.Println("Instantiating environment...") + out.Status("Instantiating environment...") addlEnv := &map[string]string{ "LIVEKIT_SANDBOX_ID": sandboxID, "NEXT_PUBLIC_LIVEKIT_SANDBOX_ID": sandboxID, @@ -384,19 +425,18 @@ func setupTemplate(ctx context.Context, cmd *cli.Command) error { bootstrap.WriteDotEnv(appName, envOutputFile, env, true) if !cmd.IsSet("install") && !SkipPrompts(cmd) { - if err := huh.NewConfirm(). + if err := huh.NewForm(huh.NewGroup(util.Confirm(). Title("Install dependencies?"). Value(&install). - Inline(true). - WithTheme(util.Theme). + WithTheme(util.Theme))). Run(); err != nil { return err } } if install { - fmt.Println("Installing template...") + out.Status("Installing template...") if err := doInstall(ctx, bootstrap.TaskInstall, appName, verbose); err != nil { - fmt.Fprintf(os.Stderr, "Warning: installation failed: %v\n", err) + out.Warnf("Warning: installation failed: %v", err) } } if err := doPostCreate(ctx, cmd, appName, verbose); err != nil { @@ -424,11 +464,15 @@ func cloneTemplate(ctx context.Context, cmd *cli.Command, url, appName string) e ) // err is handled after checking stdout and stderr - if len(stdout) > 0 && cmd.Bool("verbose") { - fmt.Println(string(stdout)) - } - if len(stderr) > 0 && cmd.Bool("verbose") { - fmt.Fprintln(os.Stderr, string(stderr)) + if cmd.Bool("verbose") { + // Subprocess output forwarded verbatim under --verbose; raw writes preserve + // any embedded formatting and skip the Printer's quiet/newline handling. + if len(stdout) > 0 { + fmt.Fprint(out.Out, stdout) + } + if len(stderr) > 0 { + fmt.Fprint(out.Err, stderr) + } } if err != nil { @@ -542,7 +586,7 @@ func doPostCreate(ctx context.Context, _ *cli.Command, rootPath string, verbose return nil } - fmt.Println("Cleaning up...") + out.Status("Cleaning up...") return task() } diff --git a/cmd/lk/cloud.go b/cmd/lk/cloud.go index d7a5b0bf..30f19d04 100644 --- a/cmd/lk/cloud.go +++ b/cmd/lk/cloud.go @@ -269,10 +269,10 @@ func tryAuthIfNeeded(ctx context.Context, cmd *cli.Command) error { if err := cliConfig.PersistIfNeeded(); err != nil { return err } - fmt.Printf("Device [%s]\n", util.Accented(cliConfig.DeviceName)) + out.Statusf("Device [%s]", util.Accented(cliConfig.DeviceName)) // request token - fmt.Println("Requesting verification token...") + out.Status("Requesting verification token...") token, err := authClient.GetVerificationToken(cliConfig.DeviceName) if err != nil { return err @@ -284,7 +284,7 @@ func tryAuthIfNeeded(ctx context.Context, cmd *cli.Command) error { } // poll for keys - fmt.Printf("Please confirm access by visiting:\n\n %s\n\n", authURL.String()) + out.Statusf("Please confirm access by visiting:\n\n %s\n", authURL.String()) _ = browser.OpenURL(authURL.String()) // discard result; this will fail in headless environments var ak *ClaimAccessKeyResponse @@ -305,16 +305,15 @@ func tryAuthIfNeeded(ctx context.Context, cmd *cli.Command) error { return errors.New("operation cancelled") } - fmt.Printf("Authenticated project [%s]\n", util.Accented(ak.ProjectName)) + out.Statusf("Authenticated project [%s]", util.Accented(ak.ProjectName)) // if other authed projects, ask if this should be the default project isDefault := len(cliConfig.Projects) == 0 if !isDefault { - if err := huh.NewConfirm(). + if err := huh.NewForm(huh.NewGroup(util.Confirm(). Title("Make this project default?"). Value(&isDefault). - Inline(true). - WithTheme(util.Theme). + WithTheme(util.Theme))). Run(); err != nil { return err } diff --git a/cmd/lk/console.go b/cmd/lk/console.go index 11d5b86a..56795b17 100644 --- a/cmd/lk/console.go +++ b/cmd/lk/console.go @@ -126,8 +126,8 @@ func runConsole(ctx context.Context, cmd *cli.Command) error { actualAddr := server.Addr().String() if inputDev != nil { - fmt.Fprintf(os.Stderr, "Input: %s\n", inputDev.Name) - fmt.Fprintf(os.Stderr, "Output: %s\n", outputDev.Name) + out.Statusf("Input: %s", inputDev.Name) + out.Statusf("Output: %s", outputDev.Name) } projectDir, projectType, entrypoint, err := detectProject(cmd) @@ -135,7 +135,7 @@ func runConsole(ctx context.Context, cmd *cli.Command) error { return err } - fmt.Fprintf(os.Stderr, "Detected %s agent (%s in %s)\n", projectType.Lang(), entrypoint, projectDir) + out.Statusf("Detected %s agent (%s in %s)", projectType.Lang(), entrypoint, projectDir) // Show spinner while starting agent stopSpinner := startSpinner("Starting agent") @@ -253,8 +253,8 @@ func listDevices() error { headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6")) defaultStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2")) - fmt.Println(headerStyle.Render(fmt.Sprintf(" %-4s %-8s %-45s %s", "#", "Type", "Name", "Default"))) - fmt.Println(strings.Repeat("─", 70)) + out.Result(headerStyle.Render(fmt.Sprintf(" %-4s %-8s %-45s %s", "#", "Type", "Name", "Default"))) + out.Result(strings.Repeat("─", 70)) for _, d := range devices { devType := "" @@ -277,9 +277,8 @@ func listDevices() error { defStr += defaultStyle.Render("✓ output") } - fmt.Printf(" %-4d %-8s %-45s %s\n", d.Index, devType, d.Name, defStr) + out.Resultf(" %-4d %-8s %-45s %s\n", d.Index, devType, d.Name, defStr) } return nil } - diff --git a/cmd/lk/dispatch.go b/cmd/lk/dispatch.go index 52569614..7e573915 100644 --- a/cmd/lk/dispatch.go +++ b/cmd/lk/dispatch.go @@ -17,7 +17,6 @@ package main import ( "context" "errors" - "fmt" "github.com/urfave/cli/v3" @@ -156,7 +155,7 @@ func listDispatchAndPrint(cmd *cli.Command, req *livekit.ListAgentDispatchReques item.Metadata, ) } - fmt.Println(table) + out.Result(table) } return nil } @@ -190,7 +189,7 @@ func createAgentDispatch(ctx context.Context, cmd *cli.Command) error { if cmd.Bool("json") { util.PrintJSON(info) } else { - fmt.Printf("Dispatch created: %v\n", info) + out.Resultf("Dispatch created: %v\n", info) } return nil @@ -221,7 +220,7 @@ func deleteAgentDispatch(ctx context.Context, cmd *cli.Command) error { if cmd.Bool("json") { util.PrintJSON(info) } else { - fmt.Printf("Dispatch deleted: %v\n", info) + out.Resultf("Dispatch deleted: %v\n", info) } return nil } diff --git a/cmd/lk/docs.go b/cmd/lk/docs.go index 241bc9d5..5d9511ec 100644 --- a/cmd/lk/docs.go +++ b/cmd/lk/docs.go @@ -19,7 +19,6 @@ import ( "errors" "fmt" "net/http" - "os" "strconv" "strings" "time" @@ -429,7 +428,7 @@ func callDocsToolAndPrint(ctx context.Context, cmd *cli.Command, tool string, ar for _, c := range result.Content { if tc, ok := c.(*mcp.TextContent); ok { - fmt.Println(tc.Text) + out.Result(tc.Text) } } return nil @@ -509,8 +508,8 @@ func checkServerVersion(session *mcp.ClientSession) { return } if major > expectedServerVersion[0] || (major == expectedServerVersion[0] && minor > expectedServerVersion[1]) { - fmt.Fprintf(os.Stderr, - "warning: the LiveKit docs server is version %s but this CLI was built for %d.%d.x — consider updating lk to the latest version\n\n", + out.Warnf( + "warning: the LiveKit docs server is version %s but this CLI was built for %d.%d.x — consider updating lk to the latest version\n", info.ServerInfo.Version, expectedServerVersion[0], expectedServerVersion[1], ) } diff --git a/cmd/lk/egress.go b/cmd/lk/egress.go index 0680743f..8d7ecdfc 100644 --- a/cmd/lk/egress.go +++ b/cmd/lk/egress.go @@ -641,7 +641,7 @@ func listEgress(ctx context.Context, cmd *cli.Command) error { item.Error, ) } - fmt.Println(table) + out.Result(table) } return nil @@ -691,9 +691,9 @@ func stopEgress(ctx context.Context, cmd *cli.Command) error { }) if err != nil { errors = append(errors, err) - fmt.Println("Error stopping Egress", id, err) + out.Warnf("Error stopping Egress %s: %v", id, err) } else { - fmt.Println("Stopping Egress", id) + out.Statusf("Stopping Egress %s", id) } } if len(errors) != 0 { @@ -767,7 +767,7 @@ func testEgressTemplate(ctx context.Context, cmd *cli.Command) error { Testers: testers, }) sim.Start() - fmt.Println("simulating speakers...") + out.Status("simulating speakers...") <-done @@ -780,8 +780,8 @@ func testEgressTemplate(ctx context.Context, cmd *cli.Command) error { func printInfo(info *livekit.EgressInfo) { if info.Error == "" { - fmt.Printf("EgressID: %v Status: %v\n", info.EgressId, info.Status) + out.Resultf("EgressID: %v Status: %v\n", info.EgressId, info.Status) } else { - fmt.Printf("EgressID: %v Error: %v\n", info.EgressId, info.Error) + out.Resultf("EgressID: %v Error: %v\n", info.EgressId, info.Error) } } diff --git a/cmd/lk/ingress.go b/cmd/lk/ingress.go index 61823a52..9e84472e 100644 --- a/cmd/lk/ingress.go +++ b/cmd/lk/ingress.go @@ -16,7 +16,6 @@ package main import ( "context" - "fmt" "github.com/urfave/cli/v3" @@ -247,7 +246,7 @@ func listIngress(ctx context.Context, cmd *cli.Command) error { errorStr, ) } - fmt.Println(table) + out.Result(table) } return nil @@ -278,9 +277,9 @@ func printIngressInfo(info *livekit.IngressInfo) { } if errorStr == "" { - fmt.Printf("IngressID: %v Status: %v\n", info.IngressId, status) - fmt.Printf("URL: %v Stream Key: %s\n", info.Url, info.StreamKey) + out.Resultf("IngressID: %v Status: %v\n", info.IngressId, status) + out.Resultf("URL: %v Stream Key: %s\n", info.Url, info.StreamKey) } else { - fmt.Printf("IngressID: %v Error: %v\n", info.IngressId, errorStr) + out.Resultf("IngressID: %v Error: %v\n", info.IngressId, errorStr) } } diff --git a/cmd/lk/join.go b/cmd/lk/join.go index a00c46f8..a62db304 100644 --- a/cmd/lk/join.go +++ b/cmd/lk/join.go @@ -184,7 +184,7 @@ func _deprecatedJoinRoom(ctx context.Context, cmd *cli.Command) error { return } if pub != nil { - fmt.Printf("finished writing %s\n", pub.Name()) + out.Statusf("finished writing %s", pub.Name()) _ = room.LocalParticipant.UnpublishTrack(pub.SID()) } } @@ -618,6 +618,6 @@ func handleSimulcastPublish(room *lksdk.Room, urls []string, fps float64, h26xSt return fmt.Errorf("failed to publish simulcast track: %w", err) } - fmt.Printf("Successfully published %s simulcast track with qualities: %v\n", strings.ToUpper(codec), trackNames) + out.Statusf("Successfully published %s simulcast track with qualities: %v", strings.ToUpper(codec), trackNames) return nil } diff --git a/cmd/lk/main.go b/cmd/lk/main.go index 5c491e6e..8a43cd2c 100644 --- a/cmd/lk/main.go +++ b/cmd/lk/main.go @@ -29,6 +29,7 @@ import ( lksdk "github.com/livekit/server-sdk-go/v2" livekitcli "github.com/livekit/livekit-cli/v2" + "github.com/livekit/livekit-cli/v2/pkg/util" ) func main() { @@ -99,6 +100,9 @@ func main() { func checkForLegacyName() { if !strings.HasSuffix(os.Args[0], "lk") && !strings.HasSuffix(os.Args[0], "lk.exe") { + // Stays on raw os.Stderr: this runs before the cli command parses (so the + // Printer isn't initialized yet) and is a deprecation warning that should + // not be suppressed by --quiet. fmt.Fprintf( os.Stderr, "\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ DEPRECATION NOTICE ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n"+ @@ -123,6 +127,10 @@ func initLogger(ctx context.Context, cmd *cli.Command) (context.Context, error) logger.InitFromConfig(logConfig, "lk") lksdk.SetLogger(logger.GetLogger()) + // Bind the human-facing output sink to the root command's writers (cli/v3 + // defaults them to os.Stdout / os.Stderr, but they're overridable in tests). + out = util.NewPrinter(cmd.Root().Writer, cmd.Root().ErrWriter, cmd.Bool("quiet")) + return nil, nil } @@ -138,7 +146,7 @@ func generateFishCompletion(ctx context.Context, cmd *cli.Command) error { return err } } else { - fmt.Println(fishScript) + out.Result(fishScript) } return nil diff --git a/cmd/lk/phone_number.go b/cmd/lk/phone_number.go index 124421e5..d4be2b0a 100644 --- a/cmd/lk/phone_number.go +++ b/cmd/lk/phone_number.go @@ -21,6 +21,7 @@ import ( "github.com/livekit/livekit-cli/v2/pkg/util" "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" lksdk "github.com/livekit/server-sdk-go/v2" "github.com/urfave/cli/v3" ) @@ -158,11 +159,7 @@ func createPhoneNumberClient(ctx context.Context, cmd *cli.Command) (*lksdk.Phon return nil, err } - // Debug: Print the URL being used - if cmd.Bool("verbose") { - fmt.Printf("Using phone number service URL: %s\n", project.URL) - } - + logger.Debugw("phone number client", "service-url", project.URL) return lksdk.NewPhoneNumberClient(project.URL, project.APIKey, project.APISecret, withDefaultClientOpts(project)...), nil } @@ -250,13 +247,13 @@ func purchasePhoneNumbers(ctx context.Context, cmd *cli.Command) error { return nil } - fmt.Printf("Successfully purchased %d phone numbers:\n", len(resp.PhoneNumbers)) + out.Resultf("Successfully purchased %d phone numbers:\n", len(resp.PhoneNumbers)) for _, phoneNumber := range resp.PhoneNumbers { ruleInfo := "" if len(phoneNumber.SipDispatchRuleIds) > 0 { ruleInfo = fmt.Sprintf(" (SIP Dispatch Rules: %s)", strings.Join(phoneNumber.SipDispatchRuleIds, ", ")) } - fmt.Printf(" %s (%s) - %s%s\n", phoneNumber.E164Format, phoneNumber.Id, strings.TrimPrefix(phoneNumber.Status.String(), "PHONE_NUMBER_STATUS_"), ruleInfo) + out.Resultf(" %s (%s) - %s%s\n", phoneNumber.E164Format, phoneNumber.Id, strings.TrimPrefix(phoneNumber.Status.String(), "PHONE_NUMBER_STATUS_"), ruleInfo) } return nil @@ -305,20 +302,20 @@ func listPhoneNumbers(ctx context.Context, cmd *cli.Command) error { return nil } - fmt.Printf("Total phone numbers: %d", resp.TotalCount) + out.Resultf("Total phone numbers: %d", resp.TotalCount) if resp.OfflineCount > 0 { - fmt.Printf(" (%d offline)", resp.OfflineCount) + out.Resultf(" (%d offline)", resp.OfflineCount) } - fmt.Printf("\n") + out.Resultf("\n") // Show pagination info if offset > 0 { - fmt.Printf("Showing results from offset %d\n", offset) + out.Resultf("Showing results from offset %d\n", offset) } if resp.NextPageToken != nil { nextOffset, _, err := livekit.DecodeTokenPagination(resp.NextPageToken) if err == nil { - fmt.Printf("More results available. Use --offset %d to see the next page.\n", nextOffset) + out.Resultf("More results available. Use --offset %d to see the next page.\n", nextOffset) } } @@ -389,19 +386,19 @@ func getPhoneNumber(ctx context.Context, cmd *cli.Command) error { dispatchRulesStr = "-" } - fmt.Printf("Phone Number Details:\n") - fmt.Printf(" ID: %s\n", item.Id) - fmt.Printf(" E164 Format: %s\n", item.E164Format) - fmt.Printf(" Country: %s\n", item.CountryCode) - fmt.Printf(" Area Code: %s\n", item.AreaCode) - fmt.Printf(" Type: %s\n", strings.TrimPrefix(item.NumberType.String(), "PHONE_NUMBER_TYPE_")) - fmt.Printf(" Locality: %s\n", item.Locality) - fmt.Printf(" Region: %s\n", item.Region) - fmt.Printf(" Capabilities: %s\n", strings.Join(item.Capabilities, ",")) - fmt.Printf(" Status: %s\n", strings.TrimPrefix(item.Status.String(), "PHONE_NUMBER_STATUS_")) - fmt.Printf(" SIP Dispatch Rules: %s\n", dispatchRulesStr) + out.Resultf("Phone Number Details:\n") + out.Resultf(" ID: %s\n", item.Id) + out.Resultf(" E164 Format: %s\n", item.E164Format) + out.Resultf(" Country: %s\n", item.CountryCode) + out.Resultf(" Area Code: %s\n", item.AreaCode) + out.Resultf(" Type: %s\n", strings.TrimPrefix(item.NumberType.String(), "PHONE_NUMBER_TYPE_")) + out.Resultf(" Locality: %s\n", item.Locality) + out.Resultf(" Region: %s\n", item.Region) + out.Resultf(" Capabilities: %s\n", strings.Join(item.Capabilities, ",")) + out.Resultf(" Status: %s\n", strings.TrimPrefix(item.Status.String(), "PHONE_NUMBER_STATUS_")) + out.Resultf(" SIP Dispatch Rules: %s\n", dispatchRulesStr) if item.ReleasedAt != nil { - fmt.Printf(" Released At: %s\n", item.ReleasedAt.AsTime().Format("2006-01-02 15:04:05")) + out.Resultf(" Released At: %s\n", item.ReleasedAt.AsTime().Format("2006-01-02 15:04:05")) } return nil @@ -453,11 +450,11 @@ func updatePhoneNumber(ctx context.Context, cmd *cli.Command) error { dispatchRulesStr = "-" } - fmt.Printf("Successfully updated phone number:\n") - fmt.Printf(" ID: %s\n", item.Id) - fmt.Printf(" E164 Format: %s\n", item.E164Format) - fmt.Printf(" Status: %s\n", strings.TrimPrefix(item.Status.String(), "PHONE_NUMBER_STATUS_")) - fmt.Printf(" SIP Dispatch Rules: %s\n", dispatchRulesStr) + out.Resultf("Successfully updated phone number:\n") + out.Resultf(" ID: %s\n", item.Id) + out.Resultf(" E164 Format: %s\n", item.E164Format) + out.Resultf(" Status: %s\n", strings.TrimPrefix(item.Status.String(), "PHONE_NUMBER_STATUS_")) + out.Resultf(" SIP Dispatch Rules: %s\n", dispatchRulesStr) return nil } @@ -491,9 +488,9 @@ func releasePhoneNumbers(ctx context.Context, cmd *cli.Command) error { } if len(ids) > 0 { - fmt.Printf("Successfully released %d phone numbers by ID: %s\n", len(ids), strings.Join(ids, ", ")) + out.Resultf("Successfully released %d phone numbers by ID: %s\n", len(ids), strings.Join(ids, ", ")) } else { - fmt.Printf("Successfully released %d phone numbers: %s\n", len(phoneNumbers), strings.Join(phoneNumbers, ", ")) + out.Resultf("Successfully released %d phone numbers: %s\n", len(phoneNumbers), strings.Join(phoneNumbers, ", ")) } return nil diff --git a/cmd/lk/project.go b/cmd/lk/project.go index cfaeadd8..85039005 100644 --- a/cmd/lk/project.go +++ b/cmd/lk/project.go @@ -17,7 +17,6 @@ package main import ( "context" "errors" - "fmt" "net/url" "regexp" @@ -162,7 +161,7 @@ func addProject(ctx context.Context, cmd *cli.Command) error { if err = validateName(p.Name); err != nil { return err } - fmt.Println(" Project Name:", p.Name) + out.Statusf(" Project Name: %s", p.Name) } else { prompts = append(prompts, huh.NewInput(). Title("Project Name"). @@ -183,7 +182,7 @@ func addProject(ctx context.Context, cmd *cli.Command) error { if err = validateURL(p.URL); err != nil { return err } - fmt.Println(" URL:", p.URL) + out.Statusf(" URL: %s", p.URL) } else { prompts = append(prompts, huh.NewInput(). Title("Project URL"). @@ -203,7 +202,7 @@ func addProject(ctx context.Context, cmd *cli.Command) error { if err = validateKey(p.APIKey); err != nil { return err } - fmt.Println(" API Key:", p.APIKey) + out.Statusf(" API Key: %s", p.APIKey) } else { prompts = append(prompts, huh.NewInput(). Title("API Key"). @@ -217,7 +216,7 @@ func addProject(ctx context.Context, cmd *cli.Command) error { if err = validateKey(p.APISecret); err != nil { return err } - fmt.Println(" API Secret:", p.APISecret) + out.Statusf(" API Secret: %s", p.APISecret) } else { prompts = append(prompts, huh.NewInput(). Title("API Secret"). @@ -231,10 +230,9 @@ func addProject(ctx context.Context, cmd *cli.Command) error { if cmd.Bool("default") || defaultProject == nil { cliConfig.DefaultProject = p.Name } else if !cmd.IsSet("default") { - prompts = append(prompts, huh.NewConfirm(). + prompts = append(prompts, util.Confirm(). Title("Make this project default?"). Value(&isDefault). - Inline(true). WithTheme(util.Theme)) } @@ -268,7 +266,7 @@ func addProject(ctx context.Context, cmd *cli.Command) error { func listProjects(ctx context.Context, cmd *cli.Command) error { if len(cliConfig.Projects) == 0 { - fmt.Println("No projects configured, use `lk cloud auth` to authenticate a new project.") + out.Status("No projects configured, use `lk cloud auth` to authenticate a new project.") return nil } @@ -300,7 +298,7 @@ func listProjects(ctx context.Context, cmd *cli.Command) error { } table.Row(pName, p.ProjectId, p.URL, p.APIKey) } - fmt.Println(table) + out.Result(table) } return nil @@ -331,7 +329,7 @@ func setDefaultProject(ctx context.Context, cmd *cli.Command) error { if err := cliConfig.PersistIfNeeded(); err != nil { return err } - fmt.Println("Default project set to [" + util.Theme.Focused.Title.Render(p.Name) + "]") + out.Statusf("Default project set to [%s]", util.Accented(p.Name)) return nil } diff --git a/cmd/lk/proto.go b/cmd/lk/proto.go index adda4ea7..8e3f9705 100644 --- a/cmd/lk/proto.go +++ b/cmd/lk/proto.go @@ -243,7 +243,7 @@ func listAndPrint[ } table.Row(row...) } - fmt.Println(table) + out.Result(table) } return nil diff --git a/cmd/lk/replay.go b/cmd/lk/replay.go index 917f10a7..aa314838 100644 --- a/cmd/lk/replay.go +++ b/cmd/lk/replay.go @@ -181,7 +181,7 @@ func listReplays(ctx context.Context, cmd *cli.Command) error { for _, info := range res.Replays { table.Row(info.ReplayId, info.RoomName, fmt.Sprint(time.Unix(0, info.StartTime))) } - fmt.Println(table) + out.Result(table) } return nil @@ -210,7 +210,7 @@ func playback(ctx context.Context, cmd *cli.Command) error { if err != nil { return err } - fmt.Println("PlaybackID:", res.PlaybackId) + out.Resultf("PlaybackID: %s\n", res.PlaybackId) return nil } diff --git a/cmd/lk/room.go b/cmd/lk/room.go index 529cdc47..1b79913d 100644 --- a/cmd/lk/room.go +++ b/cmd/lk/room.go @@ -22,7 +22,6 @@ import ( "os" "os/signal" "regexp" - "strings" "syscall" "github.com/pion/webrtc/v4" @@ -677,32 +676,32 @@ func createRoom(ctx context.Context, cmd *cli.Command) error { } if cmd.Uint("min-playout-delay") != 0 { - fmt.Printf("setting min playout delay: %d\n", cmd.Uint("min-playout-delay")) + out.Statusf("setting min playout delay: %d", cmd.Uint("min-playout-delay")) req.MinPlayoutDelay = uint32(cmd.Uint("min-playout-delay")) } if maxPlayoutDelay := cmd.Uint("max-playout-delay"); maxPlayoutDelay != 0 { - fmt.Printf("setting max playout delay: %d\n", maxPlayoutDelay) + out.Statusf("setting max playout delay: %d", maxPlayoutDelay) req.MaxPlayoutDelay = uint32(maxPlayoutDelay) } if syncStreams := cmd.Bool("sync-streams"); syncStreams { - fmt.Printf("setting sync streams: %t\n", syncStreams) + out.Statusf("setting sync streams: %t", syncStreams) req.SyncStreams = syncStreams } if emptyTimeout := cmd.Uint("empty-timeout"); emptyTimeout != 0 { - fmt.Printf("setting empty timeout: %d\n", emptyTimeout) + out.Statusf("setting empty timeout: %d", emptyTimeout) req.EmptyTimeout = uint32(emptyTimeout) } if departureTimeout := cmd.Uint("departure-timeout"); departureTimeout != 0 { - fmt.Printf("setting departure timeout: %d\n", departureTimeout) + out.Statusf("setting departure timeout: %d", departureTimeout) req.DepartureTimeout = uint32(departureTimeout) } if replayEnabled := cmd.Bool("replay-enabled"); replayEnabled { - fmt.Printf("setting replay enabled: %t\n", replayEnabled) + out.Statusf("setting replay enabled: %t", replayEnabled) req.ReplayEnabled = replayEnabled } @@ -717,11 +716,8 @@ func createRoom(ctx context.Context, cmd *cli.Command) error { func listRooms(ctx context.Context, cmd *cli.Command) error { names, _ := extractArgs(cmd) - if cmd.Bool("verbose") && len(names) > 0 { - fmt.Printf( - "Querying rooms matching %s", - strings.Join(util.MapStrings(names, util.WrapWith("\"")), ", "), - ) + if len(names) > 0 { + logger.Debugw("querying rooms", "names", names) } req := livekit.ListRoomsRequest{} @@ -746,7 +742,7 @@ func listRooms(ctx context.Context, cmd *cli.Command) error { fmt.Sprintf("%d", rm.NumPublishers), ) } - fmt.Println(table) + out.Result(table) } return nil @@ -760,7 +756,7 @@ func _deprecatedListRoom(ctx context.Context, cmd *cli.Command) error { return err } if len(res.Rooms) == 0 { - fmt.Printf("there is no matching room with name: %s\n", cmd.String("room")) + out.Statusf("there is no matching room with name: %s", cmd.String("room")) return nil } rm := res.Rooms[0] @@ -781,7 +777,7 @@ func deleteRoom(ctx context.Context, cmd *cli.Command) error { return err } - fmt.Println("deleted room", roomId) + out.Statusf("deleted room %s", roomId) return nil } @@ -795,7 +791,7 @@ func updateRoomMetadata(ctx context.Context, cmd *cli.Command) error { return err } - fmt.Println("Updated room metadata") + out.Status("Updated room metadata") util.PrintJSON(res) return nil } @@ -810,7 +806,7 @@ func _deprecatedUpdateRoomMetadata(ctx context.Context, cmd *cli.Command) error return err } - fmt.Println("Updated room metadata") + out.Status("Updated room metadata") util.PrintJSON(res) return nil } @@ -864,7 +860,7 @@ func joinRoom(ctx context.Context, cmd *cli.Command) error { participantIdentity := cmd.String("identity") if participantIdentity == "" { participantIdentity = util.ExpandTemplate("participant-%x") - fmt.Printf("Using generated participant identity [%s]\n", util.Accented(participantIdentity)) + out.Statusf("Using generated participant identity [%s]", util.Accented(participantIdentity)) } autoSubscribe := cmd.Bool("auto-subscribe") @@ -1015,7 +1011,7 @@ func joinRoom(ctx context.Context, cmd *cli.Command) error { return } if pub != nil { - fmt.Printf("finished simulcast stream %s\n", pub.Name()) + out.Statusf("finished simulcast stream %s", pub.Name()) _ = room.LocalParticipant.UnpublishTrack(pub.SID()) } } @@ -1034,7 +1030,7 @@ func joinRoom(ctx context.Context, cmd *cli.Command) error { return } if pub != nil { - fmt.Printf("finished writing %s\n", pub.Name()) + out.Statusf("finished writing %s", pub.Name()) _ = room.LocalParticipant.UnpublishTrack(pub.SID()) } } @@ -1104,7 +1100,7 @@ func listParticipants(ctx context.Context, cmd *cli.Command) error { } for _, p := range res.Participants { - fmt.Printf("%s (%s)\t tracks: %d\n", p.Identity, p.State.String(), len(p.Tracks)) + out.Resultf("%s (%s)\t tracks: %d\n", p.Identity, p.State.String(), len(p.Tracks)) } return nil } @@ -1119,7 +1115,7 @@ func _deprecatedListParticipants(ctx context.Context, cmd *cli.Command) error { } for _, p := range res.Participants { - fmt.Printf("%s (%s)\t tracks: %d\n", p.Identity, p.State.String(), len(p.Tracks)) + out.Resultf("%s (%s)\t tracks: %d\n", p.Identity, p.State.String(), len(p.Tracks)) } return nil } @@ -1171,12 +1167,12 @@ func updateParticipant(ctx context.Context, cmd *cli.Command) error { } } - fmt.Println("updating participant...") + out.Status("updating participant...") util.PrintJSON(req) if _, err := roomClient.UpdateParticipant(ctx, req); err != nil { return err } - fmt.Println("participant updated.") + out.Status("participant updated.") return nil } @@ -1192,7 +1188,7 @@ func removeParticipant(ctx context.Context, cmd *cli.Command) error { return err } - fmt.Println("successfully removed participant", identity) + out.Statusf("successfully removed participant %s", identity) return nil } @@ -1213,7 +1209,7 @@ func forwardParticipant(ctx context.Context, cmd *cli.Command) error { return err } - fmt.Println("successfully forwarded participant", identity, "from", roomName, "to", destinationRoomName) + out.Statusf("successfully forwarded participant %s from %s to %s", identity, roomName, destinationRoomName) return nil } @@ -1234,7 +1230,7 @@ func moveParticipant(ctx context.Context, cmd *cli.Command) error { return err } - fmt.Println("successfully moved participant", identity, "from", roomName, "to", destinationRoomName) + out.Statusf("successfully moved participant %s from %s to %s", identity, roomName, destinationRoomName) return nil } @@ -1259,7 +1255,7 @@ func muteTrack(ctx context.Context, cmd *cli.Command) error { if !muted { verb = "Unmuted" } - fmt.Printf("%s track [%s]\n", verb, trackSid) + out.Statusf("%s track [%s]", verb, trackSid) return nil } @@ -1284,7 +1280,7 @@ func updateSubscriptions(ctx context.Context, cmd *cli.Command) error { if !subscribe { verb = "Unsubscribed from" } - fmt.Printf("%s tracks %v\n", verb, trackSids) + out.Statusf("%s tracks %v", verb, trackSids) return nil } @@ -1311,7 +1307,7 @@ func sendData(ctx context.Context, cmd *cli.Command) error { return err } - fmt.Println("successfully sent data to room", roomName) + out.Statusf("successfully sent data to room %s", roomName) return nil } diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index d33df528..a2f614a9 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -293,8 +293,8 @@ func uploadSource(ctx context.Context, client *lksdk.AgentSimulationClient, runI return fmt.Errorf("failed to upload source: %w", err) } if _, err := client.ConfirmSimulationSourceUpload(ctx, &livekit.SimulationRun_ConfirmSourceUpload_Request{ - SimulationRunId: runID, - CodeEntrypoint: entrypoint, + SimulationRunId: runID, + CodeEntrypoint: entrypoint, }); err != nil { return fmt.Errorf("failed to confirm upload: %w", err) } @@ -330,9 +330,9 @@ func cancelSimulationRun(client *lksdk.AgentSimulationClient, runID string) { if _, err := client.CancelSimulationRun(ctx, &livekit.SimulationRun_Cancel_Request{ SimulationRunId: runID, }); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to cancel run: %v\n", err) + out.Warnf("Warning: failed to cancel run: %v", err) } else { - fmt.Fprintf(os.Stderr, "Run cancelled\n") + out.Status("Run cancelled") } } diff --git a/cmd/lk/sip.go b/cmd/lk/sip.go index b6ef87d0..994caa56 100644 --- a/cmd/lk/sip.go +++ b/cmd/lk/sip.go @@ -23,6 +23,7 @@ import ( "strings" "time" + "github.com/livekit/livekit-cli/v2/pkg/util" "github.com/livekit/protocol/livekit" lksdk "github.com/livekit/server-sdk-go/v2" "github.com/urfave/cli/v3" @@ -853,7 +854,7 @@ func deleteSIPTrunk(ctx context.Context, cmd *cli.Command) error { if err != nil { return err } - printSIPTrunkID(info) + out.Resultf("SIP Trunk [%s] deleted\n", util.Accented(info.GetSipTrunkId())) return nil }) } @@ -869,20 +870,16 @@ func deleteSIPTrunkLegacy(ctx context.Context, cmd *cli.Command) error { if err != nil { return err } - printSIPTrunkID(info) + out.Resultf("SIP Trunk [%s] deleted\n", util.Accented(info.GetSipTrunkId())) return nil } -func printSIPTrunkID(info *livekit.SIPTrunkInfo) { - fmt.Printf("SIPTrunkID: %v\n", info.GetSipTrunkId()) -} - func printSIPInboundTrunkID(info *livekit.SIPInboundTrunkInfo) { - fmt.Printf("SIPTrunkID: %v\n", info.GetSipTrunkId()) + out.Resultf("SIPTrunkID: %v\n", info.GetSipTrunkId()) } func printSIPOutboundTrunkID(info *livekit.SIPOutboundTrunkInfo) { - fmt.Printf("SIPTrunkID: %v\n", info.GetSipTrunkId()) + out.Resultf("SIPTrunkID: %v\n", info.GetSipTrunkId()) } func createSIPDispatchRule(ctx context.Context, cmd *cli.Command) error { @@ -1120,7 +1117,7 @@ func deleteSIPDispatchRuleLegacy(ctx context.Context, cmd *cli.Command) error { } func printSIPDispatchRuleID(info *livekit.SIPDispatchRuleInfo) { - fmt.Printf("SIPDispatchRuleID: %v\n", info.SipDispatchRuleId) + out.Resultf("SIPDispatchRuleID: %v\n", info.SipDispatchRuleId) } func createSIPParticipant(ctx context.Context, cmd *cli.Command) error { @@ -1204,8 +1201,8 @@ func createSIPParticipant(ctx context.Context, cmd *cli.Command) error { if msg == "" { msg = e.Code.ShortName() } - fmt.Printf("SIPStatusCode: %d\n", e.Code) - fmt.Printf("SIPStatus: %s\n", msg) + out.Resultf("SIPStatusCode: %d\n", e.Code) + out.Resultf("SIPStatus: %s\n", msg) } return resp, err }, printSIPParticipantInfo) @@ -1252,8 +1249,8 @@ func transferSIPParticipant(ctx context.Context, cmd *cli.Command) error { } func printSIPParticipantInfo(info *livekit.SIPParticipantInfo) { - fmt.Printf("SIPCallID: %v\n", info.SipCallId) - fmt.Printf("ParticipantID: %v\n", info.ParticipantId) - fmt.Printf("ParticipantIdentity: %v\n", info.ParticipantIdentity) - fmt.Printf("RoomName: %v\n", info.RoomName) + out.Resultf("SIPCallID: %v\n", info.SipCallId) + out.Resultf("ParticipantID: %v\n", info.ParticipantId) + out.Resultf("ParticipantIdentity: %v\n", info.ParticipantIdentity) + out.Resultf("RoomName: %v\n", info.RoomName) } diff --git a/cmd/lk/utils.go b/cmd/lk/utils.go index 85166eb6..b8e2e4d3 100644 --- a/cmd/lk/utils.go +++ b/cmd/lk/utils.go @@ -15,20 +15,18 @@ package main import ( - "context" "errors" "fmt" - "io" "maps" "os" "strings" - "github.com/charmbracelet/huh" "github.com/joho/godotenv" "github.com/mattn/go-isatty" "github.com/twitchtv/twirp" "github.com/urfave/cli/v3" + "github.com/livekit/protocol/logger" "github.com/livekit/protocol/utils/interceptors" "github.com/livekit/server-sdk-go/v2/signalling" @@ -93,6 +91,12 @@ var ( Usage: "Run installation after creating the application", } + // out is the process-wide sink for human-facing CLI output. It is initialized + // in main.go's root Before hook from the parsed root command, so all status, + // warning, and result lines share consistent streams (stderr/stdout) and + // --quiet gating. Before init it is nil and all methods no-op safely. + out *util.Printer + openFlag = util.OpenFlag globalFlags = []cli.Flag{ &cli.StringFlag{ @@ -145,6 +149,11 @@ var ( Aliases: []string{"y"}, Usage: "Assume yes for confirmations; fail or use default for other prompts (use in CI/non-interactive)", }, + &cli.BoolFlag{ + Name: "quiet", + Aliases: []string{"q"}, + Usage: "Suppress informational output to stderr (warnings and errors still print)", + }, &cli.StringFlag{ Name: "server-url", Value: cloudAPIServerURL, @@ -239,7 +248,6 @@ func parseKeyValuePairs(c *cli.Command, flag string) (map[string]string, error) type loadParams struct { requireURL bool confirmProject bool - output io.Writer } type loadOption func(*loadParams) @@ -253,28 +261,70 @@ var ( } ) -// attempt to load connection config, it'll prioritize -// 1. command line flags (or env var) -// 2. config file (by default, livekit.toml) -// 3. default project config -func loadProjectDetails(c *cli.Command, opts ...loadOption) (*config.ProjectConfig, error) { - p := loadParams{requireURL: true, confirmProject: false, output: os.Stdout} - for _, opt := range opts { - opt(&p) - } - w := p.output - logDetails := func(c *cli.Command, pc *config.ProjectConfig) { - if c.Bool("verbose") { - fmt.Fprintf(w, "URL: %s, api-key: %s, api-secret: %s\n", - pc.URL, - pc.APIKey, - "************", - ) - } +// projectSource records how a project's credentials were resolved. It feeds two things: +// the confirm-default prompt (only triggers for sourceDefault) and the notice string +// composed in resolveProject and emitted by callers via out.Status. +type projectSource int +const ( + sourceFlag projectSource = iota // --project NAME + sourceSubdomain // --subdomain SUBDOMAIN + sourceEnv // credentials from LIVEKIT_* environment variables + sourceInlineFlags // credentials from --url/--api-key/--api-secret (name-less; silent) + sourceDev // --dev + sourceTOML // livekit.toml in the working directory + sourceDefault // configured default project + sourceSelected // interactively picked, or added via `lk cloud auth` +) + +// resolvedProject is the outcome of project resolution: the chosen credentials, how they +// were resolved, and (for sourceEnv) which env vars came through. Call rp.announce() to +// surface the outcome to the user — that is the single hand-off from resolution to output. +type resolvedProject struct { + project *config.ProjectConfig + source projectSource + envVars []string // which LIVEKIT_* vars were used, for sourceEnv +} + +// announce surfaces how the project was resolved: a one-line breadcrumb through the +// package-level Printer (stderr, suppressed by --quiet), plus a structured debug log via +// protocol/logger (gated to --verbose). Centralizing the source→message mapping here keeps +// the resolver pure and gives us one place to evolve wording or wire color/decoration. +func (rp *resolvedProject) announce() { + if rp == nil { + return + } + switch rp.source { + case sourceEnv: + out.Statusf("Using %s from environment", strings.Join(rp.envVars, ", ")) + case sourceDev: + out.Status("Using dev credentials") + case sourceInlineFlags: + // name-less credentials supplied directly via flags; nothing to surface + default: // sourceFlag, sourceSubdomain, sourceTOML, sourceDefault, sourceSelected + out.Statusf("Using project [%s]", util.Accented(rp.project.Name)) + } + if rp.project != nil && rp.source != sourceDev { + logger.Debugw("project resolved", + "source", rp.source, + "url", rp.project.URL, + "api-key", rp.project.APIKey, + ) } +} - // if explicit project is defined, then use it +// resolveProject determines which project's credentials to use, in priority order: +// 1. --project flag +// 2. --subdomain flag +// 3. --url/--api-key/--api-secret flags or LIVEKIT_* env vars +// 4. --dev credentials +// 5. livekit.toml in the working directory +// 6. the configured default project +// +// It performs no prompting and no printing: callers print rp.notice via out.Status, and +// handle the no-project case (a returned error) by offering interactive selection. +func resolveProject(c *cli.Command, p loadParams) (*resolvedProject, error) { + // 1. explicit project if c.String("project") != "" { if c.Bool("dev") { return nil, errors.New("both project and dev flags are set") @@ -283,12 +333,10 @@ func loadProjectDetails(c *cli.Command, opts ...loadOption) (*config.ProjectConf if err != nil { return nil, err } - fmt.Fprintln(w, "Using project ["+util.Accented(c.String("project"))+"]") - logDetails(c, pc) - return pc, nil + return &resolvedProject{project: pc, source: sourceFlag}, nil } - // if explicit subdomain is provided, use it + // 2. explicit subdomain if c.String("subdomain") != "" { if c.Bool("dev") { return nil, errors.New("both subdomain and dev flags are set") @@ -297,11 +345,10 @@ func loadProjectDetails(c *cli.Command, opts ...loadOption) (*config.ProjectConf if err != nil { return nil, err } - fmt.Fprintln(w, "Using project ["+util.Accented(pc.Name)+"]") - logDetails(c, pc) - return pc, nil + return &resolvedProject{project: pc, source: sourceSubdomain}, nil } + // 3. inline credentials (flags or environment) pc := &config.ProjectConfig{} if val := c.String("url"); val != "" { pc.URL = val @@ -331,57 +378,33 @@ func loadProjectDetails(c *cli.Command, opts ...loadOption) (*config.ProjectConf envVars = append(envVars, "api-secret") } if len(envVars) > 0 { - fmt.Fprintf(w, "Using %s from environment\n", strings.Join(envVars, ", ")) - logDetails(c, pc) + return &resolvedProject{project: pc, source: sourceEnv, envVars: envVars}, nil } - return pc, nil + return &resolvedProject{project: pc, source: sourceInlineFlags}, nil } + + // 4. dev credentials if c.Bool("dev") { pc.APIKey = "devkey" pc.APISecret = "secret" - fmt.Fprintln(w, "Using dev credentials") - return pc, nil + return &resolvedProject{project: pc, source: sourceDev}, nil } - // load from config file - _, err := requireConfig(workingDir, tomlFilename) - if errors.Is(err, config.ErrInvalidConfig) { + // 5. livekit.toml in the working directory + if _, err := requireConfig(workingDir, tomlFilename); errors.Is(err, config.ErrInvalidConfig) { return nil, err } if lkConfig != nil { - return config.LoadProjectBySubdomain(lkConfig.Project.Subdomain) - } - - // load default project - dp, err := config.LoadDefaultProject() - if err == nil { - if p.confirmProject { - if dp != nil && len(cliConfig.Projects) > 1 && !c.Bool("silent") && !SkipPrompts(c) { - useDefault := true - if err := huh.NewForm(huh.NewGroup(huh.NewConfirm(). - Title(fmt.Sprintf("Use project [%s] (%s) to create agent?", dp.Name, dp.URL)). - Value(&useDefault). - Negative("Select another"). - Inline(false). - WithTheme(util.Theme))). - Run(); err != nil { - return nil, fmt.Errorf("failed to confirm project: %w", err) - } - if !useDefault { - if _, err = selectProject(context.Background(), c); err != nil { - return nil, err - } - fmt.Fprintf(w, "Using project [%s]\n", util.Accented(project.Name)) - return project, nil - } - } - } else { - if !c.Bool("silent") && !SkipPrompts(c) { - fmt.Fprintln(w, "Using default project ["+util.Theme.Focused.Title.Render(dp.Name)+"]") - logDetails(c, dp) - } + pc, err := config.LoadProjectBySubdomain(lkConfig.Project.Subdomain) + if err != nil { + return nil, err } - return dp, nil + return &resolvedProject{project: pc, source: sourceTOML}, nil + } + + // 6. configured default project + if dp, err := config.LoadDefaultProject(); err == nil { + return &resolvedProject{project: dp, source: sourceDefault}, nil } if p.requireURL && pc.URL == "" { @@ -395,7 +418,24 @@ func loadProjectDetails(c *cli.Command, opts ...loadOption) (*config.ProjectConf } // cannot happen - return pc, nil + return &resolvedProject{project: pc, source: sourceInlineFlags}, nil +} + +// loadProjectDetails resolves project credentials for commands that consume the result +// directly (egress, ingress, token, …) and announces the resolution. Commands that rely on +// the package-level `project` (app/agent) go through requireProject instead, which layers +// interactive selection on top of the same resolver before announcing. +func loadProjectDetails(c *cli.Command, opts ...loadOption) (*config.ProjectConfig, error) { + p := loadParams{requireURL: true} + for _, opt := range opts { + opt(&p) + } + rp, err := resolveProject(c, p) + if err != nil { + return nil, err + } + rp.announce() + return rp.project, nil } type TemplateStringFlag = cli.FlagBase[string, cli.StringConfig, templateStringValue] diff --git a/cmd/lk/utils_test.go b/cmd/lk/utils_test.go index 776416ab..6b5b638f 100644 --- a/cmd/lk/utils_test.go +++ b/cmd/lk/utils_test.go @@ -15,11 +15,121 @@ package main import ( + "bytes" + "context" + "io" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" + + "github.com/livekit/livekit-cli/v2/pkg/util" ) +// withCapturedAnnounce swaps the package-level Printer for a buffer-backed one for the +// duration of the test, returning the buffer that captures status output. The Printer's +// nil-safety means we don't have to worry about state from other tests. +func withCapturedAnnounce(t *testing.T) *bytes.Buffer { + t.Helper() + prev := out + var buf bytes.Buffer + out = util.NewPrinter(io.Discard, &buf, false) + t.Cleanup(func() { out = prev }) + return &buf +} + +// resolveWith parses the given args against a fresh copy of the credential-related global +// flags and returns the resolveProject outcome. Fresh flags per call avoid state leaking +// between subtests, and isolating to these flags keeps the test independent of the +// on-disk CLI config (the branches exercised here return before any config is read). +func resolveWith(t *testing.T, args ...string) (*resolvedProject, error) { + t.Helper() + var rp *resolvedProject + var rerr error + app := &cli.Command{ + Name: "lk", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "url", Sources: cli.EnvVars("LIVEKIT_URL"), Value: "http://localhost:7880"}, + &cli.StringFlag{Name: "api-key", Sources: cli.EnvVars("LIVEKIT_API_KEY")}, + &cli.StringFlag{Name: "api-secret", Sources: cli.EnvVars("LIVEKIT_API_SECRET")}, + &cli.BoolFlag{Name: "dev"}, + &cli.StringFlag{Name: "project"}, + &cli.StringFlag{Name: "subdomain"}, + }, + Action: func(_ context.Context, cmd *cli.Command) error { + rp, rerr = resolveProject(cmd, loadParams{requireURL: true}) + return nil + }, + } + require.NoError(t, app.Run(context.Background(), append([]string{"lk"}, args...))) + return rp, rerr +} + +func TestResolveProjectSource(t *testing.T) { + // Isolate from any ambient LIVEKIT_* credentials in the dev's environment. + t.Setenv("LIVEKIT_URL", "") + t.Setenv("LIVEKIT_API_KEY", "") + t.Setenv("LIVEKIT_API_SECRET", "") + + t.Run("dev credentials", func(t *testing.T) { + buf := withCapturedAnnounce(t) + rp, err := resolveWith(t, "--dev") + require.NoError(t, err) + require.NotNil(t, rp) + assert.Equal(t, sourceDev, rp.source) + assert.Equal(t, "devkey", rp.project.APIKey) + + rp.announce() + assert.Equal(t, "Using dev credentials\n", buf.String()) + }) + + t.Run("inline flags are name-less and silent", func(t *testing.T) { + buf := withCapturedAnnounce(t) + rp, err := resolveWith(t, "--url", "ws://x", "--api-key", "k", "--api-secret", "s") + require.NoError(t, err) + require.NotNil(t, rp) + assert.Equal(t, sourceInlineFlags, rp.source) + assert.Empty(t, rp.envVars) + + rp.announce() + assert.Empty(t, buf.String(), "name-less sources surface nothing to the user") + }) + + t.Run("env credentials are reported", func(t *testing.T) { + t.Setenv("LIVEKIT_URL", "ws://env-url") + t.Setenv("LIVEKIT_API_KEY", "envkey") + t.Setenv("LIVEKIT_API_SECRET", "envsecret") + buf := withCapturedAnnounce(t) + rp, err := resolveWith(t) + require.NoError(t, err) + require.NotNil(t, rp) + assert.Equal(t, sourceEnv, rp.source) + assert.ElementsMatch(t, []string{"url", "api-key", "api-secret"}, rp.envVars) + + rp.announce() + assert.Equal(t, "Using url, api-key, api-secret from environment\n", buf.String()) + }) + + t.Run("project and dev conflict", func(t *testing.T) { + _, err := resolveWith(t, "--project", "foo", "--dev") + require.Error(t, err) + assert.Contains(t, err.Error(), "both project and dev flags are set") + }) + + t.Run("--quiet suppresses the breadcrumb", func(t *testing.T) { + prev := out + var buf bytes.Buffer + out = util.NewPrinter(io.Discard, &buf, true /* quiet */) + t.Cleanup(func() { out = prev }) + + rp, err := resolveWith(t, "--dev") + require.NoError(t, err) + rp.announce() + assert.Empty(t, buf.String(), "--quiet suppresses status output") + }) +} + func TestOptionalFlag(t *testing.T) { requiredFlag := &cli.StringFlag{ Name: "test", diff --git a/pkg/config/config.go b/pkg/config/config.go index 21b0cebf..42d444fe 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -72,7 +72,6 @@ func LoadProjectBySubdomain(subdomain string) (*ProjectConfig, error) { for _, p := range conf.Projects { projectSubdomain := util.ExtractSubdomain(p.URL) if projectSubdomain == subdomain { - fmt.Printf("Using project [%s]\n", util.Accented(p.Name)) return &p, nil } } diff --git a/pkg/util/printer.go b/pkg/util/printer.go new file mode 100644 index 00000000..dc81eb6f --- /dev/null +++ b/pkg/util/printer.go @@ -0,0 +1,97 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Conventional CLI output: results go to stdout, everything else (status, +// diagnostics, warnings) goes to stderr. The stream split alone keeps redirected +// or piped result data clean; --quiet silences informational status without +// touching warnings or errors. TTY-gated decoration (color, spinners) is handled +// by lipgloss/termenv and the huh spinner respectively. + +package util + +import ( + "fmt" + "io" + "os" +) + +// Printer is a single sink for human-facing CLI output. One instance per process +// is initialized from the root command and reused everywhere, so all status, +// warning, and result lines share consistent streams and gating. +type Printer struct { + Out io.Writer // primary output: data the user might pipe or redirect + Err io.Writer // status, warnings, diagnostics + Quiet bool // suppresses Status (warnings and errors still print) +} + +// NewPrinter builds a Printer targeting the given writers. Pass nil to default +// to os.Stdout / os.Stderr; this is the path tests use with bytes.Buffer. +func NewPrinter(out, err io.Writer, quiet bool) *Printer { + if out == nil { + out = os.Stdout + } + if err == nil { + err = os.Stderr + } + return &Printer{Out: out, Err: err, Quiet: quiet} +} + +// Status writes an informational breadcrumb to stderr ("Using project [X]", +// "Cloning template…"). Suppressed by --quiet. A trailing newline is appended. +func (p *Printer) Status(a ...any) { + if p == nil || p.Quiet { + return + } + fmt.Fprintln(p.Err, a...) +} + +// Statusf is Printf-style Status. +func (p *Printer) Statusf(format string, a ...any) { + if p == nil || p.Quiet { + return + } + fmt.Fprintf(p.Err, ensureNewline(format), a...) +} + +// Warnf writes a warning to stderr. NOT suppressed by --quiet — warnings are +// always worth surfacing. +func (p *Printer) Warnf(format string, a ...any) { + if p == nil { + return + } + fmt.Fprintf(p.Err, ensureNewline(format), a...) +} + +// Result writes the command's primary output to stdout. Always printed. +func (p *Printer) Result(a ...any) { + if p == nil { + return + } + fmt.Fprintln(p.Out, a...) +} + +// Resultf is Printf-style Result. +func (p *Printer) Resultf(format string, a ...any) { + if p == nil { + return + } + fmt.Fprintf(p.Out, format, a...) +} + +func ensureNewline(s string) string { + if len(s) == 0 || s[len(s)-1] != '\n' { + return s + "\n" + } + return s +} diff --git a/pkg/util/printer_test.go b/pkg/util/printer_test.go new file mode 100644 index 00000000..21de46c6 --- /dev/null +++ b/pkg/util/printer_test.go @@ -0,0 +1,64 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package util + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPrinter_StreamsAndQuiet(t *testing.T) { + var out, err bytes.Buffer + p := NewPrinter(&out, &err, false) + + p.Result("data") + p.Resultf("%d items\n", 3) + p.Status("doing thing") + p.Statusf("using %s", "x") + p.Warnf("permissions look off: %o", 0644) + + assert.Equal(t, "data\n3 items\n", out.String(), "Result* writes only to stdout") + assert.Equal(t, + "doing thing\nusing x\npermissions look off: 644\n", + err.String(), + "Status* and Warnf write only to stderr, with trailing newlines", + ) +} + +func TestPrinter_QuietSuppressesOnlyStatus(t *testing.T) { + var out, err bytes.Buffer + p := NewPrinter(&out, &err, true) + + p.Status("breadcrumb") + p.Statusf("formatted %s", "breadcrumb") + p.Warnf("warn %d", 1) + p.Resultf("result\n") + + assert.Empty(t, "", err.String()[:0], "sanity") + assert.NotContains(t, err.String(), "breadcrumb", "--quiet suppresses Status") + assert.Contains(t, err.String(), "warn 1", "--quiet does NOT suppress warnings") + assert.Equal(t, "result\n", out.String(), "results unaffected by --quiet") +} + +func TestPrinter_NilSafe(t *testing.T) { + var p *Printer // calling on a nil receiver should be a no-op + p.Status("x") + p.Statusf("y %d", 1) + p.Warnf("z") + p.Result("a") + p.Resultf("b\n") +} diff --git a/pkg/util/theme.go b/pkg/util/theme.go index 247168d2..d9988f7a 100644 --- a/pkg/util/theme.go +++ b/pkg/util/theme.go @@ -37,4 +37,14 @@ var ( Fg = lipgloss.AdaptiveColor{Light: "235", Dark: "252"} FormBaseStyle = Theme.Form.Base.Foreground(Fg).Padding(0, 1) FormHeaderStyle = FormBaseStyle.Bold(true) + + // Form helpers + Confirm = func() *huh.Select[bool] { + return huh.NewSelect[bool](). + Options( + huh.NewOption("Yes", true), + huh.NewOption("No", false), + ). + Inline(false) + } )