From 83ce277e3d623f8582811674ca748a838f078090 Mon Sep 17 00:00:00 2001 From: Raj Nakarja Date: Wed, 7 Oct 2026 16:21:07 +0200 Subject: [PATCH] Add dev to upload on every change and tail dev runs upload once, then tail in the background, and runs upload again whenever a watched file or directory changes and then stays still for one poll. It calls the two commands directly, so their behaviour and messages carry over without further work. An IMEI target is narrowed in the log by looking up its fleet. --- internal/dev/dev.go | 141 +++++++++++++++++++++ internal/dev/dev_test.go | 260 +++++++++++++++++++++++++++++++++++++++ main.go | 3 +- main_test.go | 10 +- 4 files changed, 408 insertions(+), 6 deletions(-) create mode 100644 internal/dev/dev.go create mode 100644 internal/dev/dev_test.go 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"}, }