From 4357777339854187555ca2baa1e451287154cbda Mon Sep 17 00:00:00 2001 From: sarvesh2003dev Date: Sun, 6 Sep 2026 15:44:02 +0530 Subject: [PATCH 1/4] feat(machines): add wait command for lifecycle readiness Adds `dedalus machines wait` so users don't need to hand-roll polling loops after create/wake. --- pkg/cmd/machinewait.go | 108 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 pkg/cmd/machinewait.go diff --git a/pkg/cmd/machinewait.go b/pkg/cmd/machinewait.go new file mode 100644 index 0000000..7745707 --- /dev/null +++ b/pkg/cmd/machinewait.go @@ -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): + } + } +} From 78489086290e33e865c5953925dd0fb9ae521c19 Mon Sep 17 00:00:00 2001 From: sarvesh2003dev Date: Sun, 6 Sep 2026 15:44:49 +0530 Subject: [PATCH 2/4] feat(cli): register machines wait subcommand --- pkg/cmd/cmd.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index d358db6..5050716 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -118,6 +118,7 @@ func init() { &machinesSleep, &machinesWake, &machinesWatch, + &machinesWait, }, }, { From 096505e581f68da19f97db065e39495b6562ac85 Mon Sep 17 00:00:00 2001 From: sarvesh2003dev Date: Sun, 6 Sep 2026 16:22:44 +0530 Subject: [PATCH 3/4] feat(cli): add `dedalus doctor` diagnostics command Checks API key, API reachability, and optional machine health. --- pkg/cmd/doctor.go | 182 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 pkg/cmd/doctor.go diff --git a/pkg/cmd/doctor.go b/pkg/cmd/doctor.go new file mode 100644 index 0000000..2871ffd --- /dev/null +++ b/pkg/cmd/doctor.go @@ -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 +} From 3b7d5b2d264f0f83f7ba3f050298cd0c7c7c6e62 Mon Sep 17 00:00:00 2001 From: sarvesh2003dev Date: Sun, 6 Sep 2026 16:23:33 +0530 Subject: [PATCH 4/4] feat(cli): register doctor command --- pkg/cmd/cmd.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 5050716..24d1c05 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -95,6 +95,7 @@ func init() { }, }, Commands: []*cli.Command{ + &doctorCmd, { Name: "usage", Category: "API RESOURCE",