From b8603f0a83c4f77db29caa35a67328cff0470c5c Mon Sep 17 00:00:00 2001 From: Alexgodoroja Date: Tue, 15 Sep 2026 15:31:45 -0700 Subject: [PATCH 1/6] Name a session from the CLI, and list an account's sessions shell --name labels a session as it starts. The name is published to the account, kept in the local record, and shown on the start card and in shell list, beside the command it runs. shell ls lists the sessions in the account from every linked machine, which shell list cannot: it only knows this one. Ended sessions are counted and hidden until --all, and --json prints the records without passwords. The auto-close pre-parser learns --name so a value of its own is not mistaken for the command. --- README.md | 2 + cmd/shell/account_sessions.go | 227 +++++++++++++++++++++++++++++ cmd/shell/account_sessions_test.go | 222 ++++++++++++++++++++++++++++ cmd/shell/autoclose.go | 2 +- cmd/shell/background.go | 1 + cmd/shell/background_unix.go | 3 + cmd/shell/background_windows.go | 3 + cmd/shell/help.go | 44 +++++- cmd/shell/help_test.go | 28 ++++ cmd/shell/main.go | 18 ++- cmd/shell/session_output.go | 3 + cmd/shell/sessions.go | 47 +++++- internal/account/client.go | 35 +++++ internal/account/client_test.go | 41 ++++++ 14 files changed, 664 insertions(+), 12 deletions(-) create mode 100644 cmd/shell/account_sessions.go create mode 100644 cmd/shell/account_sessions_test.go diff --git a/README.md b/README.md index 4d37b83..a85c072 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ installer, and test caveats. shell # share a command shell # share a new shell shell --read-only # disable browser input +shell --name "web app" # label it in lists and the web app shell --foreground # also show it locally shell --auto-close 5m # set an earlier deadline shell --persistent # reuse a URL and password @@ -75,6 +76,7 @@ shell --files # opt in working-directory files shell --files-root # opt in a different file root shell list # list local sessions (adapts to terminal width) +shell ls # list your account's sessions on every machine shell password # retrieve an active password locally shell password rotate # revoke it without restarting the process shell attach # attach locally diff --git a/cmd/shell/account_sessions.go b/cmd/shell/account_sessions.go new file mode 100644 index 0000000..354a222 --- /dev/null +++ b/cmd/shell/account_sessions.go @@ -0,0 +1,227 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "text/tabwriter" + "time" + + "shell.online/internal/account" +) + +// accountSessionsTimeout bounds `shell ls`, which is one request and a refresh. +const accountSessionsTimeout = 15 * time.Second + +// accountSessionJSON is the stable, script-facing shape of `shell ls --json`. +type accountSessionJSON struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Command string `json:"command"` + Host string `json:"host"` + ShareURL string `json:"share_url"` + ReadOnly bool `json:"read_only"` + Encrypted bool `json:"encrypted"` + Persistent bool `json:"persistent"` + StartedAt time.Time `json:"started_at"` + ClosedAt *time.Time `json:"closed_at,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + Status string `json:"status"` + RelayStatus string `json:"relay_status,omitempty"` +} + +// runAccountSessionList prints the sessions in the linked account, from every +// machine it has, where `shell list` prints only the processes on this one. +func runAccountSessionList(arguments []string, stdout, stderr io.Writer) int { + flags := flag.NewFlagSet("shell ls", flag.ContinueOnError) + flags.SetOutput(stderr) + all := flags.Bool("all", false, "include sessions that have ended") + jsonOutput := flags.Bool("json", false, "emit sessions as JSON") + flags.Usage = func() { + fmt.Fprintln(stderr, "Usage: shell ls [--all] [--json]") + fmt.Fprintln(stderr, "Lists the sessions in your account, from every linked machine.") + } + if err := flags.Parse(arguments); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + if flags.NArg() != 0 { + flags.Usage() + return 2 + } + + ctx, cancel := context.WithTimeout(context.Background(), accountSessionsTimeout) + defer cancel() + client, credentials, err := linkedAccountClient(ctx, stderr) + if errors.Is(err, account.ErrNotLinked) { + fmt.Fprintln(stderr, "shell: shell ls lists the sessions in your account, and this machine is not signed in.") + fmt.Fprintln(stderr, "Run 'shell login' to link it, or 'shell list' for the sessions running here.") + return 1 + } + if err != nil { + fmt.Fprintf(stderr, "shell: %v\n", err) + return 1 + } + sessions, err := client.ListSessions(ctx, credentials.AccessToken) + if err != nil { + fmt.Fprintf(stderr, "shell: list account sessions: %v\n", err) + return 1 + } + + shown := make([]account.AccountSession, 0, len(sessions)) + for _, session := range sessions { + if *all || !accountSessionEnded(session) { + shown = append(shown, session) + } + } + hidden := len(sessions) - len(shown) + + if *jsonOutput { + listed := make([]accountSessionJSON, 0, len(shown)) + for _, session := range shown { + listed = append(listed, accountSessionForJSON(session)) + } + encoder := json.NewEncoder(stdout) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(listed); err != nil { + fmt.Fprintf(stderr, "shell: encode sessions: %v\n", err) + return 1 + } + return 0 + } + + now := time.Now() + if len(shown) == 0 { + if *all { + fmt.Fprintln(stdout, "No sessions in your account.") + } else { + fmt.Fprintln(stdout, "No open sessions in your account.") + } + } else if compactSessionList(stdout) { + printCompactAccountSessions(stdout, shown, now) + } else { + printAccountSessionTable(stdout, shown, now) + } + if hidden > 0 { + fmt.Fprintf(stdout, "%d ended session%s hidden · shell ls --all\n", hidden, pluralSuffix(hidden)) + } + return 0 +} + +// linkedAccountClient loads this machine's account and renews a stale token, +// saving the renewed one so the next command need not. +func linkedAccountClient(ctx context.Context, warn io.Writer) (*account.Client, account.Credentials, error) { + path, err := account.DefaultPath() + if err != nil { + return nil, account.Credentials{}, err + } + credentials, err := account.Load(path) + if err != nil { + return nil, account.Credentials{}, err + } + client := account.NewClient(credentials.Server, "shell/"+version) + if credentials.Expired(time.Now()) { + refreshed, refreshErr := client.Refresh(ctx, credentials) + if refreshErr != nil { + return nil, account.Credentials{}, fmt.Errorf("renew this machine's sign-in: %w", refreshErr) + } + credentials = refreshed + if saveErr := account.Save(path, credentials); saveErr != nil { + fmt.Fprintf(warn, "shell: could not store the renewed token: %v\n", saveErr) + } + } + return client, credentials, nil +} + +// accountSessionEnded mirrors the web app: closed, or gone from the relay. +// A disconnected session may still come back, so it is not ended. +func accountSessionEnded(session account.AccountSession) bool { + return session.ClosedAt != nil || session.RelayStatus == "exited" || session.RelayStatus == "missing" +} + +// accountSessionStatus uses the words the web app shows for the same states. +func accountSessionStatus(session account.AccountSession) string { + switch { + case session.ClosedAt != nil || session.RelayStatus == "exited": + return "finished" + case session.RelayStatus == "missing": + return "unavailable" + case session.RelayStatus == "disconnected": + return "offline" + case session.RelayStatus == "waiting": + return "starting" + case session.RelayStatus == "unknown": + return "unknown" + default: + return "online" + } +} + +// accountSessionDuration is how long a session has run, or ran. +func accountSessionDuration(session account.AccountSession, now time.Time) string { + end := now + if session.ClosedAt != nil { + end = time.UnixMilli(*session.ClosedAt) + } + return compactDuration(end.Sub(time.UnixMilli(session.StartedAt))) +} + +func accountSessionForJSON(session account.AccountSession) accountSessionJSON { + listed := accountSessionJSON{ + ID: session.ID, + Name: session.Name, + Command: session.Command, + Host: session.Host, + ShareURL: session.ShareURL, + ReadOnly: session.ReadOnly, + Encrypted: session.Encrypted, + Persistent: session.Persistent, + StartedAt: time.UnixMilli(session.StartedAt).UTC(), + ExitCode: session.ExitCode, + Status: accountSessionStatus(session), + RelayStatus: session.RelayStatus, + } + if session.ClosedAt != nil { + closedAt := time.UnixMilli(*session.ClosedAt).UTC() + listed.ClosedAt = &closedAt + } + return listed +} + +func printAccountSessionTable(writer io.Writer, sessions []account.AccountSession, now time.Time) { + table := tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) + fmt.Fprintln(table, "ID\tNAME\tSTATUS\tUPTIME\tMACHINE\tCOMMAND") + for _, session := range sessions { + fmt.Fprintf(table, "%s\t%s\t%s\t%s\t%s\t%s\n", + shortSessionID(session.ID), + sessionNameLabel(session.Name, 32), + accountSessionStatus(session), + accountSessionDuration(session, now), + truncateText(session.Host, 24), + truncateText(session.Command, 48), + ) + } + _ = table.Flush() +} + +func printCompactAccountSessions(writer io.Writer, sessions []account.AccountSession, now time.Time) { + for index, session := range sessions { + if index > 0 { + fmt.Fprintln(writer) + } + title := session.Name + if title == "" { + title = session.Command + } + fmt.Fprintf(writer, "%s %s\n", shortSessionID(session.ID), truncateText(title, 64)) + fmt.Fprintf(writer, " %s · %s · %s\n", accountSessionStatus(session), accountSessionDuration(session, now), session.Host) + if session.Name != "" { + fmt.Fprintf(writer, " Command %s\n", truncateText(session.Command, 64)) + } + } +} diff --git a/cmd/shell/account_sessions_test.go b/cmd/shell/account_sessions_test.go new file mode 100644 index 0000000..c5230f3 --- /dev/null +++ b/cmd/shell/account_sessions_test.go @@ -0,0 +1,222 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "shell.online/internal/account" +) + +func accountSessionsService(t *testing.T, sessions []map[string]any) *httptest.Server { + t.Helper() + service := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/api/cli/sessions" || request.Method != http.MethodGet { + t.Errorf("unexpected request %s %s", request.Method, request.URL.Path) + } + if request.Header.Get("Authorization") != "Bearer sha_access" { + t.Errorf("Authorization = %q", request.Header.Get("Authorization")) + } + writer.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(writer).Encode(map[string]any{"sessions": sessions}) + })) + t.Cleanup(service.Close) + return service +} + +func sampleAccountSessions(now time.Time) []map[string]any { + return []map[string]any{ + { + "id": "qN7wKb3xTm9Ld2Ravh4YsPcE8UjZgF6t", "name": "web app", "command": "npm run dev", + "host": "ana-mbp", "shareUrl": "https://shell.online/s/qN7wKb3xTm9Ld2Ravh4YsPcE8UjZgF6t", + "startedAt": now.Add(-2 * time.Hour).UnixMilli(), "relayStatus": "connected", + }, + { + "id": "Zm9vYmFyYmF6cXV4cXV1eDEyMzQ1Njc4", "command": "pytest -x", + "host": "build-01", "shareUrl": "https://shell.online/s/Zm9vYmFyYmF6cXV4cXV1eDEyMzQ1Njc4", + "startedAt": now.Add(-3 * time.Hour).UnixMilli(), "closedAt": now.Add(-150 * time.Minute).UnixMilli(), + "exitCode": 1, + }, + } +} + +func TestAccountSessionListShowsOpenSessionsAndCountsEndedOnes(t *testing.T) { + service := accountSessionsService(t, sampleAccountSessions(time.Now())) + linkedAccount(t, service.URL) + + var stdout, stderr bytes.Buffer + if code := runAccountSessionList(nil, &stdout, &stderr); code != 0 { + t.Fatalf("exit = %d, stderr = %q", code, stderr.String()) + } + output := stdout.String() + for _, expected := range []string{"NAME", "qN7wKb3xTm", "web app", "online", "ana-mbp", "npm run dev", "1 ended session hidden · shell ls --all"} { + if !strings.Contains(output, expected) { + t.Errorf("output does not contain %q\n%s", expected, output) + } + } + if strings.Contains(output, "pytest") { + t.Errorf("an ended session was listed without --all\n%s", output) + } +} + +func TestAccountSessionListAllIncludesEndedSessions(t *testing.T) { + service := accountSessionsService(t, sampleAccountSessions(time.Now())) + linkedAccount(t, service.URL) + + var stdout, stderr bytes.Buffer + if code := runAccountSessionList([]string{"--all"}, &stdout, &stderr); code != 0 { + t.Fatalf("exit = %d, stderr = %q", code, stderr.String()) + } + for _, expected := range []string{"web app", "pytest -x", "finished", "build-01"} { + if !strings.Contains(stdout.String(), expected) { + t.Errorf("output does not contain %q\n%s", expected, stdout.String()) + } + } + if strings.Contains(stdout.String(), "hidden") { + t.Errorf("--all should hide nothing\n%s", stdout.String()) + } +} + +func TestAccountSessionListJSONIsStable(t *testing.T) { + now := time.Now() + service := accountSessionsService(t, sampleAccountSessions(now)) + linkedAccount(t, service.URL) + + var stdout, stderr bytes.Buffer + if code := runAccountSessionList([]string{"--json", "--all"}, &stdout, &stderr); code != 0 { + t.Fatalf("exit = %d, stderr = %q", code, stderr.String()) + } + var listed []map[string]any + if err := json.Unmarshal(stdout.Bytes(), &listed); err != nil { + t.Fatalf("decode %q: %v", stdout.String(), err) + } + if len(listed) != 2 { + t.Fatalf("listed %d sessions, want 2", len(listed)) + } + if listed[0]["name"] != "web app" || listed[0]["status"] != "online" || listed[0]["share_url"] == nil { + t.Errorf("first session = %+v", listed[0]) + } + if listed[1]["status"] != "finished" || listed[1]["closed_at"] == nil || listed[1]["exit_code"] != float64(1) { + t.Errorf("second session = %+v", listed[1]) + } + if _, present := listed[1]["name"]; present { + t.Errorf("an unnamed session should omit name: %+v", listed[1]) + } +} + +func TestAccountSessionListExplainsAnUnlinkedMachine(t *testing.T) { + t.Setenv("SHELL_ONLINE_CONFIG", filepath.Join(t.TempDir(), "absent.json")) + var stdout, stderr bytes.Buffer + if code := runAccountSessionList(nil, &stdout, &stderr); code != 1 { + t.Fatalf("exit = %d, want 1", code) + } + for _, expected := range []string{"not signed in", "shell login", "shell list"} { + if !strings.Contains(stderr.String(), expected) { + t.Errorf("stderr does not contain %q: %q", expected, stderr.String()) + } + } +} + +func TestAccountSessionListRejectsArguments(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := runAccountSessionList([]string{"extra"}, &stdout, &stderr); code != 2 { + t.Fatalf("exit = %d, want 2", code) + } +} + +func TestRunSessionCommandRoutesLs(t *testing.T) { + var stdout, stderr bytes.Buffer + code, handled := runSessionCommand([]string{"ls", "--help"}, &stdout, &stderr) + if !handled || code != 0 { + t.Fatalf("ls --help = %d, handled %v", code, handled) + } + if !strings.Contains(stderr.String(), "shell ls") { + t.Fatalf("usage = %q", stderr.String()) + } +} + +func TestAccountSessionStatusMatchesTheWebApp(t *testing.T) { + closed := int64(1) + tests := []struct { + session account.AccountSession + status string + ended bool + }{ + {account.AccountSession{RelayStatus: "connected"}, "online", false}, + {account.AccountSession{}, "online", false}, + {account.AccountSession{RelayStatus: "waiting"}, "starting", false}, + {account.AccountSession{RelayStatus: "disconnected"}, "offline", false}, + {account.AccountSession{RelayStatus: "unknown"}, "unknown", false}, + {account.AccountSession{RelayStatus: "missing"}, "unavailable", true}, + {account.AccountSession{RelayStatus: "exited"}, "finished", true}, + {account.AccountSession{ClosedAt: &closed, RelayStatus: "connected"}, "finished", true}, + } + for _, test := range tests { + if got := accountSessionStatus(test.session); got != test.status { + t.Errorf("status(%+v) = %q, want %q", test.session, got, test.status) + } + if got := accountSessionEnded(test.session); got != test.ended { + t.Errorf("ended(%+v) = %v, want %v", test.session, got, test.ended) + } + } +} + +func TestValidateSessionName(t *testing.T) { + if err := validateSessionName(""); err != nil { + t.Errorf("empty name: %v", err) + } + if err := validateSessionName(strings.Repeat("é", sessionNameLimit)); err != nil { + t.Errorf("name at the limit: %v", err) + } + if err := validateSessionName(strings.Repeat("a", sessionNameLimit+1)); err == nil { + t.Error("an over-long name was accepted") + } + if err := validateSessionName("two\nlines"); err == nil { + t.Error("a multi-line name was accepted") + } +} + +func TestNameFlagIsRejectedBeforeAnythingStarts(t *testing.T) { + // run() may try to bring the daemon up; with no account there is nothing to start. + t.Setenv("SHELL_ONLINE_CONFIG", filepath.Join(t.TempDir(), "absent.json")) + var stdout, stderr bytes.Buffer + code := run([]string{"--name", strings.Repeat("a", sessionNameLimit+1), "true"}, &stdout, &stderr) + if code != 2 || !strings.Contains(stderr.String(), "--name is limited") { + t.Fatalf("exit = %d, stderr = %q", code, stderr.String()) + } +} + +func TestAutoCloseNormalizationSkipsTheNameValue(t *testing.T) { + now := time.Date(2026, time.September, 11, 12, 0, 0, 0, time.UTC) + got, err := normalizeAutoCloseArguments([]string{"--name", "nightly", "--auto-close", "in", "5m", "make"}, now) + if err != nil { + t.Fatal(err) + } + want := []string{"--name", "nightly", "--auto-close=in 5m", "make"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("normalized = %q, want %q", got, want) + } +} + +func TestCompactSessionListLeadsWithTheName(t *testing.T) { + now := time.Date(2026, time.September, 11, 12, 0, 0, 0, time.UTC) + record := localSessionRecord{ + ID: "abcdefghijklmnopqrstuvwxyzABCDEF", + Name: "training run", + ShareURL: "https://shell.online/s/abcdefghijklmnopqrstuvwxyzABCDEF", + Command: "python train.py", + StartedAt: now.Add(-time.Minute), + } + var output bytes.Buffer + printCompactSessionList(&output, []localSessionRecord{record}, map[string]relaySessionStatus{}, now) + for _, expected := range []string{"abcdefghij training run", "Command python train.py"} { + if !strings.Contains(output.String(), expected) { + t.Errorf("card does not contain %q\n%s", expected, output.String()) + } + } +} diff --git a/cmd/shell/autoclose.go b/cmd/shell/autoclose.go index 1aa92db..5a41caf 100644 --- a/cmd/shell/autoclose.go +++ b/cmd/shell/autoclose.go @@ -50,7 +50,7 @@ func normalizeAutoCloseArguments(arguments []string, now time.Time) ([]string, e } if argument != "--auto-close" { normalized = append(normalized, argument) - if (argument == "--server" || argument == "--persistent") && index+1 < len(arguments) { + if (argument == "--server" || argument == "--persistent" || argument == "--name") && index+1 < len(arguments) { normalized = append(normalized, arguments[index+1]) index++ continue diff --git a/cmd/shell/background.go b/cmd/shell/background.go index 309d778..fda1ea2 100644 --- a/cmd/shell/background.go +++ b/cmd/shell/background.go @@ -19,6 +19,7 @@ type backgroundLaunchResult struct { OK bool `json:"ok"` Error string `json:"error,omitempty"` ID string `json:"session_id,omitempty"` + Name string `json:"name,omitempty"` ShareURL string `json:"share_url,omitempty"` ReadOnly bool `json:"read_only,omitempty"` Encrypted bool `json:"encrypted,omitempty"` diff --git a/cmd/shell/background_unix.go b/cmd/shell/background_unix.go index 5144201..9e69372 100644 --- a/cmd/shell/background_unix.go +++ b/cmd/shell/background_unix.go @@ -126,6 +126,9 @@ func launchBackgroundProcess(arguments []string, jsonOutput bool, stdout, stderr "expires_at": result.ExpiresAt.Format(time.RFC3339), "background": true, } + if result.Name != "" { + event["name"] = result.Name + } if result.Password != "" { event["e2ee_password"] = result.Password } diff --git a/cmd/shell/background_windows.go b/cmd/shell/background_windows.go index 2cf5f05..0c732c8 100644 --- a/cmd/shell/background_windows.go +++ b/cmd/shell/background_windows.go @@ -117,6 +117,9 @@ func launchBackgroundProcess(arguments []string, jsonOutput bool, stdout, stderr "read_only": result.ReadOnly, "encrypted": result.Encrypted, "persistent": result.Persistent, "auto_close": "task", "expires_at": result.ExpiresAt.Format(time.RFC3339), "background": true, } + if result.Name != "" { + event["name"] = result.Name + } if result.Password != "" { event["e2ee_password"] = result.Password } diff --git a/cmd/shell/help.go b/cmd/shell/help.go index 873c608..f577e02 100644 --- a/cmd/shell/help.go +++ b/cmd/shell/help.go @@ -12,6 +12,7 @@ Start shell Share it in the background shell --read-only Share it while browser input is blocked shell --files Add on-demand files from this directory + shell --name Label it in shell ls and the web app shell Share a fresh shell shell claude Share a fork of this conversation @@ -21,6 +22,7 @@ both. Shares are interactive by default and end-to-end encrypted. Then shell list See active shares and uptime shell list --json Give agents the complete machine-readable records + shell ls See every open session in your account, on any machine shell password Print an active share's password locally shell password rotate Revoke it and make a fresh password shell attach Rejoin locally; browser access stays live @@ -40,13 +42,14 @@ Machine services Common options --read-only View only + --name Label the session --foreground Stay attached locally --persistent Keep one encrypted URL across restarts --files Opt in the working directory for file access --files-root Opt in a different directory --auto-close