Skip to content
Closed
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
47 changes: 45 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
fleets, manage devices, and control who can reach them. It is a single static
binary for managing Superstack from a terminal.

Streaming logs is not available yet.

## Install

- **macOS**, with [Homebrew](https://brew.sh):
Expand Down Expand Up @@ -40,6 +38,51 @@ Streaming logs is not available yet.
[releases page](https://github.com/siliconwitchery/superstack-cli/releases),
unpack it, and move `superstack` onto your `PATH`. Repeat to update.

## Follow a fleet's logs

`tail` shows a fleet's logs as they arrive. Keep it open in one terminal while
you upload code from another:

```sh
superstack tail <fleet_id> [imei ...] [-n num]
```

- IMEIs after the fleet id limit the logs to those devices.
- Without `-n`, `tail` prints the 10 newest logs, then each new log as it
arrives. Ctrl-C ends it.
- `-n` prints that many of the newest logs and ends. The server keeps logs for
fourteen days.

Each line gives the time, the IMEI, the kind of log, the device's name in
brackets, and the text. The time is local and carries its offset. The kind is
`lua` for `print` output, `lifecycle` for code starting or stopping, and
`error` for an error. A device with no name prints `[]`:

```
2026-10-02T12:01:07+02:00 356938035643809 lua [back door] hello
2026-10-02T12:01:09+02:00 356938035643809 error [back door] Code crashed: main.lua:3: ...
2026-10-02T12:01:09+02:00 356938035643810 lifecycle [] Code started
```

The time, the IMEI, and the kind never contain a space, so `grep` and `awk`
can match on them:

```sh
superstack tail 3 -n 500 | grep ' 356938035643809 error ' # one device's errors
```

`print` output appears as Lua prints it. A log of several lines prints one
line for each, with every field repeated. Characters that would control the
terminal appear escaped, such as `\x1b`.

If the server stops answering, `tail` says so on the error stream and keeps
trying. It then carries on from where it stopped, with no log lost or
repeated. The output holds only logs, so `tee` can keep a copy:

```sh
superstack tail 3 | tee tail.log
```

## Local development

1. Clone the repository:
Expand Down
191 changes: 191 additions & 0 deletions internal/logs/logs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package logs

import (
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"

"github.com/siliconwitchery/superstack-cli/internal/api"
)

const timeLayout = "2006-01-02T15:04:05-07:00"

func Tail(invocation api.Invocation, arguments []string) error {
positionals := []string{}
count := 10
follow := true

for index := 0; index < len(arguments); index++ {
if arguments[index] != "-n" {
positionals = append(positionals, arguments[index])
continue
}

index++

value := ""

if index < len(arguments) {
value = arguments[index]
}

parsed, err := strconv.Atoi(value)

if err != nil || parsed < 0 {
return errors.New("-n needs the number of logs to show")
}

count = parsed
follow = false
}

if len(positionals) == 0 {
return errors.New("tail takes one fleet id, then optional IMEIs, each the 15-digit number printed on the device")
}

imeis := []string{}

for index, positional := range positionals {
isImei := len(positional) == 15 && !strings.ContainsFunc(positional, func(digit rune) bool { return digit < '0' || digit > '9' })

if index == 0 && isImei || index > 0 && !isImei {
return errors.New("tail takes one fleet id, then optional IMEIs, each the 15-digit number printed on the device")
}

if isImei {
imeis = append(imeis, positional)
}
}

fleetId, err := strconv.ParseInt(positionals[0], 10, 64)

if err != nil || fleetId < 1 {
return errors.New("the fleet id is the number shown by fleet list")
}

path := "/fleets/" + strconv.FormatInt(fleetId, 10) + "/logs?"
query := url.Values{"imei": imeis, "last": {strconv.Itoa(count)}}
failures := 0
remaining := count

for {
request, err := api.AuthenticatedRequest(invocation, http.MethodGet, path+query.Encode(), nil)

if err != nil {
return err
}

answer := struct {
Logs []struct {
Imei string `json:"imei"`
Name string `json:"name"`
Kind string `json:"kind"`
Text string `json:"text"`
ReceivedAt time.Time `json:"received_at"`
} `json:"logs"`
Next int64 `json:"next"`
}{}

response, err := invocation.Client.Do(request)

failed := err != nil

if !failed {
switch {
case response.StatusCode == http.StatusOK:
err = api.Decode(response, &answer)

failed = err != nil

case response.StatusCode >= 500:
failed = true

default:
refusal := api.ServerError(response)

response.Body.Close()

return refusal
}

response.Body.Close()
}

// The query is unchanged on a retry, so no log is lost or shown twice.
if failed {
if failures == 0 {
fmt.Fprintln(os.Stderr, time.Now().Format(timeLayout)+" superstack: The server stopped answering. Trying again.")
}

delay := 10 * time.Second

if failures < 3 {
delay = time.Second << failures // 1, 2, then 4 seconds
}

time.Sleep(delay)

failures++

continue
}

if failures > 0 {
fmt.Fprintln(os.Stderr, time.Now().Format(timeLayout)+" superstack: The server is answering again.")

failures = 0
}

ended := false

if !follow {
isFull := len(answer.Logs) == 1000 // the most the server puts in one answer

if len(answer.Logs) > remaining {
answer.Logs = answer.Logs[:remaining]
}

remaining -= len(answer.Logs)
ended = remaining == 0 || !isFull
}

lines := strings.Builder{}

for _, entry := range answer.Logs {
prefix := entry.ReceivedAt.Local().Format(timeLayout) + " " + entry.Imei + " " + entry.Kind + " [" + api.Printable(entry.Name) + "] "

for _, line := range strings.Split(entry.Text, "\n") {
lines.WriteString(prefix)

// Tabs and every graphic character print as Lua would print
// them. The rest is escaped so a device cannot drive the terminal.
for _, letter := range line {
if letter == '\t' || strconv.IsGraphic(letter) {
lines.WriteRune(letter)
continue
}

quoted := strconv.QuoteRuneToGraphic(letter)

lines.WriteString(quoted[1 : len(quoted)-1])
}

lines.WriteString("\n")
}
}

fmt.Fprint(invocation.Out, lines.String())

if ended {
return nil
}

// The server holds this request for up to 20 seconds when it has no logs, inside the client's 30-second timeout.
query = url.Values{"after": {strconv.FormatInt(answer.Next, 10)}, "imei": imeis}
}
}
Loading
Loading