Skip to content

Commit 1d55c14

Browse files
committed
feat: add debug subcommand for diagnostics
- debug status: show Datadog account discovery status with completion check - debug prefs: show current preferences using preferences service - debug graphql: run free-form GraphQL queries (with account header) - debug paths: show file paths used by Tero Also adds RawQuery method to client for ad-hoc GraphQL queries.
1 parent c3290d8 commit 1d55c14

3 files changed

Lines changed: 343 additions & 0 deletions

File tree

‎internal/cmd/debug.go‎

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
9+
"github.com/spf13/cobra"
10+
"github.com/usetero/cli/internal/api"
11+
"github.com/usetero/cli/internal/auth"
12+
"github.com/usetero/cli/internal/config"
13+
"github.com/usetero/cli/internal/keyring"
14+
"github.com/usetero/cli/internal/log"
15+
"github.com/usetero/cli/internal/powersync"
16+
"github.com/usetero/cli/internal/preferences"
17+
"github.com/usetero/cli/internal/styles"
18+
"github.com/usetero/cli/internal/workos"
19+
"github.com/usetero/cli/pkg/client"
20+
)
21+
22+
func NewDebugCmd(logger log.Logger, cliConfig *config.CLIConfig) *cobra.Command {
23+
debugCmd := &cobra.Command{
24+
Use: "debug",
25+
Short: "Debug and diagnostic commands",
26+
Long: "Commands for debugging and diagnosing issues with Tero.",
27+
}
28+
29+
debugCmd.AddCommand(newDebugStatusCmd(logger, cliConfig))
30+
debugCmd.AddCommand(newDebugPrefsCmd(logger, cliConfig))
31+
debugCmd.AddCommand(newDebugGraphQLCmd(logger, cliConfig))
32+
debugCmd.AddCommand(newDebugPathsCmd(logger, cliConfig))
33+
34+
return debugCmd
35+
}
36+
37+
// newDebugStatusCmd shows the current datadog account status
38+
func newDebugStatusCmd(logger log.Logger, cliConfig *config.CLIConfig) *cobra.Command {
39+
return &cobra.Command{
40+
Use: "status",
41+
Short: "Show Datadog account discovery status",
42+
Long: "Show the current discovery status for the Datadog account, including service counts and progress.",
43+
RunE: func(cmd *cobra.Command, args []string) error {
44+
theme := styles.NewTheme(true)
45+
s := theme.Styles
46+
namespace := cliConfig.Namespace()
47+
48+
// Load preferences to get account ID
49+
cfg, err := config.Load(namespace)
50+
if err != nil {
51+
return fmt.Errorf("failed to load config: %w", err)
52+
}
53+
prefs := preferences.NewService(cfg, logger)
54+
55+
accountID := prefs.GetDefaultAccountID()
56+
if accountID == "" {
57+
fmt.Println(s.Help.Render("No account configured"))
58+
fmt.Println(s.Help.Render("Run 'tero' to complete onboarding"))
59+
return nil
60+
}
61+
62+
// Get API client
63+
apiClient, err := getAPIClient(cmd.Context(), logger, cliConfig)
64+
if err != nil {
65+
return err
66+
}
67+
68+
// Get the datadog account for this account
69+
ddService := api.NewDatadogAccountService(apiClient, logger)
70+
ddAccount, err := ddService.GetAccount(cmd.Context(), accountID)
71+
if err != nil {
72+
return fmt.Errorf("failed to get datadog account: %w", err)
73+
}
74+
if ddAccount == nil {
75+
fmt.Println(s.Help.Render("No Datadog account configured for this account"))
76+
fmt.Println(s.Help.Render("Run 'tero' to complete onboarding"))
77+
return nil
78+
}
79+
80+
// Fetch status
81+
ddStatus, err := ddService.GetStatus(cmd.Context(), ddAccount.ID)
82+
if err != nil {
83+
return fmt.Errorf("failed to fetch status: %w", err)
84+
}
85+
if ddStatus == nil {
86+
fmt.Println(s.Help.Render("No status found"))
87+
return nil
88+
}
89+
90+
// Print status
91+
fmt.Println(s.Title.Render("Datadog Account Status"))
92+
fmt.Println()
93+
94+
fmt.Printf(" %-25s %s\n", s.Help.Render("Status:"), s.Body.Render(string(ddStatus.Status)))
95+
fmt.Printf(" %-25s %s\n", s.Help.Render("Percent Complete:"), s.Body.Render(fmt.Sprintf("%.1f%%", ddStatus.PercentComplete*100)))
96+
fmt.Println()
97+
98+
fmt.Println(s.Title.Render("Service Counts"))
99+
fmt.Printf(" %-25s %s\n", s.Help.Render("Total:"), s.Body.Render(fmt.Sprintf("%d", ddStatus.ServiceCount)))
100+
fmt.Printf(" %-25s %s\n", s.Help.Render("Active:"), s.Body.Render(fmt.Sprintf("%d", ddStatus.ActiveServices)))
101+
fmt.Printf(" %-25s %s\n", s.Help.Render("Ready:"), s.Success.Render(fmt.Sprintf("%d", ddStatus.ReadyServices)))
102+
fmt.Printf(" %-25s %s\n", s.Help.Render("Analyzing:"), s.Body.Render(fmt.Sprintf("%d", ddStatus.AnalyzingServices)))
103+
fmt.Printf(" %-25s %s\n", s.Help.Render("Discovering:"), s.Body.Render(fmt.Sprintf("%d", ddStatus.DiscoveringServices)))
104+
fmt.Printf(" %-25s %s\n", s.Help.Render("Stale:"), s.Body.Render(fmt.Sprintf("%d", ddStatus.StaleServices)))
105+
fmt.Printf(" %-25s %s\n", s.Help.Render("Broken:"), s.Error.Render(fmt.Sprintf("%d", ddStatus.BrokenServices)))
106+
fmt.Printf(" %-25s %s\n", s.Help.Render("Inactive:"), s.Help.Render(fmt.Sprintf("%d", ddStatus.InactiveServices)))
107+
fmt.Printf(" %-25s %s\n", s.Help.Render("Disabled:"), s.Help.Render(fmt.Sprintf("%d", ddStatus.DisabledServices)))
108+
fmt.Println()
109+
110+
fmt.Println(s.Title.Render("Volume"))
111+
fmt.Printf(" %-25s %s\n", s.Help.Render("Service Volume:"), s.Body.Render(fmt.Sprintf("%d", ddStatus.ServiceLogVolume)))
112+
fmt.Printf(" %-25s %s\n", s.Help.Render("Discovered Volume:"), s.Body.Render(fmt.Sprintf("%d", ddStatus.DiscoveredLogVolume)))
113+
114+
// Show completion check
115+
fmt.Println()
116+
fmt.Println(s.Title.Render("Completion Check"))
117+
terminalServices := ddStatus.ReadyServices + ddStatus.BrokenServices
118+
allTerminal := terminalServices == ddStatus.ActiveServices
119+
fmt.Printf(" %-25s %s\n", s.Help.Render("Terminal Services:"), s.Body.Render(fmt.Sprintf("%d / %d active", terminalServices, ddStatus.ActiveServices)))
120+
fmt.Printf(" %-25s %s\n", s.Help.Render("All Terminal:"), s.Body.Render(fmt.Sprintf("%v", allTerminal)))
121+
fmt.Printf(" %-25s %s\n", s.Help.Render("Can Proceed:"), s.Body.Render(fmt.Sprintf("%v", allTerminal && ddStatus.ReadyServices > 0)))
122+
123+
return nil
124+
},
125+
}
126+
}
127+
128+
// newDebugPrefsCmd shows the current preferences
129+
func newDebugPrefsCmd(logger log.Logger, cliConfig *config.CLIConfig) *cobra.Command {
130+
return &cobra.Command{
131+
Use: "prefs",
132+
Short: "Show current preferences",
133+
Long: "Show the current preferences stored in the config file.",
134+
RunE: func(cmd *cobra.Command, args []string) error {
135+
theme := styles.NewTheme(true)
136+
s := theme.Styles
137+
namespace := cliConfig.Namespace()
138+
139+
cfg, err := config.Load(namespace)
140+
if err != nil {
141+
return fmt.Errorf("failed to load config: %w", err)
142+
}
143+
prefs := preferences.NewService(cfg, logger)
144+
145+
// Get config path for display
146+
homeDir, _ := os.UserHomeDir()
147+
var configPath string
148+
if namespace == "" {
149+
configPath = homeDir + "/.tero/config.yaml"
150+
} else {
151+
configPath = homeDir + "/.tero/" + namespace + "/config.yaml"
152+
}
153+
154+
fmt.Println(s.Title.Render("Preferences"))
155+
fmt.Println(s.Help.Render("Path: " + configPath))
156+
fmt.Println()
157+
158+
// Use preferences service to get values
159+
printPref := func(label, value string) {
160+
if value == "" {
161+
fmt.Printf(" %-25s %s\n", s.Help.Render(label+":"), s.Help.Render("(not set)"))
162+
} else {
163+
fmt.Printf(" %-25s %s\n", s.Help.Render(label+":"), s.Body.Render(value))
164+
}
165+
}
166+
167+
printPref("Organization ID", prefs.GetDefaultOrgID())
168+
printPref("Organization Name", prefs.GetDefaultOrgName())
169+
printPref("Account ID", prefs.GetDefaultAccountID())
170+
printPref("Workspace ID", prefs.GetDefaultWorkspaceID())
171+
printPref("Role", prefs.GetRole())
172+
printPref("Email", prefs.GetEmail())
173+
174+
// Services (list)
175+
services := prefs.GetServices()
176+
if len(services) == 0 {
177+
fmt.Printf(" %-25s %s\n", s.Help.Render("Services:"), s.Help.Render("(none)"))
178+
} else {
179+
fmt.Printf(" %-25s %s\n", s.Help.Render("Services:"), s.Body.Render(fmt.Sprintf("%v", services)))
180+
}
181+
182+
// Has seen greeting
183+
fmt.Printf(" %-25s %s\n", s.Help.Render("Has Seen Greeting:"), s.Body.Render(fmt.Sprintf("%v", prefs.GetHasSeenGreeting())))
184+
185+
return nil
186+
},
187+
}
188+
}
189+
190+
// newDebugGraphQLCmd runs a free-form GraphQL query
191+
func newDebugGraphQLCmd(logger log.Logger, cliConfig *config.CLIConfig) *cobra.Command {
192+
var variables string
193+
194+
cmd := &cobra.Command{
195+
Use: "graphql <query>",
196+
Short: "Run a GraphQL query",
197+
Long: "Run a free-form GraphQL query against the control plane API.",
198+
Args: cobra.ExactArgs(1),
199+
RunE: func(cmd *cobra.Command, args []string) error {
200+
query := args[0]
201+
202+
// Parse variables if provided
203+
var vars map[string]interface{}
204+
if variables != "" {
205+
if err := json.Unmarshal([]byte(variables), &vars); err != nil {
206+
return fmt.Errorf("invalid variables JSON: %w", err)
207+
}
208+
}
209+
210+
// Get API client
211+
apiClient, err := getAPIClient(cmd.Context(), logger, cliConfig)
212+
if err != nil {
213+
return err
214+
}
215+
216+
// Set account ID header if available
217+
cfg, _ := config.Load(cliConfig.Namespace())
218+
if cfg != nil {
219+
prefs := preferences.NewService(cfg, logger)
220+
if accountID := prefs.GetDefaultAccountID(); accountID != "" {
221+
apiClient.SetAccountID(accountID)
222+
}
223+
}
224+
225+
// Execute query
226+
result, err := apiClient.RawQuery(cmd.Context(), query, vars)
227+
if err != nil {
228+
return fmt.Errorf("query failed: %w", err)
229+
}
230+
231+
// Pretty print result
232+
output, err := json.MarshalIndent(result, "", " ")
233+
if err != nil {
234+
return fmt.Errorf("failed to format result: %w", err)
235+
}
236+
237+
fmt.Println(string(output))
238+
return nil
239+
},
240+
}
241+
242+
cmd.Flags().StringVarP(&variables, "variables", "v", "", "JSON object of variables")
243+
244+
return cmd
245+
}
246+
247+
// newDebugPathsCmd shows relevant file paths
248+
func newDebugPathsCmd(logger log.Logger, cliConfig *config.CLIConfig) *cobra.Command {
249+
return &cobra.Command{
250+
Use: "paths",
251+
Short: "Show file paths used by Tero",
252+
Long: "Show the file paths used by Tero for config, data, and credentials.",
253+
RunE: func(cmd *cobra.Command, args []string) error {
254+
theme := styles.NewTheme(true)
255+
s := theme.Styles
256+
namespace := cliConfig.Namespace()
257+
258+
homeDir, _ := os.UserHomeDir()
259+
260+
fmt.Println(s.Title.Render("Tero Paths"))
261+
fmt.Println()
262+
fmt.Printf(" %-20s %s\n", s.Help.Render("Namespace:"), s.Body.Render(ifEmpty(namespace, "(production)")))
263+
fmt.Println()
264+
265+
// Config path
266+
var configPath string
267+
if namespace == "" {
268+
configPath = homeDir + "/.tero/config.yaml"
269+
} else {
270+
configPath = homeDir + "/.tero/" + namespace + "/config.yaml"
271+
}
272+
fmt.Printf(" %-20s %s\n", s.Help.Render("Config:"), s.Body.Render(configPath))
273+
274+
// Data directory
275+
psConfig := &powersync.Config{Namespace: namespace}
276+
dataDir, _ := psConfig.DataDir()
277+
fmt.Printf(" %-20s %s\n", s.Help.Render("Data Dir:"), s.Body.Render(dataDir))
278+
279+
// Extension directory
280+
extDir, _ := psConfig.ExtensionDir()
281+
fmt.Printf(" %-20s %s\n", s.Help.Render("Extensions:"), s.Body.Render(extDir))
282+
283+
// Show database path if account is configured
284+
cfg, err := config.Load(namespace)
285+
if err == nil {
286+
accountID := cfg.Get("account_id")
287+
if accountID != "" {
288+
dbPath, _ := psConfig.DatabasePath(accountID)
289+
fmt.Printf(" %-20s %s\n", s.Help.Render("Database:"), s.Body.Render(dbPath))
290+
}
291+
}
292+
293+
return nil
294+
},
295+
}
296+
}
297+
298+
// getAPIClient creates an authenticated API client
299+
func getAPIClient(ctx context.Context, logger log.Logger, cliConfig *config.CLIConfig) (*client.Client, error) {
300+
namespace := cliConfig.Namespace()
301+
tokenStore := keyring.New(namespace)
302+
workosClient := workos.NewClient(cliConfig.WorkOSClientID, cliConfig.APIEndpoint, cliConfig.PowerSyncEndpoint)
303+
authService := auth.NewService(workosClient, tokenStore, logger)
304+
305+
token, err := authService.GetAccessToken(ctx)
306+
if err != nil {
307+
return nil, fmt.Errorf("not authenticated: run 'tero auth login' first")
308+
}
309+
310+
refreshFunc := func() (string, error) {
311+
return authService.GetAccessToken(ctx)
312+
}
313+
314+
return client.New(cliConfig.APIEndpoint, token, refreshFunc), nil
315+
}
316+
317+
func ifEmpty(s, fallback string) string {
318+
if s == "" {
319+
return fallback
320+
}
321+
return s
322+
}

