detached 3644a55

feat(remote): add SSH session catalog client

Signed-off-by: brice <b@bnema.dev>
commit
3644a55edc0aed7a1dd79e7f99246de51f8f99ed
author
brice · · 2026-07-29 21:42 UTC
committer
brice · · 2026-07-30 09:15 UTC
parent
097f96e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +589 -3
A internal/adapters/remote/catalog.go +180 -0
@@ -0,0 +1,180 @@
1+package remote
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "log/slog"
11+ "os/exec"
12+ "strings"
13+
14+ "github.com/bnema/vev/internal/adapters/sshstdio"
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/ports"
17+)
18+
19+const maxCatalogBytes = 1 << 20
20+
21+var (
22+ errCatalogDecode = errors.New("remote catalog: invalid JSON")
23+ errCatalogTrailing = errors.New("remote catalog: trailing bytes after JSON")
24+ errCatalogRequired = errors.New("remote catalog: missing required fields")
25+ errCatalogSession = errors.New("remote catalog: invalid session")
26+ errCatalogSSH = errors.New("remote catalog: ssh command failed")
27+ errCatalogTooLarge = errors.New("remote catalog: output exceeds size limit")
28+)
29+
30+// CatalogClient fetches remote session catalogs over SSH.
31+type CatalogClient struct {
32+ command func(ctx context.Context, name string, args ...string) *exec.Cmd
33+}
34+
35+var _ ports.RemoteCatalogClient = (*CatalogClient)(nil)
36+
37+// NewCatalogClient returns a RemoteCatalogClient that shells out to ssh.
38+func NewCatalogClient() ports.RemoteCatalogClient {
39+ return &CatalogClient{command: exec.CommandContext}
40+}
41+
42+// List runs `ssh -- <target> 'vev' 'cmd' 'remote-catalog' '--json'` and decodes
43+// exactly one versioned catalog envelope from stdout.
44+func (c *CatalogClient) List(ctx context.Context, target string) (ports.RemoteCatalog, error) {
45+ if err := ctx.Err(); err != nil {
46+ return ports.RemoteCatalog{}, err
47+ }
48+ if err := domain.ValidateRemoteHostTarget(target); err != nil {
49+ slog.Debug("remote catalog rejected target", "target", target, "err", err)
50+ return ports.RemoteCatalog{}, err
51+ }
52+
53+ command := c.command
54+ if command == nil {
55+ command = exec.CommandContext
56+ }
57+ spec := sshstdio.BuildCommandForRemoteCommand(target, "vev", "cmd", "remote-catalog", "--json")
58+ cmd := command(ctx, spec.Path, spec.Args...)
59+ var stdout boundedBuffer
60+ var stderr bytes.Buffer
61+ cmd.Stdout = &stdout
62+ cmd.Stderr = &stderr
63+ if err := cmd.Run(); err != nil {
64+ if ctxErr := ctx.Err(); ctxErr != nil {
65+ return ports.RemoteCatalog{}, ctxErr
66+ }
67+ stderrText := strings.TrimSpace(stderr.String())
68+ slog.Debug("remote catalog ssh failed", "target", target, "err", err, "stderr", stderrText)
69+ if stderrText != "" {
70+ return ports.RemoteCatalog{}, fmt.Errorf("%w: %w: %s", errCatalogSSH, err, stderrText)
71+ }
72+ return ports.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogSSH, err)
73+ }
74+ if stdout.overflow {
75+ slog.Debug("remote catalog output too large", "target", target, "limit", maxCatalogBytes)
76+ return ports.RemoteCatalog{}, errCatalogTooLarge
77+ }
78+
79+ catalog, err := decodeRemoteCatalog(stdout.Bytes())
80+ if err != nil {
81+ slog.Debug("remote catalog decode failed", "target", target, "err", err)
82+ return ports.RemoteCatalog{}, err
83+ }
84+ return catalog, nil
85+}
86+
87+type catalogEnvelope struct {
88+ ProtocolVersion *uint16 `json:"protocol_version"`
89+ Sessions *[]ports.RemoteCatalogSession `json:"sessions"`
90+}
91+
92+func decodeRemoteCatalog(raw []byte) (ports.RemoteCatalog, error) {
93+ dec := json.NewDecoder(bytes.NewReader(raw))
94+ var envelope catalogEnvelope
95+ if err := dec.Decode(&envelope); err != nil {
96+ return ports.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogDecode, err)
97+ }
98+ if envelope.ProtocolVersion == nil || envelope.Sessions == nil {
99+ return ports.RemoteCatalog{}, errCatalogRequired
100+ }
101+ if err := rejectTrailingJSON(dec); err != nil {
102+ return ports.RemoteCatalog{}, err
103+ }
104+
105+ if *envelope.ProtocolVersion != ports.ProtocolVersion {
106+ return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{
107+ Got: *envelope.ProtocolVersion,
108+ Want: ports.ProtocolVersion,
109+ }
110+ }
111+
112+ sessions := *envelope.Sessions
113+ if sessions == nil {
114+ sessions = []ports.RemoteCatalogSession{}
115+ }
116+ for _, session := range sessions {
117+ if err := domain.ValidateSessionName(session.Name); err != nil {
118+ return ports.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogSession, err)
119+ }
120+ if !validCatalogState(session.State) {
121+ return ports.RemoteCatalog{}, fmt.Errorf("%w: invalid state %q", errCatalogSession, session.State)
122+ }
123+ }
124+
125+ return ports.RemoteCatalog{
126+ ProtocolVersion: *envelope.ProtocolVersion,
127+ Sessions: sessions,
128+ }, nil
129+}
130+
131+func rejectTrailingJSON(dec *json.Decoder) error {
132+ var extra json.RawMessage
133+ err := dec.Decode(&extra)
134+ if errors.Is(err, io.EOF) {
135+ return nil
136+ }
137+ if err == nil {
138+ return errCatalogTrailing
139+ }
140+ return fmt.Errorf("%w: %w", errCatalogTrailing, err)
141+}
142+
143+func validCatalogState(state string) bool {
144+ switch state {
145+ case "running", "stopped", "broken":
146+ return true
147+ default:
148+ return false
149+ }
150+}
151+
152+// boundedBuffer captures stdout up to maxCatalogBytes and records overflow.
153+type boundedBuffer struct {
154+ buf bytes.Buffer
155+ overflow bool
156+}
157+
158+func (b *boundedBuffer) Write(p []byte) (int, error) {
159+ if b.overflow {
160+ return len(p), nil
161+ }
162+ remaining := maxCatalogBytes - b.buf.Len()
163+ if remaining <= 0 {
164+ b.overflow = true
165+ return len(p), nil
166+ }
167+ if len(p) <= remaining {
168+ return b.buf.Write(p)
169+ }
170+ n, err := b.buf.Write(p[:remaining])
171+ b.overflow = true
172+ if err != nil {
173+ return n, err
174+ }
175+ return len(p), nil
176+}
177+
178+func (b *boundedBuffer) Bytes() []byte {
179+ return b.buf.Bytes()
180+}
@@ -0,0 +1,180 @@
1+package remote
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "log/slog"
11+ "os/exec"
12+ "strings"
13+
14+ "github.com/bnema/vev/internal/adapters/sshstdio"
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/ports"
17+)
18+
19+const maxCatalogBytes = 1 << 20
20+
21+var (
22+ errCatalogDecode = errors.New("remote catalog: invalid JSON")
23+ errCatalogTrailing = errors.New("remote catalog: trailing bytes after JSON")
24+ errCatalogRequired = errors.New("remote catalog: missing required fields")
25+ errCatalogSession = errors.New("remote catalog: invalid session")
26+ errCatalogSSH = errors.New("remote catalog: ssh command failed")
27+ errCatalogTooLarge = errors.New("remote catalog: output exceeds size limit")
28+)
29+
30+// CatalogClient fetches remote session catalogs over SSH.
31+type CatalogClient struct {
32+ command func(ctx context.Context, name string, args ...string) *exec.Cmd
33+}
34+
35+var _ ports.RemoteCatalogClient = (*CatalogClient)(nil)
36+
37+// NewCatalogClient returns a RemoteCatalogClient that shells out to ssh.
38+func NewCatalogClient() ports.RemoteCatalogClient {
39+ return &CatalogClient{command: exec.CommandContext}
40+}
41+
42+// List runs `ssh -- <target> 'vev' 'cmd' 'remote-catalog' '--json'` and decodes
43+// exactly one versioned catalog envelope from stdout.
44+func (c *CatalogClient) List(ctx context.Context, target string) (ports.RemoteCatalog, error) {
45+ if err := ctx.Err(); err != nil {
46+ return ports.RemoteCatalog{}, err
47+ }
48+ if err := domain.ValidateRemoteHostTarget(target); err != nil {
49+ slog.Debug("remote catalog rejected target", "target", target, "err", err)
50+ return ports.RemoteCatalog{}, err
51+ }
52+
53+ command := c.command
54+ if command == nil {
55+ command = exec.CommandContext
56+ }
57+ spec := sshstdio.BuildCommandForRemoteCommand(target, "vev", "cmd", "remote-catalog", "--json")
58+ cmd := command(ctx, spec.Path, spec.Args...)
59+ var stdout boundedBuffer
60+ var stderr bytes.Buffer
61+ cmd.Stdout = &stdout
62+ cmd.Stderr = &stderr
63+ if err := cmd.Run(); err != nil {
64+ if ctxErr := ctx.Err(); ctxErr != nil {
65+ return ports.RemoteCatalog{}, ctxErr
66+ }
67+ stderrText := strings.TrimSpace(stderr.String())
68+ slog.Debug("remote catalog ssh failed", "target", target, "err", err, "stderr", stderrText)
69+ if stderrText != "" {
70+ return ports.RemoteCatalog{}, fmt.Errorf("%w: %w: %s", errCatalogSSH, err, stderrText)
71+ }
72+ return ports.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogSSH, err)
73+ }
74+ if stdout.overflow {
75+ slog.Debug("remote catalog output too large", "target", target, "limit", maxCatalogBytes)
76+ return ports.RemoteCatalog{}, errCatalogTooLarge
77+ }
78+
79+ catalog, err := decodeRemoteCatalog(stdout.Bytes())
80+ if err != nil {
81+ slog.Debug("remote catalog decode failed", "target", target, "err", err)
82+ return ports.RemoteCatalog{}, err
83+ }
84+ return catalog, nil
85+}
86+
87+type catalogEnvelope struct {
88+ ProtocolVersion *uint16 `json:"protocol_version"`
89+ Sessions *[]ports.RemoteCatalogSession `json:"sessions"`
90+}
91+
92+func decodeRemoteCatalog(raw []byte) (ports.RemoteCatalog, error) {
93+ dec := json.NewDecoder(bytes.NewReader(raw))
94+ var envelope catalogEnvelope
95+ if err := dec.Decode(&envelope); err != nil {
96+ return ports.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogDecode, err)
97+ }
98+ if envelope.ProtocolVersion == nil || envelope.Sessions == nil {
99+ return ports.RemoteCatalog{}, errCatalogRequired
100+ }
101+ if err := rejectTrailingJSON(dec); err != nil {
102+ return ports.RemoteCatalog{}, err
103+ }
104+
105+ if *envelope.ProtocolVersion != ports.ProtocolVersion {
106+ return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{
107+ Got: *envelope.ProtocolVersion,
108+ Want: ports.ProtocolVersion,
109+ }
110+ }
111+
112+ sessions := *envelope.Sessions
113+ if sessions == nil {
114+ sessions = []ports.RemoteCatalogSession{}
115+ }
116+ for _, session := range sessions {
117+ if err := domain.ValidateSessionName(session.Name); err != nil {
118+ return ports.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogSession, err)
119+ }
120+ if !validCatalogState(session.State) {
121+ return ports.RemoteCatalog{}, fmt.Errorf("%w: invalid state %q", errCatalogSession, session.State)
122+ }
123+ }
124+
125+ return ports.RemoteCatalog{
126+ ProtocolVersion: *envelope.ProtocolVersion,
127+ Sessions: sessions,
128+ }, nil
129+}
130+
131+func rejectTrailingJSON(dec *json.Decoder) error {
132+ var extra json.RawMessage
133+ err := dec.Decode(&extra)
134+ if errors.Is(err, io.EOF) {
135+ return nil
136+ }
137+ if err == nil {
138+ return errCatalogTrailing
139+ }
140+ return fmt.Errorf("%w: %w", errCatalogTrailing, err)
141+}
142+
143+func validCatalogState(state string) bool {
144+ switch state {
145+ case "running", "stopped", "broken":
146+ return true
147+ default:
148+ return false
149+ }
150+}
151+
152+// boundedBuffer captures stdout up to maxCatalogBytes and records overflow.
153+type boundedBuffer struct {
154+ buf bytes.Buffer
155+ overflow bool
156+}
157+
158+func (b *boundedBuffer) Write(p []byte) (int, error) {
159+ if b.overflow {
160+ return len(p), nil
161+ }
162+ remaining := maxCatalogBytes - b.buf.Len()
163+ if remaining <= 0 {
164+ b.overflow = true
165+ return len(p), nil
166+ }
167+ if len(p) <= remaining {
168+ return b.buf.Write(p)
169+ }
170+ n, err := b.buf.Write(p[:remaining])
171+ b.overflow = true
172+ if err != nil {
173+ return n, err
174+ }
175+ return len(p), nil
176+}
177+
178+func (b *boundedBuffer) Bytes() []byte {
179+ return b.buf.Bytes()
180+}
A internal/adapters/remote/catalog_test.go +236 -0
@@ -0,0 +1,236 @@
1+package remote
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "os/exec"
9+ "strings"
10+ "testing"
11+
12+ "github.com/bnema/vev/internal/adapters/sshstdio"
13+ "github.com/bnema/vev/internal/ports"
14+)
15+
16+func TestRemoteCatalogClientCommandConstruction(t *testing.T) {
17+ t.Parallel()
18+
19+ tests := []struct {
20+ name string
21+ target string
22+ }{
23+ {name: "plain host", target: "arch"},
24+ {name: "metacharacters stay single local arg", target: "user@host;touch/tmp/pwn"},
25+ {name: "user at host", target: "build@mule"},
26+ }
27+
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ t.Parallel()
31+ var gotPath string
32+ var gotArgs []string
33+ client := &CatalogClient{
34+ command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
35+ gotPath = name
36+ gotArgs = append([]string(nil), args...)
37+ return stdoutCmd(ctx, mustCatalogJSON(t, ports.RemoteCatalog{
38+ ProtocolVersion: ports.ProtocolVersion,
39+ Sessions: []ports.RemoteCatalogSession{},
40+ }))
41+ },
42+ }
43+
44+ if _, err := client.List(context.Background(), tt.target); err != nil {
45+ t.Fatalf("List() error = %v", err)
46+ }
47+
48+ want := sshstdio.BuildCommandForRemoteCommand(tt.target, "vev", "cmd", "remote-catalog", "--json")
49+ if gotPath != want.Path {
50+ t.Fatalf("Path = %q, want %q", gotPath, want.Path)
51+ }
52+ if len(gotArgs) != len(want.Args) {
53+ t.Fatalf("Args len = %d, want %d (%q)", len(gotArgs), len(want.Args), gotArgs)
54+ }
55+ for i := range want.Args {
56+ if gotArgs[i] != want.Args[i] {
57+ t.Fatalf("Args[%d] = %q, want %q (all %q)", i, gotArgs[i], want.Args[i], gotArgs)
58+ }
59+ }
60+ if gotArgs[0] != "--" || gotArgs[1] != tt.target {
61+ t.Fatalf("target must remain one unquoted local argv word after --; got %q", gotArgs)
62+ }
63+ if !strings.Contains(gotArgs[2], "'vev'") || !strings.Contains(gotArgs[2], "'remote-catalog'") {
64+ t.Fatalf("remote command words must be shell-quoted; got %q", gotArgs[2])
65+ }
66+ })
67+ }
68+}
69+
70+func TestRemoteCatalogClientRejectsUnsafeTarget(t *testing.T) {
71+ t.Parallel()
72+ client := NewCatalogClient()
73+ _, err := client.List(context.Background(), "bad host")
74+ if err == nil {
75+ t.Fatal("List() error = nil, want target validation error")
76+ }
77+}
78+
79+func TestRemoteCatalogClientDecode(t *testing.T) {
80+ t.Parallel()
81+
82+ valid := ports.RemoteCatalog{
83+ ProtocolVersion: ports.ProtocolVersion,
84+ Sessions: []ports.RemoteCatalogSession{
85+ {Name: "work", State: "running", Ephemeral: false, Tabs: 2, Attached: true},
86+ },
87+ }
88+ validJSON := mustCatalogJSON(t, valid)
89+
90+ tests := []struct {
91+ name string
92+ stdout string
93+ stderr string
94+ exit int
95+ want ports.RemoteCatalog
96+ wantErr error
97+ check func(t *testing.T, err error)
98+ }{
99+ {
100+ name: "matching protocol version",
101+ stdout: validJSON,
102+ want: valid,
103+ },
104+ {
105+ name: "trailing whitespace accepted",
106+ stdout: validJSON + " \n\t",
107+ want: valid,
108+ },
109+ {
110+ name: "mismatching protocol version",
111+ stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion + 1, Sessions: []ports.RemoteCatalogSession{}}),
112+ check: func(t *testing.T, err error) {
113+ t.Helper()
114+ var mismatch *ports.RemoteCatalogVersionMismatchError
115+ if !errors.As(err, &mismatch) {
116+ t.Fatalf("error = %v, want RemoteCatalogVersionMismatchError", err)
117+ }
118+ if mismatch.Got != ports.ProtocolVersion+1 || mismatch.Want != ports.ProtocolVersion {
119+ t.Fatalf("mismatch = %#v", mismatch)
120+ }
121+ },
122+ },
123+ {
124+ name: "malformed json",
125+ stdout: `{"protocol_version":`,
126+ wantErr: errCatalogDecode,
127+ },
128+ {
129+ name: "truncated json",
130+ stdout: `{"protocol_version":19,"sessions":[`,
131+ wantErr: errCatalogDecode,
132+ },
133+ {
134+ name: "trailing non-whitespace",
135+ stdout: strings.TrimSpace(validJSON) + `{"extra":true}`,
136+ wantErr: errCatalogTrailing,
137+ },
138+ {
139+ name: "missing protocol_version",
140+ stdout: `{"sessions":[]}` + "\n",
141+ wantErr: errCatalogRequired,
142+ },
143+ {
144+ name: "missing sessions",
145+ stdout: fmt.Sprintf(`{"protocol_version":%d}`+"\n", ports.ProtocolVersion),
146+ wantErr: errCatalogRequired,
147+ },
148+ {
149+ name: "invalid session name",
150+ stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "bad name", State: "running"}}}),
151+ wantErr: errCatalogSession,
152+ },
153+ {
154+ name: "invalid session state",
155+ stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "weird"}}}),
156+ wantErr: errCatalogSession,
157+ },
158+ {
159+ name: "remote exit with stderr",
160+ stdout: "",
161+ stderr: "connection refused",
162+ exit: 255,
163+ wantErr: errCatalogSSH,
164+ },
165+ }
166+
167+ for _, tt := range tests {
168+ t.Run(tt.name, func(t *testing.T) {
169+ t.Parallel()
170+ client := &CatalogClient{
171+ command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
172+ if tt.exit != 0 {
173+ cmd := exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("cat >&2; exit %d", tt.exit))
174+ cmd.Stdin = strings.NewReader(tt.stderr)
175+ return cmd
176+ }
177+ return stdoutCmd(ctx, tt.stdout)
178+ },
179+ }
180+ got, err := client.List(context.Background(), "arch")
181+ if tt.check != nil {
182+ if err == nil {
183+ t.Fatal("List() error = nil, want error")
184+ }
185+ tt.check(t, err)
186+ return
187+ }
188+ if tt.wantErr != nil {
189+ if !errors.Is(err, tt.wantErr) {
190+ t.Fatalf("List() error = %v, want %v", err, tt.wantErr)
191+ }
192+ return
193+ }
194+ if err != nil {
195+ t.Fatalf("List() error = %v", err)
196+ }
197+ if got.ProtocolVersion != tt.want.ProtocolVersion {
198+ t.Fatalf("ProtocolVersion = %d, want %d", got.ProtocolVersion, tt.want.ProtocolVersion)
199+ }
200+ if len(got.Sessions) != len(tt.want.Sessions) {
201+ t.Fatalf("Sessions len = %d, want %d", len(got.Sessions), len(tt.want.Sessions))
202+ }
203+ for i := range tt.want.Sessions {
204+ if got.Sessions[i] != tt.want.Sessions[i] {
205+ t.Fatalf("Sessions[%d] = %#v, want %#v", i, got.Sessions[i], tt.want.Sessions[i])
206+ }
207+ }
208+ })
209+ }
210+}
211+
212+func TestRemoteCatalogClientContextCanceled(t *testing.T) {
213+ t.Parallel()
214+ ctx, cancel := context.WithCancel(context.Background())
215+ cancel()
216+ client := NewCatalogClient()
217+ _, err := client.List(ctx, "arch")
218+ if !errors.Is(err, context.Canceled) {
219+ t.Fatalf("List() error = %v, want context.Canceled", err)
220+ }
221+}
222+
223+func stdoutCmd(ctx context.Context, stdout string) *exec.Cmd {
224+ cmd := exec.CommandContext(ctx, "cat")
225+ cmd.Stdin = strings.NewReader(stdout)
226+ return cmd
227+}
228+
229+func mustCatalogJSON(t *testing.T, catalog ports.RemoteCatalog) string {
230+ t.Helper()
231+ raw, err := json.Marshal(catalog)
232+ if err != nil {
233+ t.Fatalf("Marshal: %v", err)
234+ }
235+ return string(raw) + "\n"
236+}
@@ -0,0 +1,236 @@
1+package remote
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "os/exec"
9+ "strings"
10+ "testing"
11+
12+ "github.com/bnema/vev/internal/adapters/sshstdio"
13+ "github.com/bnema/vev/internal/ports"
14+)
15+
16+func TestRemoteCatalogClientCommandConstruction(t *testing.T) {
17+ t.Parallel()
18+
19+ tests := []struct {
20+ name string
21+ target string
22+ }{
23+ {name: "plain host", target: "arch"},
24+ {name: "metacharacters stay single local arg", target: "user@host;touch/tmp/pwn"},
25+ {name: "user at host", target: "build@mule"},
26+ }
27+
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ t.Parallel()
31+ var gotPath string
32+ var gotArgs []string
33+ client := &CatalogClient{
34+ command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
35+ gotPath = name
36+ gotArgs = append([]string(nil), args...)
37+ return stdoutCmd(ctx, mustCatalogJSON(t, ports.RemoteCatalog{
38+ ProtocolVersion: ports.ProtocolVersion,
39+ Sessions: []ports.RemoteCatalogSession{},
40+ }))
41+ },
42+ }
43+
44+ if _, err := client.List(context.Background(), tt.target); err != nil {
45+ t.Fatalf("List() error = %v", err)
46+ }
47+
48+ want := sshstdio.BuildCommandForRemoteCommand(tt.target, "vev", "cmd", "remote-catalog", "--json")
49+ if gotPath != want.Path {
50+ t.Fatalf("Path = %q, want %q", gotPath, want.Path)
51+ }
52+ if len(gotArgs) != len(want.Args) {
53+ t.Fatalf("Args len = %d, want %d (%q)", len(gotArgs), len(want.Args), gotArgs)
54+ }
55+ for i := range want.Args {
56+ if gotArgs[i] != want.Args[i] {
57+ t.Fatalf("Args[%d] = %q, want %q (all %q)", i, gotArgs[i], want.Args[i], gotArgs)
58+ }
59+ }
60+ if gotArgs[0] != "--" || gotArgs[1] != tt.target {
61+ t.Fatalf("target must remain one unquoted local argv word after --; got %q", gotArgs)
62+ }
63+ if !strings.Contains(gotArgs[2], "'vev'") || !strings.Contains(gotArgs[2], "'remote-catalog'") {
64+ t.Fatalf("remote command words must be shell-quoted; got %q", gotArgs[2])
65+ }
66+ })
67+ }
68+}
69+
70+func TestRemoteCatalogClientRejectsUnsafeTarget(t *testing.T) {
71+ t.Parallel()
72+ client := NewCatalogClient()
73+ _, err := client.List(context.Background(), "bad host")
74+ if err == nil {
75+ t.Fatal("List() error = nil, want target validation error")
76+ }
77+}
78+
79+func TestRemoteCatalogClientDecode(t *testing.T) {
80+ t.Parallel()
81+
82+ valid := ports.RemoteCatalog{
83+ ProtocolVersion: ports.ProtocolVersion,
84+ Sessions: []ports.RemoteCatalogSession{
85+ {Name: "work", State: "running", Ephemeral: false, Tabs: 2, Attached: true},
86+ },
87+ }
88+ validJSON := mustCatalogJSON(t, valid)
89+
90+ tests := []struct {
91+ name string
92+ stdout string
93+ stderr string
94+ exit int
95+ want ports.RemoteCatalog
96+ wantErr error
97+ check func(t *testing.T, err error)
98+ }{
99+ {
100+ name: "matching protocol version",
101+ stdout: validJSON,
102+ want: valid,
103+ },
104+ {
105+ name: "trailing whitespace accepted",
106+ stdout: validJSON + " \n\t",
107+ want: valid,
108+ },
109+ {
110+ name: "mismatching protocol version",
111+ stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion + 1, Sessions: []ports.RemoteCatalogSession{}}),
112+ check: func(t *testing.T, err error) {
113+ t.Helper()
114+ var mismatch *ports.RemoteCatalogVersionMismatchError
115+ if !errors.As(err, &mismatch) {
116+ t.Fatalf("error = %v, want RemoteCatalogVersionMismatchError", err)
117+ }
118+ if mismatch.Got != ports.ProtocolVersion+1 || mismatch.Want != ports.ProtocolVersion {
119+ t.Fatalf("mismatch = %#v", mismatch)
120+ }
121+ },
122+ },
123+ {
124+ name: "malformed json",
125+ stdout: `{"protocol_version":`,
126+ wantErr: errCatalogDecode,
127+ },
128+ {
129+ name: "truncated json",
130+ stdout: `{"protocol_version":19,"sessions":[`,
131+ wantErr: errCatalogDecode,
132+ },
133+ {
134+ name: "trailing non-whitespace",
135+ stdout: strings.TrimSpace(validJSON) + `{"extra":true}`,
136+ wantErr: errCatalogTrailing,
137+ },
138+ {
139+ name: "missing protocol_version",
140+ stdout: `{"sessions":[]}` + "\n",
141+ wantErr: errCatalogRequired,
142+ },
143+ {
144+ name: "missing sessions",
145+ stdout: fmt.Sprintf(`{"protocol_version":%d}`+"\n", ports.ProtocolVersion),
146+ wantErr: errCatalogRequired,
147+ },
148+ {
149+ name: "invalid session name",
150+ stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "bad name", State: "running"}}}),
151+ wantErr: errCatalogSession,
152+ },
153+ {
154+ name: "invalid session state",
155+ stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "weird"}}}),
156+ wantErr: errCatalogSession,
157+ },
158+ {
159+ name: "remote exit with stderr",
160+ stdout: "",
161+ stderr: "connection refused",
162+ exit: 255,
163+ wantErr: errCatalogSSH,
164+ },
165+ }
166+
167+ for _, tt := range tests {
168+ t.Run(tt.name, func(t *testing.T) {
169+ t.Parallel()
170+ client := &CatalogClient{
171+ command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
172+ if tt.exit != 0 {
173+ cmd := exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("cat >&2; exit %d", tt.exit))
174+ cmd.Stdin = strings.NewReader(tt.stderr)
175+ return cmd
176+ }
177+ return stdoutCmd(ctx, tt.stdout)
178+ },
179+ }
180+ got, err := client.List(context.Background(), "arch")
181+ if tt.check != nil {
182+ if err == nil {
183+ t.Fatal("List() error = nil, want error")
184+ }
185+ tt.check(t, err)
186+ return
187+ }
188+ if tt.wantErr != nil {
189+ if !errors.Is(err, tt.wantErr) {
190+ t.Fatalf("List() error = %v, want %v", err, tt.wantErr)
191+ }
192+ return
193+ }
194+ if err != nil {
195+ t.Fatalf("List() error = %v", err)
196+ }
197+ if got.ProtocolVersion != tt.want.ProtocolVersion {
198+ t.Fatalf("ProtocolVersion = %d, want %d", got.ProtocolVersion, tt.want.ProtocolVersion)
199+ }
200+ if len(got.Sessions) != len(tt.want.Sessions) {
201+ t.Fatalf("Sessions len = %d, want %d", len(got.Sessions), len(tt.want.Sessions))
202+ }
203+ for i := range tt.want.Sessions {
204+ if got.Sessions[i] != tt.want.Sessions[i] {
205+ t.Fatalf("Sessions[%d] = %#v, want %#v", i, got.Sessions[i], tt.want.Sessions[i])
206+ }
207+ }
208+ })
209+ }
210+}
211+
212+func TestRemoteCatalogClientContextCanceled(t *testing.T) {
213+ t.Parallel()
214+ ctx, cancel := context.WithCancel(context.Background())
215+ cancel()
216+ client := NewCatalogClient()
217+ _, err := client.List(ctx, "arch")
218+ if !errors.Is(err, context.Canceled) {
219+ t.Fatalf("List() error = %v, want context.Canceled", err)
220+ }
221+}
222+
223+func stdoutCmd(ctx context.Context, stdout string) *exec.Cmd {
224+ cmd := exec.CommandContext(ctx, "cat")
225+ cmd.Stdin = strings.NewReader(stdout)
226+ return cmd
227+}
228+
229+func mustCatalogJSON(t *testing.T, catalog ports.RemoteCatalog) string {
230+ t.Helper()
231+ raw, err := json.Marshal(catalog)
232+ if err != nil {
233+ t.Fatalf("Marshal: %v", err)
234+ }
235+ return string(raw) + "\n"
236+}
M internal/adapters/sshstdio/transport.go +13 -2
@@ -301,9 +301,20 @@ func BuildCommand(target, session string) CommandSpec {
301301 // BuildCommandForMode constructs the local ssh subprocess argv for a hidden vev
302302 // remote mode such as _stdio or _udp-bootstrap.
303303 func BuildCommandForMode(target, mode, session string) CommandSpec {
304- remote := []string{shellQuote("vev"), shellQuote(mode)}
304+ remote := []string{"vev", mode}
305305 if session != "" {
306- remote = append(remote, shellQuote(session))
306+ remote = append(remote, session)
307+ }
308+ return BuildCommandForRemoteCommand(target, remote...)
309+}
310+
311+// BuildCommandForRemoteCommand constructs ssh argv for an arbitrary remote
312+// command. Every remote word is POSIX single-quoted; the target remains one
313+// local argv word after the option terminator.
314+func BuildCommandForRemoteCommand(target string, command ...string) CommandSpec {
315+ remote := make([]string, 0, len(command))
316+ for _, word := range command {
317+ remote = append(remote, shellQuote(word))
307318 }
308319 args := []string{"--", target, strings.Join(remote, " ")}
309320 return CommandSpec{Path: "ssh", Args: args}
@@ -301,9 +301,20 @@ func BuildCommand(target, session string) CommandSpec {
301 // BuildCommandForMode constructs the local ssh subprocess argv for a hidden vev301 // BuildCommandForMode constructs the local ssh subprocess argv for a hidden vev
302 // remote mode such as _stdio or _udp-bootstrap.302 // remote mode such as _stdio or _udp-bootstrap.
303 func BuildCommandForMode(target, mode, session string) CommandSpec {303 func BuildCommandForMode(target, mode, session string) CommandSpec {
304- remote := []string{shellQuote("vev"), shellQuote(mode)}304+ remote := []string{"vev", mode}
305 if session != "" {305 if session != "" {
306- remote = append(remote, shellQuote(session))306+ remote = append(remote, session)
307+ }
308+ return BuildCommandForRemoteCommand(target, remote...)
309+}
310+
311+// BuildCommandForRemoteCommand constructs ssh argv for an arbitrary remote
312+// command. Every remote word is POSIX single-quoted; the target remains one
313+// local argv word after the option terminator.
314+func BuildCommandForRemoteCommand(target string, command ...string) CommandSpec {
315+ remote := make([]string, 0, len(command))
316+ for _, word := range command {
317+ remote = append(remote, shellQuote(word))
307 }318 }
308 args := []string{"--", target, strings.Join(remote, " ")}319 args := []string{"--", target, strings.Join(remote, " ")}
309 return CommandSpec{Path: "ssh", Args: args}320 return CommandSpec{Path: "ssh", Args: args}
M internal/adapters/sshstdio/transport_test.go +45 -0
@@ -60,6 +60,51 @@ func TestBuildCommandForModeUsesCanonicalSSHArgs(t *testing.T) {
6060 }
6161 }
6262
63+func TestBuildCommandForRemoteCommandQuotesEveryWord(t *testing.T) {
64+ tests := []struct {
65+ name string
66+ target string
67+ command []string
68+ want []string
69+ }{
70+ {
71+ name: "catalog command",
72+ target: "arch",
73+ command: []string{"vev", "cmd", "remote-catalog", "--json"},
74+ want: []string{"--", "arch", "'vev' 'cmd' 'remote-catalog' '--json'"},
75+ },
76+ {
77+ name: "metacharacters in remote words are quoted",
78+ target: "user@host; touch /tmp/pwn",
79+ command: []string{"vev", "cmd", "remote-catalog; rm -rf /", "--json"},
80+ want: []string{"--", "user@host; touch /tmp/pwn", "'vev' 'cmd' 'remote-catalog; rm -rf /' '--json'"},
81+ },
82+ {
83+ name: "single quotes are posix escaped",
84+ target: "arch",
85+ command: []string{"it's", "fine"},
86+ want: []string{"--", "arch", "'it'\\''s' 'fine'"},
87+ },
88+ }
89+
90+ for _, tt := range tests {
91+ t.Run(tt.name, func(t *testing.T) {
92+ got := BuildCommandForRemoteCommand(tt.target, tt.command...)
93+ if got.Path != "ssh" {
94+ t.Fatalf("Path = %q, want ssh", got.Path)
95+ }
96+ if len(got.Args) != len(tt.want) {
97+ t.Fatalf("Args len = %d, want %d (%q)", len(got.Args), len(tt.want), got.Args)
98+ }
99+ for i := range tt.want {
100+ if got.Args[i] != tt.want[i] {
101+ t.Fatalf("Args[%d] = %q, want %q (all args %q)", i, got.Args[i], tt.want[i], got.Args)
102+ }
103+ }
104+ })
105+ }
106+}
107+
63108 func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) {
64109 clientRead, serverWrite := io.Pipe()
65110 serverRead, clientWrite := io.Pipe()
@@ -60,6 +60,51 @@ func TestBuildCommandForModeUsesCanonicalSSHArgs(t *testing.T) {
60 }60 }
61 }61 }
62 62
63+func TestBuildCommandForRemoteCommandQuotesEveryWord(t *testing.T) {
64+ tests := []struct {
65+ name string
66+ target string
67+ command []string
68+ want []string
69+ }{
70+ {
71+ name: "catalog command",
72+ target: "arch",
73+ command: []string{"vev", "cmd", "remote-catalog", "--json"},
74+ want: []string{"--", "arch", "'vev' 'cmd' 'remote-catalog' '--json'"},
75+ },
76+ {
77+ name: "metacharacters in remote words are quoted",
78+ target: "user@host; touch /tmp/pwn",
79+ command: []string{"vev", "cmd", "remote-catalog; rm -rf /", "--json"},
80+ want: []string{"--", "user@host; touch /tmp/pwn", "'vev' 'cmd' 'remote-catalog; rm -rf /' '--json'"},
81+ },
82+ {
83+ name: "single quotes are posix escaped",
84+ target: "arch",
85+ command: []string{"it's", "fine"},
86+ want: []string{"--", "arch", "'it'\\''s' 'fine'"},
87+ },
88+ }
89+
90+ for _, tt := range tests {
91+ t.Run(tt.name, func(t *testing.T) {
92+ got := BuildCommandForRemoteCommand(tt.target, tt.command...)
93+ if got.Path != "ssh" {
94+ t.Fatalf("Path = %q, want ssh", got.Path)
95+ }
96+ if len(got.Args) != len(tt.want) {
97+ t.Fatalf("Args len = %d, want %d (%q)", len(got.Args), len(tt.want), got.Args)
98+ }
99+ for i := range tt.want {
100+ if got.Args[i] != tt.want[i] {
101+ t.Fatalf("Args[%d] = %q, want %q (all args %q)", i, got.Args[i], tt.want[i], got.Args)
102+ }
103+ }
104+ })
105+ }
106+}
107+
63 func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) {108 func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) {
64 clientRead, serverWrite := io.Pipe()109 clientRead, serverWrite := io.Pipe()
65 serverRead, clientWrite := io.Pipe()110 serverRead, clientWrite := io.Pipe()
M internal/ports/mocks/remote_mock.go +94 -0
@@ -5,6 +5,7 @@
55 package portsmocks
66
77 import (
8+ "context"
89 "log/slog"
910
1011 "github.com/bnema/vev/internal/ports"
@@ -462,3 +463,96 @@ func (_c *MockRemoteHostStore_RemovePinned_Call) RunAndReturn(run func(target st
462463 _c.Call.Return(run)
463464 return _c
464465 }
466+
467+// NewMockRemoteCatalogClient creates a new instance of MockRemoteCatalogClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
468+// The first argument is typically a *testing.T value.
469+func NewMockRemoteCatalogClient(t interface {
470+ mock.TestingT
471+ Cleanup(func())
472+}) *MockRemoteCatalogClient {
473+ mock := &MockRemoteCatalogClient{}
474+ mock.Mock.Test(t)
475+
476+ t.Cleanup(func() { mock.AssertExpectations(t) })
477+
478+ return mock
479+}
480+
481+// MockRemoteCatalogClient is an autogenerated mock type for the RemoteCatalogClient type
482+type MockRemoteCatalogClient struct {
483+ mock.Mock
484+}
485+
486+type MockRemoteCatalogClient_Expecter struct {
487+ mock *mock.Mock
488+}
489+
490+func (_m *MockRemoteCatalogClient) EXPECT() *MockRemoteCatalogClient_Expecter {
491+ return &MockRemoteCatalogClient_Expecter{mock: &_m.Mock}
492+}
493+
494+// List provides a mock function for the type MockRemoteCatalogClient
495+func (_mock *MockRemoteCatalogClient) List(ctx context.Context, target string) (ports.RemoteCatalog, error) {
496+ ret := _mock.Called(ctx, target)
497+
498+ if len(ret) == 0 {
499+ panic("no return value specified for List")
500+ }
501+
502+ var r0 ports.RemoteCatalog
503+ var r1 error
504+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.RemoteCatalog, error)); ok {
505+ return returnFunc(ctx, target)
506+ }
507+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.RemoteCatalog); ok {
508+ r0 = returnFunc(ctx, target)
509+ } else {
510+ r0 = ret.Get(0).(ports.RemoteCatalog)
511+ }
512+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
513+ r1 = returnFunc(ctx, target)
514+ } else {
515+ r1 = ret.Error(1)
516+ }
517+ return r0, r1
518+}
519+
520+// MockRemoteCatalogClient_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
521+type MockRemoteCatalogClient_List_Call struct {
522+ *mock.Call
523+}
524+
525+// List is a helper method to define mock.On call
526+// - ctx context.Context
527+// - target string
528+func (_e *MockRemoteCatalogClient_Expecter) List(ctx any, target any) *MockRemoteCatalogClient_List_Call {
529+ return &MockRemoteCatalogClient_List_Call{Call: _e.mock.On("List", ctx, target)}
530+}
531+
532+func (_c *MockRemoteCatalogClient_List_Call) Run(run func(ctx context.Context, target string)) *MockRemoteCatalogClient_List_Call {
533+ _c.Call.Run(func(args mock.Arguments) {
534+ var arg0 context.Context
535+ if args[0] != nil {
536+ arg0 = args[0].(context.Context)
537+ }
538+ var arg1 string
539+ if args[1] != nil {
540+ arg1 = args[1].(string)
541+ }
542+ run(
543+ arg0,
544+ arg1,
545+ )
546+ })
547+ return _c
548+}
549+
550+func (_c *MockRemoteCatalogClient_List_Call) Return(remoteCatalog ports.RemoteCatalog, err error) *MockRemoteCatalogClient_List_Call {
551+ _c.Call.Return(remoteCatalog, err)
552+ return _c
553+}
554+
555+func (_c *MockRemoteCatalogClient_List_Call) RunAndReturn(run func(ctx context.Context, target string) (ports.RemoteCatalog, error)) *MockRemoteCatalogClient_List_Call {
556+ _c.Call.Return(run)
557+ return _c
558+}
@@ -5,6 +5,7 @@
5 package portsmocks5 package portsmocks
6 6
7 import (7 import (
8+ "context"
8 "log/slog"9 "log/slog"
9 10
10 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
@@ -462,3 +463,96 @@ func (_c *MockRemoteHostStore_RemovePinned_Call) RunAndReturn(run func(target st
462 _c.Call.Return(run)463 _c.Call.Return(run)
463 return _c464 return _c
464 }465 }
466+
467+// NewMockRemoteCatalogClient creates a new instance of MockRemoteCatalogClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
468+// The first argument is typically a *testing.T value.
469+func NewMockRemoteCatalogClient(t interface {
470+ mock.TestingT
471+ Cleanup(func())
472+}) *MockRemoteCatalogClient {
473+ mock := &MockRemoteCatalogClient{}
474+ mock.Mock.Test(t)
475+
476+ t.Cleanup(func() { mock.AssertExpectations(t) })
477+
478+ return mock
479+}
480+
481+// MockRemoteCatalogClient is an autogenerated mock type for the RemoteCatalogClient type
482+type MockRemoteCatalogClient struct {
483+ mock.Mock
484+}
485+
486+type MockRemoteCatalogClient_Expecter struct {
487+ mock *mock.Mock
488+}
489+
490+func (_m *MockRemoteCatalogClient) EXPECT() *MockRemoteCatalogClient_Expecter {
491+ return &MockRemoteCatalogClient_Expecter{mock: &_m.Mock}
492+}
493+
494+// List provides a mock function for the type MockRemoteCatalogClient
495+func (_mock *MockRemoteCatalogClient) List(ctx context.Context, target string) (ports.RemoteCatalog, error) {
496+ ret := _mock.Called(ctx, target)
497+
498+ if len(ret) == 0 {
499+ panic("no return value specified for List")
500+ }
501+
502+ var r0 ports.RemoteCatalog
503+ var r1 error
504+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.RemoteCatalog, error)); ok {
505+ return returnFunc(ctx, target)
506+ }
507+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.RemoteCatalog); ok {
508+ r0 = returnFunc(ctx, target)
509+ } else {
510+ r0 = ret.Get(0).(ports.RemoteCatalog)
511+ }
512+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
513+ r1 = returnFunc(ctx, target)
514+ } else {
515+ r1 = ret.Error(1)
516+ }
517+ return r0, r1
518+}
519+
520+// MockRemoteCatalogClient_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
521+type MockRemoteCatalogClient_List_Call struct {
522+ *mock.Call
523+}
524+
525+// List is a helper method to define mock.On call
526+// - ctx context.Context
527+// - target string
528+func (_e *MockRemoteCatalogClient_Expecter) List(ctx any, target any) *MockRemoteCatalogClient_List_Call {
529+ return &MockRemoteCatalogClient_List_Call{Call: _e.mock.On("List", ctx, target)}
530+}
531+
532+func (_c *MockRemoteCatalogClient_List_Call) Run(run func(ctx context.Context, target string)) *MockRemoteCatalogClient_List_Call {
533+ _c.Call.Run(func(args mock.Arguments) {
534+ var arg0 context.Context
535+ if args[0] != nil {
536+ arg0 = args[0].(context.Context)
537+ }
538+ var arg1 string
539+ if args[1] != nil {
540+ arg1 = args[1].(string)
541+ }
542+ run(
543+ arg0,
544+ arg1,
545+ )
546+ })
547+ return _c
548+}
549+
550+func (_c *MockRemoteCatalogClient_List_Call) Return(remoteCatalog ports.RemoteCatalog, err error) *MockRemoteCatalogClient_List_Call {
551+ _c.Call.Return(remoteCatalog, err)
552+ return _c
553+}
554+
555+func (_c *MockRemoteCatalogClient_List_Call) RunAndReturn(run func(ctx context.Context, target string) (ports.RemoteCatalog, error)) *MockRemoteCatalogClient_List_Call {
556+ _c.Call.Return(run)
557+ return _c
558+}
M internal/ports/remote.go +21 -1
@@ -1,6 +1,10 @@
11 package ports
22
3-import "log/slog"
3+import (
4+ "context"
5+ "fmt"
6+ "log/slog"
7+)
48
59 // RemoteTransportMode selects the transport used for remote attach after CLI parsing.
610 type RemoteTransportMode string
@@ -29,6 +33,11 @@ type RemoteHostStore interface {
2933 Remove(target string) error
3034 }
3135
36+// RemoteCatalogClient fetches a versioned session catalog from a remote host.
37+type RemoteCatalogClient interface {
38+ List(ctx context.Context, target string) (RemoteCatalog, error)
39+}
40+
3241 // RemoteCatalogSession is one live session in the remote discovery catalog.
3342 // State is an explicit string contract (running|stopped|broken), not SessionState.
3443 type RemoteCatalogSession struct {
@@ -44,3 +53,14 @@ type RemoteCatalog struct {
4453 ProtocolVersion uint16 `json:"protocol_version"`
4554 Sessions []RemoteCatalogSession `json:"sessions"`
4655 }
56+
57+// RemoteCatalogVersionMismatchError is returned when a remote catalog envelope
58+// reports a protocol_version that is not equal to ProtocolVersion.
59+type RemoteCatalogVersionMismatchError struct {
60+ Got uint16
61+ Want uint16
62+}
63+
64+func (e *RemoteCatalogVersionMismatchError) Error() string {
65+ return fmt.Sprintf("remote catalog: protocol version mismatch: got %d, want %d", e.Got, e.Want)
66+}
@@ -1,6 +1,10 @@
1 package ports1 package ports
2 2
3-import "log/slog"3+import (
4+ "context"
5+ "fmt"
6+ "log/slog"
7+)
4 8
5 // RemoteTransportMode selects the transport used for remote attach after CLI parsing.9 // RemoteTransportMode selects the transport used for remote attach after CLI parsing.
6 type RemoteTransportMode string10 type RemoteTransportMode string
@@ -29,6 +33,11 @@ type RemoteHostStore interface {
29 Remove(target string) error33 Remove(target string) error
30 }34 }
31 35
36+// RemoteCatalogClient fetches a versioned session catalog from a remote host.
37+type RemoteCatalogClient interface {
38+ List(ctx context.Context, target string) (RemoteCatalog, error)
39+}
40+
32 // RemoteCatalogSession is one live session in the remote discovery catalog.41 // RemoteCatalogSession is one live session in the remote discovery catalog.
33 // State is an explicit string contract (running|stopped|broken), not SessionState.42 // State is an explicit string contract (running|stopped|broken), not SessionState.
34 type RemoteCatalogSession struct {43 type RemoteCatalogSession struct {
@@ -44,3 +53,14 @@ type RemoteCatalog struct {
44 ProtocolVersion uint16 `json:"protocol_version"`53 ProtocolVersion uint16 `json:"protocol_version"`
45 Sessions []RemoteCatalogSession `json:"sessions"`54 Sessions []RemoteCatalogSession `json:"sessions"`
46 }55 }
56+
57+// RemoteCatalogVersionMismatchError is returned when a remote catalog envelope
58+// reports a protocol_version that is not equal to ProtocolVersion.
59+type RemoteCatalogVersionMismatchError struct {
60+ Got uint16
61+ Want uint16
62+}
63+
64+func (e *RemoteCatalogVersionMismatchError) Error() string {
65+ return fmt.Sprintf("remote catalog: protocol version mismatch: got %d, want %d", e.Got, e.Want)
66+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close