Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pkg/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ func init() {
},
},
Commands: []*cli.Command{
&doctorCmd,
{
Name: "usage",
Category: "API RESOURCE",
Expand All @@ -118,6 +119,7 @@ func init() {
&machinesSleep,
&machinesWake,
&machinesWatch,
&machinesWait,
},
},
{
Expand Down
182 changes: 182 additions & 0 deletions pkg/cmd/doctor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package cmd

import (
"context"
"fmt"
"os"
"strings"
"time"

"github.com/dedalus-labs/dedalus-go"
"github.com/dedalus-labs/dedalus-go/option"
"github.com/tidwall/gjson"
"github.com/urfave/cli/v3"
)

// doctorCmd is a non-generated DX helper for onboarding / support.
var doctorCmd = cli.Command{
Name: "doctor",
Usage: "Run diagnostics: API credentials, reachability, optional machine health",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "machine-id",
Usage: "Optional machine to inspect",
},
&cli.DurationFlag{
Name: "timeout",
Usage: "Per-check timeout",
Value: 15 * time.Second,
},
},
Action: handleDoctor,
HideHelpCommand: true,
Suggest: true,
}

type doctorCheck struct {
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail"`
}

func handleDoctor(ctx context.Context, cmd *cli.Command) error {
timeout := cmd.Duration("timeout")
machineID := cmd.String("machine-id")

checks := make([]doctorCheck, 0, 6)
allOK := true

// 1. API key presence
hasKey := os.Getenv("DEDALUS_API_KEY") != "" ||
os.Getenv("DEDALUS_X_API_KEY") != "" ||
cmd.IsSet("api-key") ||
cmd.IsSet("x-api-key")
if hasKey {
checks = append(checks, doctorCheck{Name: "api_key", OK: true, Detail: "API key is configured"})
} else {
allOK = false
checks = append(checks, doctorCheck{
Name: "api_key",
OK: false,
Detail: "Set DEDALUS_API_KEY or pass --api-key",
})
}

if !hasKey {
return printDoctor(cmd, allOK, checks)
}

// 2. API reachability via machines.list
client := dedalus.NewClient(getDefaultRequestOptions(cmd)...)
listCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

var res []byte
_, err := client.Machines.List(listCtx, dedalus.MachineListParams{}, option.WithResponseBodyInto(&res))
if err != nil {
allOK = false
checks = append(checks, doctorCheck{
Name: "api_reachable",
OK: false,
Detail: err.Error(),
})
return printDoctor(cmd, allOK, checks)
}
checks = append(checks, doctorCheck{
Name: "api_reachable",
OK: true,
Detail: "machines.list succeeded",
})

obj := gjson.ParseBytes(res)
items := obj.Get("items")
if items.Exists() && items.IsArray() {
checks = append(checks, doctorCheck{
Name: "machines_visible",
OK: true,
Detail: fmt.Sprintf("list returned %d item(s) on first page", len(items.Array())),
})
}

// 3. Optional machine inspection
if machineID != "" {
getCtx, cancelGet := context.WithTimeout(ctx, timeout)
defer cancelGet()

var mres []byte
params := dedalus.MachineGetParams{MachineID: machineID}
_, err := client.Machines.Get(getCtx, params, option.WithResponseBodyInto(&mres))
if err != nil {
allOK = false
checks = append(checks, doctorCheck{
Name: "machine_status",
OK: false,
Detail: err.Error(),
})
} else {
m := gjson.ParseBytes(mres)
phase := m.Get("status.phase").String()
lastErr := m.Get("status.last_error").String()
ok := phase != "failed" && phase != "destroyed"
if !ok {
allOK = false
}
detail := fmt.Sprintf("%s phase=%s", machineID, phase)
if lastErr != "" {
detail = detail + " last_error=" + lastErr
}
checks = append(checks, doctorCheck{
Name: "machine_status",
OK: ok,
Detail: detail,
})
}
}

return printDoctor(cmd, allOK, checks)
}