‎internal/cmd/root.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ func NewRootCmd(logger log.Logger, version string) *cobra.Command {
2020
// Subcommands
2121
rootCmd.AddCommand(NewAuthCmd(logger, cliConfig))
2222
rootCmd.AddCommand(NewResetCmd(cliConfig))
23+
rootCmd.AddCommand(NewDebugCmd(logger, cliConfig))
2324

2425
return rootCmd
2526
}

‎pkg/client/client.go‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,23 @@ func (t *authTransport) SetAccessToken(token string) {
199199
func (t *authTransport) SetAccountID(accountID string) {
200200
t.accountID = accountID
201201
}
202+
203+
// RawQuery executes a free-form GraphQL query and returns the raw result.
204+
func (c *Client) RawQuery(ctx context.Context, query string, variables map[string]interface{}) (map[string]interface{}, error) {
205+
req := &graphql.Request{
206+
Query: query,
207+
Variables: variables,
208+
}
209+
resp := &graphql.Response{
210+
Data: new(map[string]interface{}),
211+
}
212+
213+
if err := c.gql.MakeRequest(ctx, req, resp); err != nil {
214+
return nil, err
215+
}
216+
217+
if data, ok := resp.Data.(*map[string]interface{}); ok {
218+
return *data, nil
219+
}
220+
return nil, nil
221+
}

0 commit comments

Comments
 (0)