diff --git a/internal/dev/dev.go b/internal/dev/dev.go new file mode 100644 index 0000000..8ba515c --- /dev/null +++ b/internal/dev/dev.go @@ -0,0 +1,141 @@ +package dev + +import ( + "errors" + "fmt" + "io" + "io/fs" + "maps" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/siliconwitchery/superstack-cli/internal/api" + "github.com/siliconwitchery/superstack-cli/internal/files" + "github.com/siliconwitchery/superstack-cli/internal/tail" +) + +type serialWriter struct { + mutex sync.Mutex + out io.Writer +} + +func (writer *serialWriter) Write(data []byte) (int, error) { + writer.mutex.Lock() + defer writer.mutex.Unlock() + + return writer.out.Write(data) +} + +func Dev(invocation api.Invocation, arguments []string) error { + if len(arguments) < 2 { + return errors.New("dev takes an IMEI or fleet id, then the files or directories to upload on every change") + } + + target := arguments[0] + tailArguments := []string{target} + isImei := len(target) == 15 && !strings.ContainsFunc(target, func(digit rune) bool { return digit < '0' || digit > '9' }) + + if isImei { + devices, err := api.FetchDevices(invocation) + + if err != nil { + return err + } + + index := slices.IndexFunc(devices, func(device api.DeviceEntry) bool { return device.Imei == target }) + + if index < 0 { + return fmt.Errorf("device %s is not paired with any of your fleets", target) + } + + tailArguments = []string{strconv.FormatInt(devices[index].FleetId, 10), target} + } + + invocation.Out = &serialWriter{out: invocation.Out} + + // The first upload, which also checks the arguments + err := files.Upload(invocation, arguments) + + if err != nil { + return err + } + + // The log, whose end is the end of dev + tailEnded := make(chan error, 1) + + go func() { + tailEnded <- tail.Tail(invocation, tailArguments) + }() + + // Upload again once the watched paths have changed and then stayed still for one poll + previous := snapshot(arguments[1:]) + changed := false + + for { + select { + case err := <-tailEnded: + return err + + case <-time.After(500 * time.Millisecond): + } + + current := snapshot(arguments[1:]) + + if !maps.Equal(current, previous) { + previous = current + changed = true + continue + } + + if !changed { + continue + } + + changed = false + err = files.Upload(invocation, arguments) + + if err != nil { + fmt.Fprintf(invocation.Out, "superstack: %s\n", err) + } + } +} + +func snapshot(paths []string) map[string]string { + state := map[string]string{} + + for _, argument := range paths { + filepath.WalkDir(argument, func(path string, entry fs.DirEntry, walkError error) error { + if walkError != nil { + return nil + } + + if path != argument && strings.HasPrefix(entry.Name(), ".") { + if entry.IsDir() { + return filepath.SkipDir + } + + return nil + } + + if !entry.Type().IsRegular() { + return nil + } + + info, err := entry.Info() + + if err != nil { + return nil + } + + state[path] = strconv.FormatInt(info.Size(), 10) + " " + info.ModTime().String() + + return nil + }) + } + + return state +} diff --git a/internal/dev/dev_test.go b/internal/dev/dev_test.go new file mode 100644 index 0000000..bd27608 --- /dev/null +++ b/internal/dev/dev_test.go @@ -0,0 +1,260 @@ +package dev + +import ( + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/siliconwitchery/superstack-cli/internal/api" + "github.com/siliconwitchery/superstack-cli/internal/api/apitest" +) + +// Pairs one device with fleet 3, accepts uploads to that device and fleet, serves two stored logs, and holds +// every follow request until released, when it refuses it as no such fleet so that dev ends +type testServer struct { + mutex sync.Mutex + uploads []string + queries []string + released chan struct{} +} + +func (server *testServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/devices": + name := "boiler" + json.NewEncoder(w).Encode([]api.DeviceEntry{{Imei: "354820091234567", Name: &name, FleetId: 3}}) + + case r.Method == http.MethodPut && r.URL.Path == "/devices/354820091234567/files": + body, _ := io.ReadAll(r.Body) + + server.mutex.Lock() + server.uploads = append(server.uploads, string(body)) + server.mutex.Unlock() + + w.WriteHeader(http.StatusNoContent) + + case r.Method == http.MethodPut && r.URL.Path == "/fleets/3/files": + body, _ := io.ReadAll(r.Body) + + server.mutex.Lock() + server.uploads = append(server.uploads, string(body)) + server.mutex.Unlock() + + w.Write([]byte(`{"devices": 2}`)) + + case r.Method == http.MethodGet && r.URL.Path == "/fleets/3/logs": + server.mutex.Lock() + server.queries = append(server.queries, r.URL.RawQuery) + server.mutex.Unlock() + + if r.URL.Query().Has("after") { + <-server.released + http.Error(w, "no such fleet", http.StatusNotFound) + return + } + + name := "boiler" + receivedAt := time.Date(2026, 10, 6, 13, 4, 5, 0, time.UTC) + json.NewEncoder(w).Encode(api.LogsAnswer{Logs: []api.LogEntry{ + {Id: 1, Imei: "354820091234567", Name: &name, Kind: "print", Text: "log 1", ReceivedAt: receivedAt}, + {Id: 2, Imei: "354820091234567", Name: &name, Kind: "print", Text: "log 2", ReceivedAt: receivedAt}, + }, Next: 2}) + + default: + http.Error(w, "unexpected request", http.StatusNotFound) + } +} + +func (server *testServer) uploadCount() int { + server.mutex.Lock() + defer server.mutex.Unlock() + + return len(server.uploads) +} + +type safeBuffer struct { + mutex sync.Mutex + text strings.Builder +} + +func (buffer *safeBuffer) Write(data []byte) (int, error) { + buffer.mutex.Lock() + defer buffer.mutex.Unlock() + + return buffer.text.Write(data) +} + +func (buffer *safeBuffer) String() string { + buffer.mutex.Lock() + defer buffer.mutex.Unlock() + + return buffer.text.String() +} + +func waitFor(t *testing.T, what string, condition func() bool) { + t.Helper() + + deadline := time.Now().Add(10 * time.Second) + + for !condition() { + if time.Now().After(deadline) { + t.Fatalf("gave up waiting for %s", what) + } + + time.Sleep(50 * time.Millisecond) + } +} + +func TestDevRefusesArguments(t *testing.T) { + project := t.TempDir() + + if err := os.WriteFile(filepath.Join(project, "main.lua"), []byte("print(1)"), 0o644); err != nil { + t.Fatal(err) + } + + tests := []struct { + arguments []string + wantError string + }{ + {[]string{}, "takes an IMEI or fleet id"}, + {[]string{"354820091234567"}, "takes an IMEI or fleet id"}, + {[]string{"354820099999999", project}, "device 354820099999999 is not paired with any of your fleets"}, + {[]string{"pilot", project}, "the first argument is the 15-digit IMEI"}, + {[]string{"3", filepath.Join(project, "missing.lua")}, "does not exist"}, + } + + for _, test := range tests { + t.Run(strings.Join(test.arguments, " "), func(t *testing.T) { + invocation, _ := apitest.LoggedInInvocation(t, &testServer{released: make(chan struct{})}) + + err := Dev(invocation, test.arguments) + + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("got %v, want an error containing %q", err, test.wantError) + } + }) + } +} + +func TestDevUploadsOnChangeAndFollowsTheLog(t *testing.T) { + tests := []struct { + name string + target string + watchFile bool + wantQuery string + wantUploaded string + wantRefusal string + }{ + { + name: "a file to a device", + target: "354820091234567", + watchFile: true, + wantQuery: "after=2&imei=354820091234567", + wantUploaded: "Uploaded 1 file to device 354820091234567. It arrives at its next check-in.\n", + wantRefusal: "does not exist", + }, + { + name: "a directory to a fleet", + target: "3", + wantQuery: "after=2", + wantUploaded: "Uploaded 1 file to 2 devices in fleet 3. It arrives at each device's next check-in.\n", + wantRefusal: "there are no files to upload", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + project := t.TempDir() + mainLua := filepath.Join(project, "main.lua") + + if err := os.WriteFile(mainLua, []byte("print(1)"), 0o644); err != nil { + t.Fatal(err) + } + + watched := project + + if test.watchFile { + watched = mainLua + } + + server := &testServer{released: make(chan struct{})} + invocation, _ := apitest.LoggedInInvocation(t, server) + out := &safeBuffer{} + invocation.Out = out + ended := make(chan error, 1) + + go func() { + ended <- Dev(invocation, []string{test.target, watched}) + }() + + // The first upload, then an edit + waitFor(t, "the first upload", func() bool { return server.uploadCount() == 1 }) + + if err := os.WriteFile(mainLua, []byte("print(2) -- edited"), 0o644); err != nil { + t.Fatal(err) + } + + waitFor(t, "the upload after the edit", func() bool { return server.uploadCount() == 2 }) + + // A deleted file is reported, and its return is uploaded + if err := os.Remove(mainLua); err != nil { + t.Fatal(err) + } + + waitFor(t, "the refusal", func() bool { return strings.Contains(out.String(), test.wantRefusal) }) + + if err := os.WriteFile(mainLua, []byte("print(3)"), 0o644); err != nil { + t.Fatal(err) + } + + waitFor(t, "the upload after the return", func() bool { return server.uploadCount() == 3 }) + + close(server.released) + + err := <-ended + + if err == nil || !strings.Contains(err.Error(), "no such fleet") { + t.Fatalf("dev ended with %v, want the log's refusal", err) + } + + server.mutex.Lock() + defer server.mutex.Unlock() + + if len(server.queries) < 2 || server.queries[1] != test.wantQuery { + t.Errorf("log queries were %q, want the follow to ask %q", server.queries, test.wantQuery) + } + + wantBodies := []string{ + `{"files":{"main.lua":"cHJpbnQoMSk="}}`, + `{"files":{"main.lua":"cHJpbnQoMikgLS0gZWRpdGVk"}}`, + `{"files":{"main.lua":"cHJpbnQoMyk="}}`, + } + + for index, body := range server.uploads { + if body != wantBodies[index] { + t.Errorf("upload %d sent %s, want %s", index+1, body, wantBodies[index]) + } + } + + output := out.String() + + if strings.Count(output, test.wantUploaded) != 3 { + t.Errorf("output was:\n%s\nwant %q three times", output, test.wantUploaded) + } + + if !strings.Contains(output, "[boiler] log 1\n") || !strings.Contains(output, "[boiler] log 2\n") { + t.Errorf("output was:\n%s\nwant the two stored logs", output) + } + + if !strings.HasPrefix(output, test.wantUploaded) { + t.Errorf("output was:\n%s\nwant the first upload before any log", output) + } + }) + } +} diff --git a/main.go b/main.go index 33e78b1..f7d624c 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "os" "github.com/siliconwitchery/superstack-cli/internal/account" + "github.com/siliconwitchery/superstack-cli/internal/dev" "github.com/siliconwitchery/superstack-cli/internal/device" "github.com/siliconwitchery/superstack-cli/internal/dispatch" "github.com/siliconwitchery/superstack-cli/internal/files" @@ -52,7 +53,7 @@ var sections = []dispatch.Section{ Commands: []dispatch.Command{ {Name: "upload", Arguments: " ...", Summary: "Upload files or directories to a device or fleet", Run: files.Upload}, {Name: "download", Arguments: " ", Summary: "Download the code last uploaded to a device into ", Run: files.Download}, - {Name: "dev", Arguments: " ... [--log-file ]", Summary: "Upload on every change, and tail"}, + {Name: "dev", Arguments: " ...", Summary: "Upload on every change, and tail", Run: dev.Dev}, }, }, { diff --git a/main_test.go b/main_test.go index b744b95..790e4d1 100644 --- a/main_test.go +++ b/main_test.go @@ -42,9 +42,7 @@ func TestCommandTable(t *testing.T) { } func TestOnlyPlannedCommandsAreUnimplemented(t *testing.T) { - plannedCommands := map[string]bool{ - "dev": true, - } + plannedCommands := map[string]bool{} answeredByDispatch := map[string]bool{ "version": true, "help": true, @@ -85,12 +83,13 @@ func TestNoPartImportsAnother(t *testing.T) { const fixtures = "api/apitest" // The graph docs/cli.md publishes: a part reaches api and nothing else, - // and only main reaches dispatch. + // except dev, which runs upload and tail, and only main reaches dispatch. allowed := map[string][]string{ "api": {}, "api/apitest": {"api"}, "dispatch": {"api"}, "account": {"api"}, + "dev": {"api", "files", "tail"}, "device": {"api"}, "files": {"api"}, "fleet": {"api"}, @@ -157,6 +156,7 @@ func TestNoPartImportsAnother(t *testing.T) { func TestTheTableWiresEveryCommandOffered(t *testing.T) { wired := []string{ "account balance", "account delete", "account topup", + "dev", "device list", "device pair", "device rename", "device restart", "device start", "device stop", "device unpair", "download", "fleet create", "fleet delete", "fleet list", "fleet rename", "fleet transfer", @@ -228,7 +228,7 @@ func TestMainReportsFailureWithANonZeroExit(t *testing.T) { {name: "no arguments", arguments: " ", wantCode: 0, wantSays: "Usage: superstack"}, {name: "the version", arguments: "version", wantCode: 0}, {name: "an unknown command", arguments: "nonsense", wantCode: 1, wantSays: "unknown command"}, - {name: "a command nobody has built yet", arguments: "dev 111111111111111 main.lua", wantCode: 1, wantSays: "not available yet"}, + {name: "a command missing its arguments", arguments: "dev", wantCode: 1, wantSays: "takes an IMEI or fleet id"}, {name: "a command that needs a login", arguments: "fleet list", wantCode: 1, wantSays: "not logged in"}, {name: "a flag with no value", arguments: "--server", wantCode: 1, wantSays: "needs an address"}, }