func printDoctor(cmd *cli.Command, allOK bool, checks []doctorCheck) error {
// Human-readable summary always goes to stderr; structured to stdout when format=json
for _, c := range checks {
mark := "PASS"
if !c.OK {
mark = "FAIL"
}
fmt.Fprintf(cmd.Root().ErrWriter, "[%s] %s — %s\n", mark, c.Name, c.Detail)
}
if allOK {
fmt.Fprintln(cmd.Root().ErrWriter, "\nAll checks passed.")
} else {
fmt.Fprintln(cmd.Root().ErrWriter, "\nOne or more checks failed.")
}

format := strings.ToLower(cmd.Root().String("format"))
if format == "json" || format == "pretty" || cmd.Root().IsSet("format") {
// Emit a small JSON report on stdout
b := strings.Builder{}
b.WriteString(`{"ok":`)
if allOK {
b.WriteString("true")
} else {
b.WriteString("false")
}
b.WriteString(`,"checks":[`)
for i, c := range checks {
if i > 0 {
b.WriteByte(',')
}
b.WriteString(fmt.Sprintf(
`{"name":%q,"ok":%v,"detail":%q}`,
c.Name, c.OK, c.Detail,
))
}
b.WriteString(`]}`)
fmt.Fprintln(cmd.Root().Writer, b.String())
}

if !allOK {
return cli.Exit("", 1)
}
return nil
}
108 changes: 108 additions & 0 deletions pkg/cmd/machinewait.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package cmd

import (
"context"
"fmt"
"time"

"github.com/dedalus-labs/dedalus-cli/internal/requestflag"
"github.com/dedalus-labs/dedalus-go"
"github.com/dedalus-labs/dedalus-go/option"
"github.com/tidwall/gjson"
"github.com/urfave/cli/v3"
)

// machinesWait is a DX helper (not generated from OpenAPI).
// It polls retrieve until the machine reaches the desired phase.
var machinesWait = cli.Command{
Name: "wait",
Usage: "Wait until a machine reaches a lifecycle phase (default: running)",
Flags: []cli.Flag{
&requestflag.Flag[string]{
Name: "machine-id",
Required: true,
PathParam: "machine_id",
},
&cli.StringFlag{
Name: "phase",
Usage: "Target phase to wait for (default: running)",
Value: "running",
},
&cli.DurationFlag{
Name: "timeout",
Usage: "Maximum time to wait",
Value: 2 * time.Minute,
},
&cli.DurationFlag{
Name: "interval",
Usage: "Poll interval",
Value: 1500 * time.Millisecond,
},
},
Action: handleMachinesWait,
HideHelpCommand: true,
Suggest: true,
}

func handleMachinesWait(ctx context.Context, cmd *cli.Command) error {
client := dedalus.NewClient(getDefaultRequestOptions(cmd)...)

machineID := cmd.Value("machine-id").(string)
targetPhase := cmd.String("phase")
timeout := cmd.Duration("timeout")
interval := cmd.Duration("interval")

deadline := time.Now().Add(timeout)
var lastPhase string

for {
if err := ctx.Err(); err != nil {
return err
}

var res []byte
params := dedalus.MachineGetParams{MachineID: machineID}
_, err := client.Machines.Get(ctx, params, option.WithResponseBodyInto(&res))
if err != nil {
return err
}

obj := gjson.ParseBytes(res)
lastPhase = obj.Get("status.phase").String()
lastError := obj.Get("status.last_error").String()

if lastPhase == targetPhase {
format := cmd.Root().String("format")
explicitFormat := cmd.Root().IsSet("format")
transform := cmd.Root().String("transform")
return ShowJSON(obj, ShowJSONOpts{
ExplicitFormat: explicitFormat,
Format: format,
RawOutput: cmd.Root().Bool("raw-output"),
Title: "machines wait",
Transform: transform,
})
}

if lastPhase == "failed" || lastPhase == "destroyed" {
if lastError != "" {
return fmt.Errorf("machine %s reached terminal phase %q: %s", machineID, lastPhase, lastError)
}
return fmt.Errorf("machine %s reached terminal phase %q", machineID, lastPhase)
}

if time.Now().After(deadline) {
return fmt.Errorf("timed out waiting for machine %s to reach %q after %s (last phase: %s)",
machineID, targetPhase, timeout, lastPhase)
}

// Best-effort progress on stderr so JSON stdout stays clean
fmt.Fprintf(cmd.Root().ErrWriter, "waiting: phase=%s target=%s\n", lastPhase, targetPhase)

select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(interval):
}
}
}