detached f7c9bbe

feat(ports): add command wire messages and bump protocol to 18

commit
f7c9bbe80955c986eb60a7e0f57986d9a8831e28
author
brice · · 2026-07-23 21:31 UTC
parent
2301cb6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +313 -32
M internal/adapters/shellcmd/runner.go +2 -2
@@ -18,7 +18,7 @@ type Runner struct{}
1818 func New() Runner { return Runner{} }
1919
2020 // Run executes spec.Command through sh -c, capturing bounded stdout and stderr.
21-func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error) {
21+func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.ShellCommandResult, error) {
2222 cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command)
2323 cmd.Env = spec.Env
2424 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
@@ -36,7 +36,7 @@ func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandRes
3636 cmd.Stderr = &stderr
3737
3838 err := cmd.Run()
39- res := ports.CommandResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
39+ res := ports.ShellCommandResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
4040 if err != nil {
4141 var exitErr *exec.ExitError
4242 if errors.As(err, &exitErr) {
@@ -18,7 +18,7 @@ type Runner struct{}
18 func New() Runner { return Runner{} }18 func New() Runner { return Runner{} }
19 19
20 // Run executes spec.Command through sh -c, capturing bounded stdout and stderr.20 // Run executes spec.Command through sh -c, capturing bounded stdout and stderr.
21-func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error) {21+func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.ShellCommandResult, error) {
22 cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command)22 cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command)
23 cmd.Env = spec.Env23 cmd.Env = spec.Env
24 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}24 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
@@ -36,7 +36,7 @@ func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandRes
36 cmd.Stderr = &stderr36 cmd.Stderr = &stderr
37 37
38 err := cmd.Run()38 err := cmd.Run()
39- res := ports.CommandResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}39+ res := ports.ShellCommandResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
40 if err != nil {40 if err != nil {
41 var exitErr *exec.ExitError41 var exitErr *exec.ExitError
42 if errors.As(err, &exitErr) {42 if errors.As(err, &exitErr) {
M internal/ports/frame.go +9 -7
@@ -1,7 +1,7 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 17
4+const ProtocolVersion uint16 = 18
55
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
@@ -21,13 +21,15 @@ const (
2121 MsgAck MsgType = 9
2222 MsgImagePush MsgType = 10
2323 MsgClientNotice MsgType = 11
24+ MsgCommand MsgType = 12
2425
25- MsgWelcome MsgType = 16
26- MsgError MsgType = 17
27- MsgOutput MsgType = 18
28- MsgDetached MsgType = 19
29- MsgPong MsgType = 20
30- MsgSessions MsgType = 21
26+ MsgWelcome MsgType = 16
27+ MsgError MsgType = 17
28+ MsgOutput MsgType = 18
29+ MsgDetached MsgType = 19
30+ MsgPong MsgType = 20
31+ MsgSessions MsgType = 21
32+ MsgCommandResult MsgType = 22
3133 )
3234
3335 // Frame is the unit of exchange over a Transport: a typed, length-delimited
@@ -1,7 +1,7 @@
1 package ports1 package ports
2 2
3 // ProtocolVersion is the current vev IPC wire protocol version.3 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 174+const ProtocolVersion uint16 = 18
5 5
6 // MsgType identifies the kind of payload carried by a Frame.6 // MsgType identifies the kind of payload carried by a Frame.
7 type MsgType uint87 type MsgType uint8
@@ -21,13 +21,15 @@ const (
21 MsgAck MsgType = 921 MsgAck MsgType = 9
22 MsgImagePush MsgType = 1022 MsgImagePush MsgType = 10
23 MsgClientNotice MsgType = 1123 MsgClientNotice MsgType = 11
24+ MsgCommand MsgType = 12
24 25
25- MsgWelcome MsgType = 1626+ MsgWelcome MsgType = 16
26- MsgError MsgType = 1727+ MsgError MsgType = 17
27- MsgOutput MsgType = 1828+ MsgOutput MsgType = 18
28- MsgDetached MsgType = 1929+ MsgDetached MsgType = 19
29- MsgPong MsgType = 2030+ MsgPong MsgType = 20
30- MsgSessions MsgType = 2131+ MsgSessions MsgType = 21
32+ MsgCommandResult MsgType = 22
31 )33 )
32 34
33 // Frame is the unit of exchange over a Transport: a typed, length-delimited35 // Frame is the unit of exchange over a Transport: a typed, length-delimited
M internal/ports/frame_test.go +13 -2
@@ -3,8 +3,17 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 17 {
7- t.Fatalf("ProtocolVersion = %d, want 17", ProtocolVersion)
6+ if ProtocolVersion != 18 {
7+ t.Fatalf("ProtocolVersion = %d, want 18", ProtocolVersion)
8+ }
9+}
10+
11+func TestControlMsgTypes(t *testing.T) {
12+ if MsgCommand != 12 {
13+ t.Fatalf("MsgCommand = %d, want 12", MsgCommand)
14+ }
15+ if MsgCommandResult != 22 {
16+ t.Fatalf("MsgCommandResult = %d, want 22", MsgCommandResult)
817 }
918 }
1019
@@ -24,12 +33,14 @@ func TestMsgTypeUnique(t *testing.T) {
2433 {"MsgAck", MsgAck},
2534 {"MsgImagePush", MsgImagePush},
2635 {"MsgClientNotice", MsgClientNotice},
36+ {"MsgCommand", MsgCommand},
2737 {"MsgWelcome", MsgWelcome},
2838 {"MsgError", MsgError},
2939 {"MsgOutput", MsgOutput},
3040 {"MsgDetached", MsgDetached},
3141 {"MsgPong", MsgPong},
3242 {"MsgSessions", MsgSessions},
43+ {"MsgCommandResult", MsgCommandResult},
3344 }
3445
3546 seen := make(map[MsgType]string, len(tests))
@@ -3,8 +3,17 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 17 {6+ if ProtocolVersion != 18 {
7- t.Fatalf("ProtocolVersion = %d, want 17", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 18", ProtocolVersion)
8+ }
9+}
10+
11+func TestControlMsgTypes(t *testing.T) {
12+ if MsgCommand != 12 {
13+ t.Fatalf("MsgCommand = %d, want 12", MsgCommand)
14+ }
15+ if MsgCommandResult != 22 {
16+ t.Fatalf("MsgCommandResult = %d, want 22", MsgCommandResult)
8 }17 }
9 }18 }
10 19
@@ -24,12 +33,14 @@ func TestMsgTypeUnique(t *testing.T) {
24 {"MsgAck", MsgAck},33 {"MsgAck", MsgAck},
25 {"MsgImagePush", MsgImagePush},34 {"MsgImagePush", MsgImagePush},
26 {"MsgClientNotice", MsgClientNotice},35 {"MsgClientNotice", MsgClientNotice},
36+ {"MsgCommand", MsgCommand},
27 {"MsgWelcome", MsgWelcome},37 {"MsgWelcome", MsgWelcome},
28 {"MsgError", MsgError},38 {"MsgError", MsgError},
29 {"MsgOutput", MsgOutput},39 {"MsgOutput", MsgOutput},
30 {"MsgDetached", MsgDetached},40 {"MsgDetached", MsgDetached},
31 {"MsgPong", MsgPong},41 {"MsgPong", MsgPong},
32 {"MsgSessions", MsgSessions},42 {"MsgSessions", MsgSessions},
43+ {"MsgCommandResult", MsgCommandResult},
33 }44 }
34 45
35 seen := make(map[MsgType]string, len(tests))46 seen := make(map[MsgType]string, len(tests))
M internal/ports/mocks/shell_mock.go +8 -8
@@ -39,22 +39,22 @@ func (_m *MockShellCommandRunner) EXPECT() *MockShellCommandRunner_Expecter {
3939 }
4040
4141 // Run provides a mock function for the type MockShellCommandRunner
42-func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error) {
42+func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) (ports.ShellCommandResult, error) {
4343 ret := _mock.Called(ctx, spec)
4444
4545 if len(ret) == 0 {
4646 panic("no return value specified for Run")
4747 }
4848
49- var r0 ports.CommandResult
49+ var r0 ports.ShellCommandResult
5050 var r1 error
51- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) (ports.CommandResult, error)); ok {
51+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) (ports.ShellCommandResult, error)); ok {
5252 return returnFunc(ctx, spec)
5353 }
54- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ports.CommandResult); ok {
54+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ports.ShellCommandResult); ok {
5555 r0 = returnFunc(ctx, spec)
5656 } else {
57- r0 = ret.Get(0).(ports.CommandResult)
57+ r0 = ret.Get(0).(ports.ShellCommandResult)
5858 }
5959 if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok {
6060 r1 = returnFunc(ctx, spec)
@@ -94,12 +94,12 @@ func (_c *MockShellCommandRunner_Run_Call) Run(run func(ctx context.Context, spe
9494 return _c
9595 }
9696
97-func (_c *MockShellCommandRunner_Run_Call) Return(commandResult ports.CommandResult, err error) *MockShellCommandRunner_Run_Call {
98- _c.Call.Return(commandResult, err)
97+func (_c *MockShellCommandRunner_Run_Call) Return(shellCommandResult ports.ShellCommandResult, err error) *MockShellCommandRunner_Run_Call {
98+ _c.Call.Return(shellCommandResult, err)
9999 return _c
100100 }
101101
102-func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error)) *MockShellCommandRunner_Run_Call {
102+func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) (ports.ShellCommandResult, error)) *MockShellCommandRunner_Run_Call {
103103 _c.Call.Return(run)
104104 return _c
105105 }
@@ -39,22 +39,22 @@ func (_m *MockShellCommandRunner) EXPECT() *MockShellCommandRunner_Expecter {
39 }39 }
40 40
41 // Run provides a mock function for the type MockShellCommandRunner41 // Run provides a mock function for the type MockShellCommandRunner
42-func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error) {42+func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) (ports.ShellCommandResult, error) {
43 ret := _mock.Called(ctx, spec)43 ret := _mock.Called(ctx, spec)
44 44
45 if len(ret) == 0 {45 if len(ret) == 0 {
46 panic("no return value specified for Run")46 panic("no return value specified for Run")
47 }47 }
48 48
49- var r0 ports.CommandResult49+ var r0 ports.ShellCommandResult
50 var r1 error50 var r1 error
51- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) (ports.CommandResult, error)); ok {51+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) (ports.ShellCommandResult, error)); ok {
52 return returnFunc(ctx, spec)52 return returnFunc(ctx, spec)
53 }53 }
54- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ports.CommandResult); ok {54+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ports.ShellCommandResult); ok {
55 r0 = returnFunc(ctx, spec)55 r0 = returnFunc(ctx, spec)
56 } else {56 } else {
57- r0 = ret.Get(0).(ports.CommandResult)57+ r0 = ret.Get(0).(ports.ShellCommandResult)
58 }58 }
59 if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok {59 if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok {
60 r1 = returnFunc(ctx, spec)60 r1 = returnFunc(ctx, spec)
@@ -94,12 +94,12 @@ func (_c *MockShellCommandRunner_Run_Call) Run(run func(ctx context.Context, spe
94 return _c94 return _c
95 }95 }
96 96
97-func (_c *MockShellCommandRunner_Run_Call) Return(commandResult ports.CommandResult, err error) *MockShellCommandRunner_Run_Call {97+func (_c *MockShellCommandRunner_Run_Call) Return(shellCommandResult ports.ShellCommandResult, err error) *MockShellCommandRunner_Run_Call {
98- _c.Call.Return(commandResult, err)98+ _c.Call.Return(shellCommandResult, err)
99 return _c99 return _c
100 }100 }
101 101
102-func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error)) *MockShellCommandRunner_Run_Call {102+func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) (ports.ShellCommandResult, error)) *MockShellCommandRunner_Run_Call {
103 _c.Call.Return(run)103 _c.Call.Return(run)
104 return _c104 return _c
105 }105 }
M internal/ports/shell.go +6 -6
@@ -14,11 +14,11 @@ type CommandSpec struct {
1414 StderrLimit int
1515 }
1616
17-// CommandResult carries bounded output and the process exit status. ExitCode is
18-// 0 when the command ran to completion successfully, the process exit code when
19-// it ran and failed, and -1 when the command could not be started or was
20-// terminated by a signal.
21-type CommandResult struct {
17+// ShellCommandResult carries bounded output and the process exit status.
18+// ExitCode is 0 when the command ran to completion successfully, the process
19+// exit code when it ran and failed, and -1 when the command could not be
20+// started or was terminated by a signal.
21+type ShellCommandResult struct {
2222 Stdout []byte
2323 Stderr []byte
2424 ExitCode int
@@ -26,5 +26,5 @@ type CommandResult struct {
2626
2727 // ShellCommandRunner runs shell commands and returns bounded output.
2828 type ShellCommandRunner interface {
29- Run(ctx context.Context, spec CommandSpec) (CommandResult, error)
29+ Run(ctx context.Context, spec CommandSpec) (ShellCommandResult, error)
3030 }
@@ -14,11 +14,11 @@ type CommandSpec struct {
14 StderrLimit int14 StderrLimit int
15 }15 }
16 16
17-// CommandResult carries bounded output and the process exit status. ExitCode is17+// ShellCommandResult carries bounded output and the process exit status.
18-// 0 when the command ran to completion successfully, the process exit code when18+// ExitCode is 0 when the command ran to completion successfully, the process
19-// it ran and failed, and -1 when the command could not be started or was19+// exit code when it ran and failed, and -1 when the command could not be
20-// terminated by a signal.20+// started or was terminated by a signal.
21-type CommandResult struct {21+type ShellCommandResult struct {
22 Stdout []byte22 Stdout []byte
23 Stderr []byte23 Stderr []byte
24 ExitCode int24 ExitCode int
@@ -26,5 +26,5 @@ type CommandResult struct {
26 26
27 // ShellCommandRunner runs shell commands and returns bounded output.27 // ShellCommandRunner runs shell commands and returns bounded output.
28 type ShellCommandRunner interface {28 type ShellCommandRunner interface {
29- Run(ctx context.Context, spec CommandSpec) (CommandResult, error)29+ Run(ctx context.Context, spec CommandSpec) (ShellCommandResult, error)
30 }30 }
M internal/ports/wire.go +145 -0
@@ -45,6 +45,11 @@ const (
4545 ErrNameTaken uint16 = 3
4646 ErrServerShutdown uint16 = 4
4747 ErrInvalidSessionName uint16 = 5
48+ ErrUnknownCommand uint16 = 6
49+ ErrNotScriptable uint16 = 7
50+ ErrInvalidCommandArgs uint16 = 8
51+ ErrNoSuchTarget uint16 = 9
52+ ErrAmbiguousTarget uint16 = 10
4853 ErrInternal uint16 = 255
4954 )
5055
@@ -185,6 +190,26 @@ type Kill struct {
185190 All bool
186191 }
187192
193+// CommandRequest asks the daemon to run one control command. Version must
194+// stay first so a future payload layout can still be rejected cleanly.
195+type CommandRequest struct {
196+ Version uint16
197+ Slug string
198+ Args []string
199+ TargetSession string
200+ TargetTab string
201+ TargetPane string
202+ JSON bool
203+}
204+
205+// CommandResult reports a control command's outcome.
206+type CommandResult struct {
207+ OK bool
208+ Code uint16
209+ Text string
210+ Output string
211+}
212+
188213 // SessionInfo describes one session in a Sessions listing.
189214 type SessionInfo struct {
190215 SessionID string
@@ -342,6 +367,15 @@ func PeekHelloVersion(b []byte) (uint16, bool) {
342367 return binary.BigEndian.Uint16(b), true
343368 }
344369
370+// PeekCommandVersion returns the leading protocol version from a
371+// CommandRequest payload.
372+func PeekCommandVersion(b []byte) (uint16, bool) {
373+ if len(b) < 2 {
374+ return 0, false
375+ }
376+ return binary.BigEndian.Uint16(b), true
377+}
378+
345379 // MarshalHello encodes h into a Hello message payload.
346380 func MarshalHello(h Hello) []byte {
347381 w := payloadWriter{}
@@ -851,6 +885,117 @@ func UnmarshalKill(b []byte) (Kill, error) {
851885 return Kill{Name: name, All: all != 0}, nil
852886 }
853887
888+// MarshalCommandRequest encodes m into a CommandRequest payload.
889+func MarshalCommandRequest(m CommandRequest) []byte {
890+ w := payloadWriter{}
891+ w.putUint16(m.Version)
892+ w.putString(m.Slug)
893+ w.putUint16(uint16(len(m.Args)))
894+ for _, arg := range m.Args {
895+ w.putLongString(arg)
896+ }
897+ w.putString(m.TargetSession)
898+ w.putString(m.TargetTab)
899+ w.putString(m.TargetPane)
900+ if m.JSON {
901+ w.putUint8(1)
902+ } else {
903+ w.putUint8(0)
904+ }
905+ return w.b
906+}
907+
908+// UnmarshalCommandRequest decodes a strict CommandRequest payload.
909+func UnmarshalCommandRequest(b []byte) (CommandRequest, error) {
910+ r := payloadReader{b: b}
911+ var m CommandRequest
912+ var err error
913+
914+ if m.Version, err = r.getUint16(); err != nil {
915+ return CommandRequest{}, err
916+ }
917+ if m.Slug, err = r.getString(); err != nil {
918+ return CommandRequest{}, err
919+ }
920+ argCount, err := r.getUint16()
921+ if err != nil {
922+ return CommandRequest{}, err
923+ }
924+ // Every argument requires at least a uint32 length prefix. Reject an
925+ // impossible count before allocating from untrusted input.
926+ if uint64(argCount) > uint64(len(r.b)/4) {
927+ return CommandRequest{}, errShortPayload
928+ }
929+ if argCount != 0 {
930+ m.Args = make([]string, 0, int(argCount))
931+ for range int(argCount) {
932+ arg, err := r.getLongString()
933+ if err != nil {
934+ return CommandRequest{}, err
935+ }
936+ m.Args = append(m.Args, arg)
937+ }
938+ }
939+ if m.TargetSession, err = r.getString(); err != nil {
940+ return CommandRequest{}, err
941+ }
942+ if m.TargetTab, err = r.getString(); err != nil {
943+ return CommandRequest{}, err
944+ }
945+ if m.TargetPane, err = r.getString(); err != nil {
946+ return CommandRequest{}, err
947+ }
948+ jsonFlag, err := r.getUint8()
949+ if err != nil {
950+ return CommandRequest{}, err
951+ }
952+ m.JSON = jsonFlag != 0
953+ if err := r.done(); err != nil {
954+ return CommandRequest{}, err
955+ }
956+ return m, nil
957+}
958+
959+// MarshalCommandResult encodes m into a CommandResult payload.
960+func MarshalCommandResult(m CommandResult) []byte {
961+ w := payloadWriter{}
962+ if m.OK {
963+ w.putUint8(1)
964+ } else {
965+ w.putUint8(0)
966+ }
967+ w.putUint16(m.Code)
968+ w.putString(m.Text)
969+ w.putLongString(m.Output)
970+ return w.b
971+}
972+
973+// UnmarshalCommandResult decodes a strict CommandResult payload.
974+func UnmarshalCommandResult(b []byte) (CommandResult, error) {
975+ r := payloadReader{b: b}
976+ var m CommandResult
977+ var err error
978+
979+ okFlag, err := r.getUint8()
980+ if err != nil {
981+ return CommandResult{}, err
982+ }
983+ m.OK = okFlag != 0
984+ if m.Code, err = r.getUint16(); err != nil {
985+ return CommandResult{}, err
986+ }
987+ if m.Text, err = r.getString(); err != nil {
988+ return CommandResult{}, err
989+ }
990+ if m.Output, err = r.getLongString(); err != nil {
991+ return CommandResult{}, err
992+ }
993+ if err := r.done(); err != nil {
994+ return CommandResult{}, err
995+ }
996+ return m, nil
997+}
998+
854999 // MarshalSessions encodes m into a Sessions message payload: a uint16 count
8551000 // followed by that many session records.
8561001 func MarshalSessions(m Sessions) []byte {
@@ -45,6 +45,11 @@ const (
45 ErrNameTaken uint16 = 345 ErrNameTaken uint16 = 3
46 ErrServerShutdown uint16 = 446 ErrServerShutdown uint16 = 4
47 ErrInvalidSessionName uint16 = 547 ErrInvalidSessionName uint16 = 5
48+ ErrUnknownCommand uint16 = 6
49+ ErrNotScriptable uint16 = 7
50+ ErrInvalidCommandArgs uint16 = 8
51+ ErrNoSuchTarget uint16 = 9
52+ ErrAmbiguousTarget uint16 = 10
48 ErrInternal uint16 = 25553 ErrInternal uint16 = 255
49 )54 )
50 55
@@ -185,6 +190,26 @@ type Kill struct {
185 All bool190 All bool
186 }191 }
187 192
193+// CommandRequest asks the daemon to run one control command. Version must
194+// stay first so a future payload layout can still be rejected cleanly.
195+type CommandRequest struct {
196+ Version uint16
197+ Slug string
198+ Args []string
199+ TargetSession string
200+ TargetTab string
201+ TargetPane string
202+ JSON bool
203+}
204+
205+// CommandResult reports a control command's outcome.
206+type CommandResult struct {
207+ OK bool
208+ Code uint16
209+ Text string
210+ Output string
211+}
212+
188 // SessionInfo describes one session in a Sessions listing.213 // SessionInfo describes one session in a Sessions listing.
189 type SessionInfo struct {214 type SessionInfo struct {
190 SessionID string215 SessionID string
@@ -342,6 +367,15 @@ func PeekHelloVersion(b []byte) (uint16, bool) {
342 return binary.BigEndian.Uint16(b), true367 return binary.BigEndian.Uint16(b), true
343 }368 }
344 369
370+// PeekCommandVersion returns the leading protocol version from a
371+// CommandRequest payload.
372+func PeekCommandVersion(b []byte) (uint16, bool) {
373+ if len(b) < 2 {
374+ return 0, false
375+ }
376+ return binary.BigEndian.Uint16(b), true
377+}
378+
345 // MarshalHello encodes h into a Hello message payload.379 // MarshalHello encodes h into a Hello message payload.
346 func MarshalHello(h Hello) []byte {380 func MarshalHello(h Hello) []byte {
347 w := payloadWriter{}381 w := payloadWriter{}
@@ -851,6 +885,117 @@ func UnmarshalKill(b []byte) (Kill, error) {
851 return Kill{Name: name, All: all != 0}, nil885 return Kill{Name: name, All: all != 0}, nil
852 }886 }
853 887
888+// MarshalCommandRequest encodes m into a CommandRequest payload.
889+func MarshalCommandRequest(m CommandRequest) []byte {
890+ w := payloadWriter{}
891+ w.putUint16(m.Version)
892+ w.putString(m.Slug)
893+ w.putUint16(uint16(len(m.Args)))
894+ for _, arg := range m.Args {
895+ w.putLongString(arg)
896+ }
897+ w.putString(m.TargetSession)
898+ w.putString(m.TargetTab)
899+ w.putString(m.TargetPane)
900+ if m.JSON {
901+ w.putUint8(1)
902+ } else {
903+ w.putUint8(0)
904+ }
905+ return w.b
906+}
907+
908+// UnmarshalCommandRequest decodes a strict CommandRequest payload.
909+func UnmarshalCommandRequest(b []byte) (CommandRequest, error) {
910+ r := payloadReader{b: b}
911+ var m CommandRequest
912+ var err error
913+
914+ if m.Version, err = r.getUint16(); err != nil {
915+ return CommandRequest{}, err
916+ }
917+ if m.Slug, err = r.getString(); err != nil {
918+ return CommandRequest{}, err
919+ }
920+ argCount, err := r.getUint16()
921+ if err != nil {
922+ return CommandRequest{}, err
923+ }
924+ // Every argument requires at least a uint32 length prefix. Reject an
925+ // impossible count before allocating from untrusted input.
926+ if uint64(argCount) > uint64(len(r.b)/4) {
927+ return CommandRequest{}, errShortPayload
928+ }
929+ if argCount != 0 {
930+ m.Args = make([]string, 0, int(argCount))
931+ for range int(argCount) {
932+ arg, err := r.getLongString()
933+ if err != nil {
934+ return CommandRequest{}, err
935+ }
936+ m.Args = append(m.Args, arg)
937+ }
938+ }
939+ if m.TargetSession, err = r.getString(); err != nil {
940+ return CommandRequest{}, err
941+ }
942+ if m.TargetTab, err = r.getString(); err != nil {
943+ return CommandRequest{}, err
944+ }
945+ if m.TargetPane, err = r.getString(); err != nil {
946+ return CommandRequest{}, err
947+ }
948+ jsonFlag, err := r.getUint8()
949+ if err != nil {
950+ return CommandRequest{}, err
951+ }
952+ m.JSON = jsonFlag != 0
953+ if err := r.done(); err != nil {
954+ return CommandRequest{}, err
955+ }
956+ return m, nil
957+}
958+
959+// MarshalCommandResult encodes m into a CommandResult payload.
960+func MarshalCommandResult(m CommandResult) []byte {
961+ w := payloadWriter{}
962+ if m.OK {
963+ w.putUint8(1)
964+ } else {
965+ w.putUint8(0)
966+ }
967+ w.putUint16(m.Code)
968+ w.putString(m.Text)
969+ w.putLongString(m.Output)
970+ return w.b
971+}
972+
973+// UnmarshalCommandResult decodes a strict CommandResult payload.
974+func UnmarshalCommandResult(b []byte) (CommandResult, error) {
975+ r := payloadReader{b: b}
976+ var m CommandResult
977+ var err error
978+
979+ okFlag, err := r.getUint8()
980+ if err != nil {
981+ return CommandResult{}, err
982+ }
983+ m.OK = okFlag != 0
984+ if m.Code, err = r.getUint16(); err != nil {
985+ return CommandResult{}, err
986+ }
987+ if m.Text, err = r.getString(); err != nil {
988+ return CommandResult{}, err
989+ }
990+ if m.Output, err = r.getLongString(); err != nil {
991+ return CommandResult{}, err
992+ }
993+ if err := r.done(); err != nil {
994+ return CommandResult{}, err
995+ }
996+ return m, nil
997+}
998+
854 // MarshalSessions encodes m into a Sessions message payload: a uint16 count999 // MarshalSessions encodes m into a Sessions message payload: a uint16 count
855 // followed by that many session records.1000 // followed by that many session records.
856 func MarshalSessions(m Sessions) []byte {1001 func MarshalSessions(m Sessions) []byte {
M internal/ports/wire_test.go +125 -2
@@ -121,7 +121,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
121121 Env: []string{"A=B", "XY=123"},
122122 })
123123 want := []byte{
124- 0x00, 0x11, 0x00, // version, intent
124+ 0x00, 0x12, 0x00, // version, intent
125125 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
126126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
127127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -335,7 +335,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
335335 }
336336 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
337337 require.Equal(t, want, MarshalTheme(cleared))
338- require.Equal(t, uint16(17), ProtocolVersion)
338+ require.Equal(t, uint16(18), ProtocolVersion)
339339 }
340340
341341 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -442,6 +442,129 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
442442 assertTrailingGarbageFails(t, full, UnmarshalWelcome)
443443 }
444444
445+func TestCommandErrorCodes(t *testing.T) {
446+ require.Equal(t, uint16(6), ErrUnknownCommand)
447+ require.Equal(t, uint16(7), ErrNotScriptable)
448+ require.Equal(t, uint16(8), ErrInvalidCommandArgs)
449+ require.Equal(t, uint16(9), ErrNoSuchTarget)
450+ require.Equal(t, uint16(10), ErrAmbiguousTarget)
451+}
452+
453+func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
454+ tests := []struct {
455+ name string
456+ msg CommandRequest
457+ want []byte
458+ }{
459+ {
460+ name: "minimal",
461+ msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
462+ want: []byte{
463+ 0x00, 0x12,
464+ 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
465+ 0x00, 0x00,
466+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
467+ 0x00,
468+ },
469+ },
470+ {
471+ name: "full",
472+ msg: CommandRequest{
473+ Version: ProtocolVersion,
474+ Slug: "toast",
475+ Args: []string{"-l", "warn", "tests KO"},
476+ TargetSession: "dev",
477+ TargetTab: "t_abc",
478+ TargetPane: "p_def",
479+ JSON: true,
480+ },
481+ want: []byte{
482+ 0x00, 0x12,
483+ 0x00, 0x05, 't', 'o', 'a', 's', 't',
484+ 0x00, 0x03,
485+ 0x00, 0x00, 0x00, 0x02, '-', 'l',
486+ 0x00, 0x00, 0x00, 0x04, 'w', 'a', 'r', 'n',
487+ 0x00, 0x00, 0x00, 0x08, 't', 'e', 's', 't', 's', ' ', 'K', 'O',
488+ 0x00, 0x03, 'd', 'e', 'v',
489+ 0x00, 0x05, 't', '_', 'a', 'b', 'c',
490+ 0x00, 0x05, 'p', '_', 'd', 'e', 'f',
491+ 0x01,
492+ },
493+ },
494+ }
495+
496+ for _, tt := range tests {
497+ t.Run(tt.name, func(t *testing.T) {
498+ got := MarshalCommandRequest(tt.msg)
499+ require.Equal(t, tt.want, got)
500+ back, err := UnmarshalCommandRequest(got)
501+ require.NoError(t, err)
502+ require.Equal(t, tt.msg, back)
503+ version, ok := PeekCommandVersion(got)
504+ require.True(t, ok)
505+ require.Equal(t, tt.msg.Version, version)
506+ assertAllPrefixesFail(t, got, UnmarshalCommandRequest)
507+ assertTrailingGarbageFails(t, got, UnmarshalCommandRequest)
508+ })
509+ }
510+
511+ if _, ok := PeekCommandVersion([]byte{0x00}); ok {
512+ t.Fatal("PeekCommandVersion accepted a one-byte payload")
513+ }
514+}
515+
516+func TestCommandRequestRejectsImpossibleArgumentCount(t *testing.T) {
517+ payload := MarshalCommandRequest(CommandRequest{Version: ProtocolVersion, Slug: "toast"})
518+ argCountOffset := 2 + 2 + len("toast")
519+ payload[argCountOffset] = 0xff
520+ payload[argCountOffset+1] = 0xff
521+
522+ if _, err := UnmarshalCommandRequest(payload); err == nil {
523+ t.Fatal("UnmarshalCommandRequest accepted an impossible argument count")
524+ }
525+}
526+
527+func TestCommandResultGoldenAndRoundTrip(t *testing.T) {
528+ tests := []struct {
529+ name string
530+ msg CommandResult
531+ want []byte
532+ }{
533+ {
534+ name: "error",
535+ msg: CommandResult{Code: ErrNoSuchTarget, Text: "no such session"},
536+ want: []byte{
537+ 0x00,
538+ 0x00, 0x09,
539+ 0x00, 0x0f, 'n', 'o', ' ', 's', 'u', 'c', 'h', ' ', 's', 'e', 's', 's', 'i', 'o', 'n',
540+ 0x00, 0x00, 0x00, 0x00,
541+ },
542+ },
543+ {
544+ name: "success with output",
545+ msg: CommandResult{OK: true, Text: "listed", Output: "ID\tFOCUSED\n"},
546+ want: []byte{
547+ 0x01,
548+ 0x00, 0x00,
549+ 0x00, 0x06, 'l', 'i', 's', 't', 'e', 'd',
550+ 0x00, 0x00, 0x00, 0x0b, 'I', 'D', '\t', 'F', 'O', 'C', 'U', 'S', 'E', 'D', '\n',
551+ },
552+ },
553+ }
554+
555+ for _, tt := range tests {
556+ t.Run(tt.name, func(t *testing.T) {
557+ got := MarshalCommandResult(tt.msg)
558+ require.Equal(t, tt.want, got)
559+ back, err := UnmarshalCommandResult(got)
560+ require.NoError(t, err)
561+ require.Equal(t, tt.msg, back)
562+ assertAllPrefixesFail(t, got, UnmarshalCommandResult)
563+ assertTrailingGarbageFails(t, got, UnmarshalCommandResult)
564+ })
565+ }
566+}
567+
445568 func TestErrorMsgGoldenAndRoundTrip(t *testing.T) {
446569 msg := ErrorMsg{Code: ErrNameTaken, Text: "name taken"}
447570 want := []byte{0x00, 0x03, 0x00, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x20, 0x74, 0x61, 0x6b, 0x65, 0x6e}
@@ -121,7 +121,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
121 Env: []string{"A=B", "XY=123"},121 Env: []string{"A=B", "XY=123"},
122 })122 })
123 want := []byte{123 want := []byte{
124- 0x00, 0x11, 0x00, // version, intent124+ 0x00, 0x12, 0x00, // version, intent
125 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,125 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -335,7 +335,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
335 }335 }
336 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)336 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
337 require.Equal(t, want, MarshalTheme(cleared))337 require.Equal(t, want, MarshalTheme(cleared))
338- require.Equal(t, uint16(17), ProtocolVersion)338+ require.Equal(t, uint16(18), ProtocolVersion)
339 }339 }
340 340
341 func TestResizeGoldenAndRoundTrip(t *testing.T) {341 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -442,6 +442,129 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
442 assertTrailingGarbageFails(t, full, UnmarshalWelcome)442 assertTrailingGarbageFails(t, full, UnmarshalWelcome)
443 }443 }
444 444
445+func TestCommandErrorCodes(t *testing.T) {
446+ require.Equal(t, uint16(6), ErrUnknownCommand)
447+ require.Equal(t, uint16(7), ErrNotScriptable)
448+ require.Equal(t, uint16(8), ErrInvalidCommandArgs)
449+ require.Equal(t, uint16(9), ErrNoSuchTarget)
450+ require.Equal(t, uint16(10), ErrAmbiguousTarget)
451+}
452+
453+func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
454+ tests := []struct {
455+ name string
456+ msg CommandRequest
457+ want []byte
458+ }{
459+ {
460+ name: "minimal",
461+ msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
462+ want: []byte{
463+ 0x00, 0x12,
464+ 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
465+ 0x00, 0x00,
466+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
467+ 0x00,
468+ },
469+ },
470+ {
471+ name: "full",
472+ msg: CommandRequest{
473+ Version: ProtocolVersion,
474+ Slug: "toast",
475+ Args: []string{"-l", "warn", "tests KO"},
476+ TargetSession: "dev",
477+ TargetTab: "t_abc",
478+ TargetPane: "p_def",
479+ JSON: true,
480+ },
481+ want: []byte{
482+ 0x00, 0x12,
483+ 0x00, 0x05, 't', 'o', 'a', 's', 't',
484+ 0x00, 0x03,
485+ 0x00, 0x00, 0x00, 0x02, '-', 'l',
486+ 0x00, 0x00, 0x00, 0x04, 'w', 'a', 'r', 'n',
487+ 0x00, 0x00, 0x00, 0x08, 't', 'e', 's', 't', 's', ' ', 'K', 'O',
488+ 0x00, 0x03, 'd', 'e', 'v',
489+ 0x00, 0x05, 't', '_', 'a', 'b', 'c',
490+ 0x00, 0x05, 'p', '_', 'd', 'e', 'f',
491+ 0x01,
492+ },
493+ },
494+ }
495+
496+ for _, tt := range tests {
497+ t.Run(tt.name, func(t *testing.T) {
498+ got := MarshalCommandRequest(tt.msg)
499+ require.Equal(t, tt.want, got)
500+ back, err := UnmarshalCommandRequest(got)
501+ require.NoError(t, err)
502+ require.Equal(t, tt.msg, back)
503+ version, ok := PeekCommandVersion(got)
504+ require.True(t, ok)
505+ require.Equal(t, tt.msg.Version, version)
506+ assertAllPrefixesFail(t, got, UnmarshalCommandRequest)
507+ assertTrailingGarbageFails(t, got, UnmarshalCommandRequest)
508+ })
509+ }
510+
511+ if _, ok := PeekCommandVersion([]byte{0x00}); ok {
512+ t.Fatal("PeekCommandVersion accepted a one-byte payload")
513+ }
514+}
515+
516+func TestCommandRequestRejectsImpossibleArgumentCount(t *testing.T) {
517+ payload := MarshalCommandRequest(CommandRequest{Version: ProtocolVersion, Slug: "toast"})
518+ argCountOffset := 2 + 2 + len("toast")
519+ payload[argCountOffset] = 0xff
520+ payload[argCountOffset+1] = 0xff
521+
522+ if _, err := UnmarshalCommandRequest(payload); err == nil {
523+ t.Fatal("UnmarshalCommandRequest accepted an impossible argument count")
524+ }
525+}
526+
527+func TestCommandResultGoldenAndRoundTrip(t *testing.T) {
528+ tests := []struct {
529+ name string
530+ msg CommandResult
531+ want []byte
532+ }{
533+ {
534+ name: "error",
535+ msg: CommandResult{Code: ErrNoSuchTarget, Text: "no such session"},
536+ want: []byte{
537+ 0x00,
538+ 0x00, 0x09,
539+ 0x00, 0x0f, 'n', 'o', ' ', 's', 'u', 'c', 'h', ' ', 's', 'e', 's', 's', 'i', 'o', 'n',
540+ 0x00, 0x00, 0x00, 0x00,
541+ },
542+ },
543+ {
544+ name: "success with output",
545+ msg: CommandResult{OK: true, Text: "listed", Output: "ID\tFOCUSED\n"},
546+ want: []byte{
547+ 0x01,
548+ 0x00, 0x00,
549+ 0x00, 0x06, 'l', 'i', 's', 't', 'e', 'd',
550+ 0x00, 0x00, 0x00, 0x0b, 'I', 'D', '\t', 'F', 'O', 'C', 'U', 'S', 'E', 'D', '\n',
551+ },
552+ },
553+ }
554+
555+ for _, tt := range tests {
556+ t.Run(tt.name, func(t *testing.T) {
557+ got := MarshalCommandResult(tt.msg)
558+ require.Equal(t, tt.want, got)
559+ back, err := UnmarshalCommandResult(got)
560+ require.NoError(t, err)
561+ require.Equal(t, tt.msg, back)
562+ assertAllPrefixesFail(t, got, UnmarshalCommandResult)
563+ assertTrailingGarbageFails(t, got, UnmarshalCommandResult)
564+ })
565+ }
566+}
567+
445 func TestErrorMsgGoldenAndRoundTrip(t *testing.T) {568 func TestErrorMsgGoldenAndRoundTrip(t *testing.T) {
446 msg := ErrorMsg{Code: ErrNameTaken, Text: "name taken"}569 msg := ErrorMsg{Code: ErrNameTaken, Text: "name taken"}
447 want := []byte{0x00, 0x03, 0x00, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x20, 0x74, 0x61, 0x6b, 0x65, 0x6e}570 want := []byte{0x00, 0x03, 0x00, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x20, 0x74, 0x61, 0x6b, 0x65, 0x6e}
M internal/usecase/daemon/bar_script_test.go +5 -5
@@ -119,7 +119,7 @@ func TestBarScriptRunner(t *testing.T) {
119119 containsEnv(spec.Env, "VEV_PANE_CWD=/repo") &&
120120 containsEnv(spec.Env, "VEV_COLS=120") &&
121121 !containsEnv(spec.Env, "VEV_ANCHOR=old")
122- })).Return(ports.CommandResult{Stdout: []byte("\x1b[31mok\x1b[0m\nignored")}, nil)
122+ })).Return(ports.ShellCommandResult{Stdout: []byte("\x1b[31mok\x1b[0m\nignored")}, nil)
123123 runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond}
124124
125125 got, err := runner.run(context.Background(), "printf 'ok\\nignored'",
@@ -137,7 +137,7 @@ func TestBarScriptRunner(t *testing.T) {
137137 wantErr := errors.New("exit 1")
138138 portRunner := portsmocks.NewMockShellCommandRunner(t)
139139 portRunner.EXPECT().Run(mock.Anything, mock.Anything).
140- Return(ports.CommandResult{Stdout: []byte("partial\x00 output\nignored"), ExitCode: 1}, wantErr)
140+ Return(ports.ShellCommandResult{Stdout: []byte("partial\x00 output\nignored"), ExitCode: 1}, wantErr)
141141 runner := barScriptRunner{runner: portRunner, timeout: time.Second}
142142
143143 got, err := runner.run(context.Background(), "false", nil, barScriptContext{})
@@ -152,9 +152,9 @@ func TestBarScriptRunner(t *testing.T) {
152152 t.Run("timeout propagates deadline", func(t *testing.T) {
153153 portRunner := portsmocks.NewMockShellCommandRunner(t)
154154 portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(
155- func(ctx context.Context, _ ports.CommandSpec) (ports.CommandResult, error) {
155+ func(ctx context.Context, _ ports.CommandSpec) (ports.ShellCommandResult, error) {
156156 <-ctx.Done()
157- return ports.CommandResult{ExitCode: -1}, ctx.Err()
157+ return ports.ShellCommandResult{ExitCode: -1}, ctx.Err()
158158 })
159159 runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond}
160160
@@ -169,7 +169,7 @@ func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) {
169169 portRunner := portsmocks.NewMockShellCommandRunner(t)
170170 portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool {
171171 return spec.StdoutLimit == barScriptOutputLimit
172- })).Return(ports.CommandResult{Stdout: []byte(strings.Repeat("a", 4096))}, nil)
172+ })).Return(ports.ShellCommandResult{Stdout: []byte(strings.Repeat("a", 4096))}, nil)
173173 runner := barScriptRunner{runner: portRunner, timeout: time.Second}
174174
175175 got, err := runner.run(context.Background(), "long-output", nil, barScriptContext{})
@@ -119,7 +119,7 @@ func TestBarScriptRunner(t *testing.T) {
119 containsEnv(spec.Env, "VEV_PANE_CWD=/repo") &&119 containsEnv(spec.Env, "VEV_PANE_CWD=/repo") &&
120 containsEnv(spec.Env, "VEV_COLS=120") &&120 containsEnv(spec.Env, "VEV_COLS=120") &&
121 !containsEnv(spec.Env, "VEV_ANCHOR=old")121 !containsEnv(spec.Env, "VEV_ANCHOR=old")
122- })).Return(ports.CommandResult{Stdout: []byte("\x1b[31mok\x1b[0m\nignored")}, nil)122+ })).Return(ports.ShellCommandResult{Stdout: []byte("\x1b[31mok\x1b[0m\nignored")}, nil)
123 runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond}123 runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond}
124 124
125 got, err := runner.run(context.Background(), "printf 'ok\\nignored'",125 got, err := runner.run(context.Background(), "printf 'ok\\nignored'",
@@ -137,7 +137,7 @@ func TestBarScriptRunner(t *testing.T) {
137 wantErr := errors.New("exit 1")137 wantErr := errors.New("exit 1")
138 portRunner := portsmocks.NewMockShellCommandRunner(t)138 portRunner := portsmocks.NewMockShellCommandRunner(t)
139 portRunner.EXPECT().Run(mock.Anything, mock.Anything).139 portRunner.EXPECT().Run(mock.Anything, mock.Anything).
140- Return(ports.CommandResult{Stdout: []byte("partial\x00 output\nignored"), ExitCode: 1}, wantErr)140+ Return(ports.ShellCommandResult{Stdout: []byte("partial\x00 output\nignored"), ExitCode: 1}, wantErr)
141 runner := barScriptRunner{runner: portRunner, timeout: time.Second}141 runner := barScriptRunner{runner: portRunner, timeout: time.Second}
142 142
143 got, err := runner.run(context.Background(), "false", nil, barScriptContext{})143 got, err := runner.run(context.Background(), "false", nil, barScriptContext{})
@@ -152,9 +152,9 @@ func TestBarScriptRunner(t *testing.T) {
152 t.Run("timeout propagates deadline", func(t *testing.T) {152 t.Run("timeout propagates deadline", func(t *testing.T) {
153 portRunner := portsmocks.NewMockShellCommandRunner(t)153 portRunner := portsmocks.NewMockShellCommandRunner(t)
154 portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(154 portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(
155- func(ctx context.Context, _ ports.CommandSpec) (ports.CommandResult, error) {155+ func(ctx context.Context, _ ports.CommandSpec) (ports.ShellCommandResult, error) {
156 <-ctx.Done()156 <-ctx.Done()
157- return ports.CommandResult{ExitCode: -1}, ctx.Err()157+ return ports.ShellCommandResult{ExitCode: -1}, ctx.Err()
158 })158 })
159 runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond}159 runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond}
160 160
@@ -169,7 +169,7 @@ func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) {
169 portRunner := portsmocks.NewMockShellCommandRunner(t)169 portRunner := portsmocks.NewMockShellCommandRunner(t)
170 portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool {170 portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool {
171 return spec.StdoutLimit == barScriptOutputLimit171 return spec.StdoutLimit == barScriptOutputLimit
172- })).Return(ports.CommandResult{Stdout: []byte(strings.Repeat("a", 4096))}, nil)172+ })).Return(ports.ShellCommandResult{Stdout: []byte(strings.Repeat("a", 4096))}, nil)
173 runner := barScriptRunner{runner: portRunner, timeout: time.Second}173 runner := barScriptRunner{runner: portRunner, timeout: time.Second}
174 174
175 got, err := runner.run(context.Background(), "long-output", nil, barScriptContext{})175 got, err := runner.run(context.Background(), "long-output", nil, barScriptContext{})

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 12

Tags 11

↑↓ moveenter openesc close