From 3bbf3efc90a05e055367830121219d0eb56c3724 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Wed, 4 Mar 2026 19:29:15 +0400 Subject: [PATCH 1/2] feat: introduce device command --- internal/reporter/reporter.go | 24 +++++++++++ internal/simulator/simulator.go | 72 +++++++++++++++++++++++++++++++++ pkg/cmd/devices/devices.go | 57 ++++++++++++++++++++++++++ pkg/cmd/root/root.go | 2 + 4 files changed, 155 insertions(+) create mode 100644 internal/reporter/reporter.go create mode 100644 internal/simulator/simulator.go create mode 100644 pkg/cmd/devices/devices.go diff --git a/internal/reporter/reporter.go b/internal/reporter/reporter.go new file mode 100644 index 0000000..6717082 --- /dev/null +++ b/internal/reporter/reporter.go @@ -0,0 +1,24 @@ +package reporter + +import ( + "fmt" + "io" +) + +func PrintBanner(w io.Writer) { + fmt.Println(w, "Debugger") +} + +func PrintDeviceList(w io.Writer, platform string, devices []string) { + if len(devices) == 0 { + fmt.Fprintf(w, " No %s devices found (booted / connected)\n\n", platform) + return + } + + fmt.Fprintf(w, "%s\n", platform) + + for _, d := range devices { + fmt.Fprintf(w, "%s %s\n", "*", d) + } + fmt.Fprintln(w) +} diff --git a/internal/simulator/simulator.go b/internal/simulator/simulator.go new file mode 100644 index 0000000..36c3734 --- /dev/null +++ b/internal/simulator/simulator.go @@ -0,0 +1,72 @@ +package simulator + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" +) + +type simctlDevice struct { + UDID string `json:"udid"` + Name string `json:"name"` + State string `json:"state"` + IsAvailable bool `json:"isAvailable"` +} + +type simctlOutput struct { + Devices map[string][]simctlDevice `json:"devices"` +} + +func CheckToolsAvailable() map[string]bool { + available := make(map[string]bool) + for _, tool := range []string{"xcrun"} { + _, err := exec.LookPath(tool) + available[tool] = err == nil + } + return available +} + +func ListIOSDevices() ([]string, error) { + out, err := exec.Command("xcrun", "simctl", "list", "devices", "booted", "--json").Output() + if err != nil { + return nil, fmt.Errorf("xcrun simctl failed: %w", err) + } + + var payload simctlOutput + if err := json.Unmarshal(out, &payload); err != nil { + return nil, fmt.Errorf("failed to parse simctl JSON: %w", err) + } + + var names []string + for _, devices := range payload.Devices { + for _, d := range devices { + if strings.EqualFold(d.State, "Booted") && d.IsAvailable { + names = append(names, fmt.Sprintf("%s (%s)", d.Name, d.UDID)) + } + } + } + return names, nil +} + +func GetBootedIOSDevices() ([]simctlDevice, error) { + out, err := exec.Command("xcrun", "simctl", "list", "devices", "booted", "--json").Output() + if err != nil { + return nil, fmt.Errorf("xcrun simctl failed: %w", err) + } + + var payload simctlOutput + if err := json.Unmarshal(out, &payload); err != nil { + return nil, fmt.Errorf("failed to parse simctl JSON: %w", err) + } + + var devices []simctlDevice + for _, devices := range payload.Devices { + for _, d := range devices { + if strings.EqualFold(d.State, "Booted") && d.IsAvailable { + devices = append(devices, d) + } + } + } + return devices, nil +} diff --git a/pkg/cmd/devices/devices.go b/pkg/cmd/devices/devices.go new file mode 100644 index 0000000..1772614 --- /dev/null +++ b/pkg/cmd/devices/devices.go @@ -0,0 +1,57 @@ +package devices + +import ( + "encoding/json" + + "github.com/space-code/linkctl/internal/reporter" + "github.com/space-code/linkctl/internal/simulator" + "github.com/space-code/linkctl/pkg/cmdutil" + "github.com/spf13/cobra" +) + +type options struct { + asJSON bool +} + +func NewCmdDevices(f *cmdutil.Factory) *cobra.Command { + opts := options{} + + cmd := &cobra.Command{ + Use: "devices", + Short: "List connected iOS simulators", + Long: "List all currently booted iOS simulators.", + Example: `deeplink devices`, + RunE: func(cmd *cobra.Command, args []string) error { + return run(f, &opts) + }, + } + + return cmd +} + +type devicesJSON struct { + IOS []string `json:"ios"` + Tools map[string]bool `json:"tools"` +} + +func run(f *cmdutil.Factory, opts *options) error { + tools := simulator.CheckToolsAvailable() + var iosDevices []string + + if tools["xcrun"] { + iosDevices, _ = simulator.ListIOSDevices() + } + + if opts.asJSON { + enc := json.NewEncoder(f.IOStreams.Out) + enc.SetIndent("", " ") + return enc.Encode(devicesJSON{ + IOS: iosDevices, + Tools: tools, + }) + } + + w := f.IOStreams.Out + reporter.PrintDeviceList(w, "iOS", iosDevices) + return nil +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 85866ba..3ec989b 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -1,6 +1,7 @@ package root import ( + devicesCmd "github.com/space-code/linkctl/pkg/cmd/devices" versionCmd "github.com/space-code/linkctl/pkg/cmd/version" "github.com/space-code/linkctl/pkg/cmdutil" "github.com/spf13/cobra" @@ -17,6 +18,7 @@ func NewCmdRoot(f *cmdutil.Factory, appVersion string) (*cobra.Command, error) { } cmd.AddCommand(versionCmd.NewCmdVersion(f)) + cmd.AddCommand(devicesCmd.NewCmdDevices(f)) return cmd, nil } From dee32d40943df867966898a2b9d4bdebda9de9d1 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Thu, 5 Mar 2026 11:41:32 +0400 Subject: [PATCH 2/2] test: add tests for the devices command --- pkg/cmd/devices/devices.go | 4 +- pkg/cmd/devices/devices_test.go | 83 +++++++++++++++++++++++++++++++++ pkg/iostreams/iostreams.go | 52 +++++++++++++++++++-- 3 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 pkg/cmd/devices/devices_test.go diff --git a/pkg/cmd/devices/devices.go b/pkg/cmd/devices/devices.go index 1772614..4bcbf31 100644 --- a/pkg/cmd/devices/devices.go +++ b/pkg/cmd/devices/devices.go @@ -20,12 +20,14 @@ func NewCmdDevices(f *cmdutil.Factory) *cobra.Command { Use: "devices", Short: "List connected iOS simulators", Long: "List all currently booted iOS simulators.", - Example: `deeplink devices`, + Example: `linkctl devices`, RunE: func(cmd *cobra.Command, args []string) error { return run(f, &opts) }, } + cmd.Flags().BoolVar(&opts.asJSON, "json", false, "Output results as JSON") + return cmd } diff --git a/pkg/cmd/devices/devices_test.go b/pkg/cmd/devices/devices_test.go new file mode 100644 index 0000000..d70674f --- /dev/null +++ b/pkg/cmd/devices/devices_test.go @@ -0,0 +1,83 @@ +package devices_test + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/space-code/linkctl/pkg/cmd/devices" + "github.com/space-code/linkctl/pkg/cmdutil" + "github.com/space-code/linkctl/pkg/iostreams" +) + +func newFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer) { + t.Helper() + + ios, _, stdout, _ := iostreams.Test() + + f := &cmdutil.Factory{ + AppVersion: "1.0.0", + ExecutableName: "linkctl", + IOStreams: ios, + } + + return f, stdout +} + +func TestDevicesCmd_NoError(t *testing.T) { + f, _ := newFactory(t) + cmd := devices.NewCmdDevices(f) + cmd.SetArgs([]string{}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDevicesCmd_JSONOutput_HasAllKeys(t *testing.T) { + f, stdout := newFactory(t) + cmd := devices.NewCmdDevices(f) + cmd.SetArgs([]string{"--json"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + raw := stdout.String() + for _, key := range []string{"ios", "tools"} { + if !strings.Contains(raw, key) { + t.Errorf("JSON output missing key %s\ngot: %s", key, raw) + } + } +} + +func TestDevicesCmd_UnknownFlag(t *testing.T) { + f, _ := newFactory(t) + cmd := devices.NewCmdDevices(f) + cmd.SetArgs([]string{"--unknown"}) + if err := cmd.Execute(); err == nil { + t.Fatalf("unexpected error for unknown flag") + } +} + +func TestDevicesCmd_JSONOutput_Shape(t *testing.T) { + f, stdout := newFactory(t) + cmd := devices.NewCmdDevices(f) + cmd.SetArgs([]string{"--json"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var payload struct { + IOS []string `json:"ios"` + Tools map[string]bool `json:"tools"` + } + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("output is not valid JSON: %v\ngot: %s", err, stdout.String()) + } + + for _, key := range []string{"xcrun"} { + if _, ok := payload.Tools[key]; !ok { + t.Errorf("uexpected tools map to contain key %q", key) + } + } +} diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 2195c9f..ad009a8 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -1,14 +1,25 @@ package iostreams import ( + "bytes" "io" "os" ) +type fileWriter interface { + io.Writer + Fd() uintptr +} + +type fileReader interface { + io.ReadCloser + Fd() uintptr +} + type IOStreams struct { - In io.Reader - Out io.Writer - ErrOut io.Writer + In fileReader + Out fileWriter + ErrOut fileWriter } func System() *IOStreams { @@ -20,3 +31,38 @@ func System() *IOStreams { return ios } + +func Test() (*IOStreams, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) { + in := &bytes.Buffer{} + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + + io := &IOStreams{ + In: &fdReader{ + fd: 0, + ReadCloser: io.NopCloser(in), + }, + Out: &fdWriter{fd: 1, Writer: out}, + ErrOut: &fdWriter{fd: 2, Writer: errOut}, + } + + return io, in, out, errOut +} + +type fdWriter struct { + io.Writer + fd uintptr +} + +func (w *fdWriter) Fd() uintptr { + return w.fd +} + +type fdReader struct { + io.ReadCloser + fd uintptr +} + +func (r *fdReader) Fd() uintptr { + return r.fd +}