detached 9182b6b

refactor(daemon): move bar script execution behind port

Signed-off-by: brice <b@bnema.dev>
commit
9182b6bf7fd91b1d5566234be6d8cef38d1ea147
author
brice · · 2026-07-06 16:36 UTC
parent
edf4b33
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +326 -83
A internal/adapters/shellcmd/runner.go +63 -0
@@ -0,0 +1,63 @@
1+package shellcmd
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "os/exec"
8+ "syscall"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+// Runner executes bounded shell commands for bar scripts and similar usecases.
14+type Runner struct{}
15+
16+// New returns a production shell command runner.
17+func New() Runner { return Runner{} }
18+
19+// Run executes spec.Command through sh -c, capturing bounded stdout and discarding stderr.
20+func (Runner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) {
21+ cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command)
22+ cmd.Env = spec.Env
23+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
24+ cmd.WaitDelay = spec.Timeout
25+ cmd.Cancel = func() error {
26+ if cmd.Process == nil {
27+ return nil
28+ }
29+ return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
30+ }
31+ var stdout boundedBuffer
32+ stdout.limit = spec.StdoutLimit
33+ cmd.Stdout = &stdout
34+ cmd.Stderr = io.Discard
35+ if err := cmd.Run(); err != nil {
36+ return stdout.Bytes(), err
37+ }
38+ return stdout.Bytes(), nil
39+}
40+
41+type boundedBuffer struct {
42+ buf bytes.Buffer
43+ limit int
44+}
45+
46+func (b *boundedBuffer) Write(p []byte) (int, error) {
47+ if b.limit <= 0 {
48+ return len(p), nil
49+ }
50+ remaining := b.limit - b.buf.Len()
51+ if remaining > 0 {
52+ if len(p) < remaining {
53+ remaining = len(p)
54+ }
55+ _, _ = b.buf.Write(p[:remaining])
56+ }
57+ return len(p), nil
58+}
59+
60+func (b *boundedBuffer) Bytes() []byte { return b.buf.Bytes() }
61+
62+var _ ports.ShellCommandRunner = Runner{}
63+var _ io.Writer = (*boundedBuffer)(nil)
@@ -0,0 +1,63 @@
1+package shellcmd
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "os/exec"
8+ "syscall"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+// Runner executes bounded shell commands for bar scripts and similar usecases.
14+type Runner struct{}
15+
16+// New returns a production shell command runner.
17+func New() Runner { return Runner{} }
18+
19+// Run executes spec.Command through sh -c, capturing bounded stdout and discarding stderr.
20+func (Runner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) {
21+ cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command)
22+ cmd.Env = spec.Env
23+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
24+ cmd.WaitDelay = spec.Timeout
25+ cmd.Cancel = func() error {
26+ if cmd.Process == nil {
27+ return nil
28+ }
29+ return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
30+ }
31+ var stdout boundedBuffer
32+ stdout.limit = spec.StdoutLimit
33+ cmd.Stdout = &stdout
34+ cmd.Stderr = io.Discard
35+ if err := cmd.Run(); err != nil {
36+ return stdout.Bytes(), err
37+ }
38+ return stdout.Bytes(), nil
39+}
40+
41+type boundedBuffer struct {
42+ buf bytes.Buffer
43+ limit int
44+}
45+
46+func (b *boundedBuffer) Write(p []byte) (int, error) {
47+ if b.limit <= 0 {
48+ return len(p), nil
49+ }
50+ remaining := b.limit - b.buf.Len()
51+ if remaining > 0 {
52+ if len(p) < remaining {
53+ remaining = len(p)
54+ }
55+ _, _ = b.buf.Write(p[:remaining])
56+ }
57+ return len(p), nil
58+}
59+
60+func (b *boundedBuffer) Bytes() []byte { return b.buf.Bytes() }
61+
62+var _ ports.ShellCommandRunner = Runner{}
63+var _ io.Writer = (*boundedBuffer)(nil)
A internal/adapters/shellcmd/runner_test.go +73 -0
@@ -0,0 +1,73 @@
1+package shellcmd
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+func TestRunnerRun(t *testing.T) {
14+ tests := []struct {
15+ name string
16+ command string
17+ limit int
18+ want string
19+ }{
20+ {name: "success", command: "printf 'ok'", limit: 1024, want: "ok"},
21+ {name: "bounded stdout", command: "yes a | head -c 4096", limit: 8, want: "a\na\na\na\n"},
22+ }
23+ for _, tt := range tests {
24+ t.Run(tt.name, func(t *testing.T) {
25+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
26+ defer cancel()
27+
28+ got, err := New().Run(ctx, ports.CommandSpec{Command: tt.command, Timeout: time.Second, StdoutLimit: tt.limit})
29+ if err != nil {
30+ t.Fatalf("Run() error = %v", err)
31+ }
32+ if string(got) != tt.want {
33+ t.Fatalf("Run() = %q, want %q", string(got), tt.want)
34+ }
35+ })
36+ }
37+}
38+
39+func TestRunnerRunBackgroundChildDoesNotWedgeAfterTimeout(t *testing.T) {
40+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
41+ defer cancel()
42+ start := time.Now()
43+
44+ _, err := New().Run(ctx, ports.CommandSpec{Command: "sleep 1 &", Timeout: 50 * time.Millisecond, StdoutLimit: 1024})
45+
46+ if err == nil {
47+ t.Fatal("Run() error = nil, want timeout or wait error")
48+ }
49+ if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
50+ t.Fatalf("context error = %v, want DeadlineExceeded", ctx.Err())
51+ }
52+ if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
53+ t.Fatalf("Run() elapsed = %s, want <= 500ms", elapsed)
54+ }
55+}
56+
57+func TestRunnerRunUsesProvidedEnvironment(t *testing.T) {
58+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
59+ defer cancel()
60+
61+ got, err := New().Run(ctx, ports.CommandSpec{
62+ Command: "printf '%s' \"$VEV_TEST_VALUE\"",
63+ Env: []string{"PATH=/bin:/usr/bin", "VEV_TEST_VALUE=from-env"},
64+ Timeout: time.Second,
65+ StdoutLimit: 1024,
66+ })
67+ if err != nil {
68+ t.Fatalf("Run() error = %v", err)
69+ }
70+ if strings.TrimSpace(string(got)) != "from-env" {
71+ t.Fatalf("Run() env output = %q, want from-env", string(got))
72+ }
73+}
@@ -0,0 +1,73 @@
1+package shellcmd
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+func TestRunnerRun(t *testing.T) {
14+ tests := []struct {
15+ name string
16+ command string
17+ limit int
18+ want string
19+ }{
20+ {name: "success", command: "printf 'ok'", limit: 1024, want: "ok"},
21+ {name: "bounded stdout", command: "yes a | head -c 4096", limit: 8, want: "a\na\na\na\n"},
22+ }
23+ for _, tt := range tests {
24+ t.Run(tt.name, func(t *testing.T) {
25+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
26+ defer cancel()
27+
28+ got, err := New().Run(ctx, ports.CommandSpec{Command: tt.command, Timeout: time.Second, StdoutLimit: tt.limit})
29+ if err != nil {
30+ t.Fatalf("Run() error = %v", err)
31+ }
32+ if string(got) != tt.want {
33+ t.Fatalf("Run() = %q, want %q", string(got), tt.want)
34+ }
35+ })
36+ }
37+}
38+
39+func TestRunnerRunBackgroundChildDoesNotWedgeAfterTimeout(t *testing.T) {
40+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
41+ defer cancel()
42+ start := time.Now()
43+
44+ _, err := New().Run(ctx, ports.CommandSpec{Command: "sleep 1 &", Timeout: 50 * time.Millisecond, StdoutLimit: 1024})
45+
46+ if err == nil {
47+ t.Fatal("Run() error = nil, want timeout or wait error")
48+ }
49+ if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
50+ t.Fatalf("context error = %v, want DeadlineExceeded", ctx.Err())
51+ }
52+ if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
53+ t.Fatalf("Run() elapsed = %s, want <= 500ms", elapsed)
54+ }
55+}
56+
57+func TestRunnerRunUsesProvidedEnvironment(t *testing.T) {
58+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
59+ defer cancel()
60+
61+ got, err := New().Run(ctx, ports.CommandSpec{
62+ Command: "printf '%s' \"$VEV_TEST_VALUE\"",
63+ Env: []string{"PATH=/bin:/usr/bin", "VEV_TEST_VALUE=from-env"},
64+ Timeout: time.Second,
65+ StdoutLimit: 1024,
66+ })
67+ if err != nil {
68+ t.Fatalf("Run() error = %v", err)
69+ }
70+ if strings.TrimSpace(string(got)) != "from-env" {
71+ t.Fatalf("Run() env output = %q, want from-env", string(got))
72+ }
73+}
M internal/app/run.go +2 -0
@@ -28,6 +28,7 @@ import (
2828 "github.com/bnema/vev/internal/adapters/dgram"
2929 "github.com/bnema/vev/internal/adapters/ipc"
3030 "github.com/bnema/vev/internal/adapters/pty"
31+ "github.com/bnema/vev/internal/adapters/shellcmd"
3132 snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot"
3233 "github.com/bnema/vev/internal/adapters/sshstdio"
3334 "github.com/bnema/vev/internal/adapters/term"
@@ -284,6 +285,7 @@ func runDaemon() error {
284285 }
285286 logConfigWarnings(log, warnings)
286287 daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))
288+ daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New()))
287289 daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome))
288290 daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir())))
289291 storePath := persist.StorePath(platform.StateDir())
@@ -28,6 +28,7 @@ import (
28 "github.com/bnema/vev/internal/adapters/dgram"28 "github.com/bnema/vev/internal/adapters/dgram"
29 "github.com/bnema/vev/internal/adapters/ipc"29 "github.com/bnema/vev/internal/adapters/ipc"
30 "github.com/bnema/vev/internal/adapters/pty"30 "github.com/bnema/vev/internal/adapters/pty"
31+ "github.com/bnema/vev/internal/adapters/shellcmd"
31 snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot"32 snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot"
32 "github.com/bnema/vev/internal/adapters/sshstdio"33 "github.com/bnema/vev/internal/adapters/sshstdio"
33 "github.com/bnema/vev/internal/adapters/term"34 "github.com/bnema/vev/internal/adapters/term"
@@ -284,6 +285,7 @@ func runDaemon() error {
284 }285 }
285 logConfigWarnings(log, warnings)286 logConfigWarnings(log, warnings)
286 daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))287 daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))
288+ daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New()))
287 daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome))289 daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome))
288 daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir())))290 daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir())))
289 storePath := persist.StorePath(platform.StateDir())291 storePath := persist.StorePath(platform.StateDir())
M internal/ports/mocks/mocks.go +95 -0
@@ -2068,6 +2068,101 @@ func (_c *MockTimer_Stop_Call) RunAndReturn(run func() bool) *MockTimer_Stop_Cal
20682068 return _c
20692069 }
20702070
2071+// NewMockShellCommandRunner creates a new instance of MockShellCommandRunner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2072+// The first argument is typically a *testing.T value.
2073+func NewMockShellCommandRunner(t interface {
2074+ mock.TestingT
2075+ Cleanup(func())
2076+}) *MockShellCommandRunner {
2077+ mock := &MockShellCommandRunner{}
2078+ mock.Mock.Test(t)
2079+
2080+ t.Cleanup(func() { mock.AssertExpectations(t) })
2081+
2082+ return mock
2083+}
2084+
2085+// MockShellCommandRunner is an autogenerated mock type for the ShellCommandRunner type
2086+type MockShellCommandRunner struct {
2087+ mock.Mock
2088+}
2089+
2090+type MockShellCommandRunner_Expecter struct {
2091+ mock *mock.Mock
2092+}
2093+
2094+func (_m *MockShellCommandRunner) EXPECT() *MockShellCommandRunner_Expecter {
2095+ return &MockShellCommandRunner_Expecter{mock: &_m.Mock}
2096+}
2097+
2098+// Run provides a mock function for the type MockShellCommandRunner
2099+func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) {
2100+ ret := _mock.Called(ctx, spec)
2101+
2102+ if len(ret) == 0 {
2103+ panic("no return value specified for Run")
2104+ }
2105+
2106+ var r0 []byte
2107+ var r1 error
2108+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ([]byte, error)); ok {
2109+ return returnFunc(ctx, spec)
2110+ }
2111+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) []byte); ok {
2112+ r0 = returnFunc(ctx, spec)
2113+ } else {
2114+ if ret.Get(0) != nil {
2115+ r0 = ret.Get(0).([]byte)
2116+ }
2117+ }
2118+ if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok {
2119+ r1 = returnFunc(ctx, spec)
2120+ } else {
2121+ r1 = ret.Error(1)
2122+ }
2123+ return r0, r1
2124+}
2125+
2126+// MockShellCommandRunner_Run_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Run'
2127+type MockShellCommandRunner_Run_Call struct {
2128+ *mock.Call
2129+}
2130+
2131+// Run is a helper method to define mock.On call
2132+// - ctx context.Context
2133+// - spec ports.CommandSpec
2134+func (_e *MockShellCommandRunner_Expecter) Run(ctx any, spec any) *MockShellCommandRunner_Run_Call {
2135+ return &MockShellCommandRunner_Run_Call{Call: _e.mock.On("Run", ctx, spec)}
2136+}
2137+
2138+func (_c *MockShellCommandRunner_Run_Call) Run(run func(ctx context.Context, spec ports.CommandSpec)) *MockShellCommandRunner_Run_Call {
2139+ _c.Call.Run(func(args mock.Arguments) {
2140+ var arg0 context.Context
2141+ if args[0] != nil {
2142+ arg0 = args[0].(context.Context)
2143+ }
2144+ var arg1 ports.CommandSpec
2145+ if args[1] != nil {
2146+ arg1 = args[1].(ports.CommandSpec)
2147+ }
2148+ run(
2149+ arg0,
2150+ arg1,
2151+ )
2152+ })
2153+ return _c
2154+}
2155+
2156+func (_c *MockShellCommandRunner_Run_Call) Return(bytes []byte, err error) *MockShellCommandRunner_Run_Call {
2157+ _c.Call.Return(bytes, err)
2158+ return _c
2159+}
2160+
2161+func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) ([]byte, error)) *MockShellCommandRunner_Run_Call {
2162+ _c.Call.Return(run)
2163+ return _c
2164+}
2165+
20712166 // NewMockProcessInspector creates a new instance of MockProcessInspector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
20722167 // The first argument is typically a *testing.T value.
20732168 func NewMockProcessInspector(t interface {
@@ -2068,6 +2068,101 @@ func (_c *MockTimer_Stop_Call) RunAndReturn(run func() bool) *MockTimer_Stop_Cal
2068 return _c2068 return _c
2069 }2069 }
2070 2070
2071+// NewMockShellCommandRunner creates a new instance of MockShellCommandRunner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2072+// The first argument is typically a *testing.T value.
2073+func NewMockShellCommandRunner(t interface {
2074+ mock.TestingT
2075+ Cleanup(func())
2076+}) *MockShellCommandRunner {
2077+ mock := &MockShellCommandRunner{}
2078+ mock.Mock.Test(t)
2079+
2080+ t.Cleanup(func() { mock.AssertExpectations(t) })
2081+
2082+ return mock
2083+}
2084+
2085+// MockShellCommandRunner is an autogenerated mock type for the ShellCommandRunner type
2086+type MockShellCommandRunner struct {
2087+ mock.Mock
2088+}
2089+
2090+type MockShellCommandRunner_Expecter struct {
2091+ mock *mock.Mock
2092+}
2093+
2094+func (_m *MockShellCommandRunner) EXPECT() *MockShellCommandRunner_Expecter {
2095+ return &MockShellCommandRunner_Expecter{mock: &_m.Mock}
2096+}
2097+
2098+// Run provides a mock function for the type MockShellCommandRunner
2099+func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) {
2100+ ret := _mock.Called(ctx, spec)
2101+
2102+ if len(ret) == 0 {
2103+ panic("no return value specified for Run")
2104+ }
2105+
2106+ var r0 []byte
2107+ var r1 error
2108+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ([]byte, error)); ok {
2109+ return returnFunc(ctx, spec)
2110+ }
2111+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) []byte); ok {
2112+ r0 = returnFunc(ctx, spec)
2113+ } else {
2114+ if ret.Get(0) != nil {
2115+ r0 = ret.Get(0).([]byte)
2116+ }
2117+ }
2118+ if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok {
2119+ r1 = returnFunc(ctx, spec)
2120+ } else {
2121+ r1 = ret.Error(1)
2122+ }
2123+ return r0, r1
2124+}
2125+
2126+// MockShellCommandRunner_Run_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Run'
2127+type MockShellCommandRunner_Run_Call struct {
2128+ *mock.Call
2129+}
2130+
2131+// Run is a helper method to define mock.On call
2132+// - ctx context.Context
2133+// - spec ports.CommandSpec
2134+func (_e *MockShellCommandRunner_Expecter) Run(ctx any, spec any) *MockShellCommandRunner_Run_Call {
2135+ return &MockShellCommandRunner_Run_Call{Call: _e.mock.On("Run", ctx, spec)}
2136+}
2137+
2138+func (_c *MockShellCommandRunner_Run_Call) Run(run func(ctx context.Context, spec ports.CommandSpec)) *MockShellCommandRunner_Run_Call {
2139+ _c.Call.Run(func(args mock.Arguments) {
2140+ var arg0 context.Context
2141+ if args[0] != nil {
2142+ arg0 = args[0].(context.Context)
2143+ }
2144+ var arg1 ports.CommandSpec
2145+ if args[1] != nil {
2146+ arg1 = args[1].(ports.CommandSpec)
2147+ }
2148+ run(
2149+ arg0,
2150+ arg1,
2151+ )
2152+ })
2153+ return _c
2154+}
2155+
2156+func (_c *MockShellCommandRunner_Run_Call) Return(bytes []byte, err error) *MockShellCommandRunner_Run_Call {
2157+ _c.Call.Return(bytes, err)
2158+ return _c
2159+}
2160+
2161+func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) ([]byte, error)) *MockShellCommandRunner_Run_Call {
2162+ _c.Call.Return(run)
2163+ return _c
2164+}
2165+
2071 // NewMockProcessInspector creates a new instance of MockProcessInspector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.2166 // NewMockProcessInspector creates a new instance of MockProcessInspector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2072 // The first argument is typically a *testing.T value.2167 // The first argument is typically a *testing.T value.
2073 func NewMockProcessInspector(t interface {2168 func NewMockProcessInspector(t interface {
M internal/ports/ports.go +13 -0
@@ -96,6 +96,19 @@ type Timer interface {
9696 Stop() bool
9797 }
9898
99+// CommandSpec describes a bounded shell command execution request.
100+type CommandSpec struct {
101+ Command string
102+ Env []string
103+ Timeout time.Duration
104+ StdoutLimit int
105+}
106+
107+// ShellCommandRunner runs shell commands and returns bounded stdout bytes.
108+type ShellCommandRunner interface {
109+ Run(ctx context.Context, spec CommandSpec) ([]byte, error)
110+}
111+
99112 // ProcessInspector reads process metadata for daemon snapshot/persistence use.
100113 // Implementations may live in platform packages; usecases depend on this port.
101114 type ProcessInspector interface {
@@ -96,6 +96,19 @@ type Timer interface {
96 Stop() bool96 Stop() bool
97 }97 }
98 98
99+// CommandSpec describes a bounded shell command execution request.
100+type CommandSpec struct {
101+ Command string
102+ Env []string
103+ Timeout time.Duration
104+ StdoutLimit int
105+}
106+
107+// ShellCommandRunner runs shell commands and returns bounded stdout bytes.
108+type ShellCommandRunner interface {
109+ Run(ctx context.Context, spec CommandSpec) ([]byte, error)
110+}
111+
99 // ProcessInspector reads process metadata for daemon snapshot/persistence use.112 // ProcessInspector reads process metadata for daemon snapshot/persistence use.
100 // Implementations may live in platform packages; usecases depend on this port.113 // Implementations may live in platform packages; usecases depend on this port.
101 type ProcessInspector interface {114 type ProcessInspector interface {
M internal/usecase/daemon/bar_script.go +13 -43
@@ -4,13 +4,12 @@ import (
44 "bytes"
55 "context"
66 "errors"
7- "io"
8- "os/exec"
97 "strconv"
108 "strings"
11- "syscall"
129 "time"
1310 "unicode/utf8"
11+
12+ "github.com/bnema/vev/internal/ports"
1413 )
1514
1615 const (
@@ -54,6 +53,7 @@ func (c barScriptContext) env(base []string) []string {
5453 }
5554
5655 type barScriptRunner struct {
56+ runner ports.ShellCommandRunner
5757 timeout time.Duration
5858 baseEnv []string
5959 }
@@ -62,6 +62,9 @@ func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barS
6262 if strings.TrimSpace(command) == "" {
6363 return "", nil
6464 }
65+ if r.runner == nil {
66+ return "", errors.New("bar script command runner is nil")
67+ }
6568 timeout := r.timeout
6669 if timeout <= 0 {
6770 timeout = barScriptTimeout
@@ -69,53 +72,22 @@ func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barS
6972 ctx, cancel := context.WithTimeout(ctx, timeout)
7073 defer cancel()
7174
72- cmd := exec.CommandContext(ctx, "sh", "-c", command)
73- cmd.Env = scriptCtx.env(r.baseEnv)
74- cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
75- cmd.WaitDelay = timeout
76- cmd.Cancel = func() error {
77- if cmd.Process == nil {
78- return nil
79- }
80- return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
81- }
82- var stdout boundedBuffer
83- stdout.limit = barScriptOutputLimit
84- cmd.Stdout = &stdout
85- cmd.Stderr = io.Discard
86-
87- err := cmd.Run()
75+ stdout, err := r.runner.Run(ctx, ports.CommandSpec{
76+ Command: command,
77+ Env: scriptCtx.env(r.baseEnv),
78+ Timeout: timeout,
79+ StdoutLimit: barScriptOutputLimit,
80+ })
8881 if errors.Is(ctx.Err(), context.DeadlineExceeded) {
8982 return "", context.DeadlineExceeded
9083 }
91- text := sanitizeBarScriptOutput(stdout.Bytes(), barScriptOutputLimit)
84+ text := sanitizeBarScriptOutput(stdout, barScriptOutputLimit)
9285 if err != nil {
9386 return text, err
9487 }
9588 return text, nil
9689 }
9790
98-type boundedBuffer struct {
99- buf bytes.Buffer
100- limit int
101-}
102-
103-func (b *boundedBuffer) Write(p []byte) (int, error) {
104- if b.limit <= 0 {
105- return len(p), nil
106- }
107- remaining := b.limit - b.buf.Len()
108- if remaining > 0 {
109- if len(p) < remaining {
110- remaining = len(p)
111- }
112- _, _ = b.buf.Write(p[:remaining])
113- }
114- return len(p), nil
115-}
116-
117-func (b *boundedBuffer) Bytes() []byte { return b.buf.Bytes() }
118-
11991 func sanitizeBarScriptOutput(raw []byte, limit int) string {
12092 if limit <= 0 || limit > len(raw) {
12193 limit = len(raw)
@@ -209,5 +181,3 @@ func trimUTF8Bytes(s string, limit int) string {
209181 }
210182 return s[:limit]
211183 }
212-
213-var _ io.Writer = (*boundedBuffer)(nil)
@@ -4,13 +4,12 @@ import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6 "errors"6 "errors"
7- "io"
8- "os/exec"
9 "strconv"7 "strconv"
10 "strings"8 "strings"
11- "syscall"
12 "time"9 "time"
13 "unicode/utf8"10 "unicode/utf8"
11+
12+ "github.com/bnema/vev/internal/ports"
14 )13 )
15 14
16 const (15 const (
@@ -54,6 +53,7 @@ func (c barScriptContext) env(base []string) []string {
54 }53 }
55 54
56 type barScriptRunner struct {55 type barScriptRunner struct {
56+ runner ports.ShellCommandRunner
57 timeout time.Duration57 timeout time.Duration
58 baseEnv []string58 baseEnv []string
59 }59 }
@@ -62,6 +62,9 @@ func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barS
62 if strings.TrimSpace(command) == "" {62 if strings.TrimSpace(command) == "" {
63 return "", nil63 return "", nil
64 }64 }
65+ if r.runner == nil {
66+ return "", errors.New("bar script command runner is nil")
67+ }
65 timeout := r.timeout68 timeout := r.timeout
66 if timeout <= 0 {69 if timeout <= 0 {
67 timeout = barScriptTimeout70 timeout = barScriptTimeout
@@ -69,53 +72,22 @@ func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barS
69 ctx, cancel := context.WithTimeout(ctx, timeout)72 ctx, cancel := context.WithTimeout(ctx, timeout)
70 defer cancel()73 defer cancel()
71 74
72- cmd := exec.CommandContext(ctx, "sh", "-c", command)75+ stdout, err := r.runner.Run(ctx, ports.CommandSpec{
73- cmd.Env = scriptCtx.env(r.baseEnv)76+ Command: command,
74- cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}77+ Env: scriptCtx.env(r.baseEnv),
75- cmd.WaitDelay = timeout78+ Timeout: timeout,
76- cmd.Cancel = func() error {79+ StdoutLimit: barScriptOutputLimit,
77- if cmd.Process == nil {80+ })
78- return nil
79- }
80- return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
81- }
82- var stdout boundedBuffer
83- stdout.limit = barScriptOutputLimit
84- cmd.Stdout = &stdout
85- cmd.Stderr = io.Discard
86-
87- err := cmd.Run()
88 if errors.Is(ctx.Err(), context.DeadlineExceeded) {81 if errors.Is(ctx.Err(), context.DeadlineExceeded) {
89 return "", context.DeadlineExceeded82 return "", context.DeadlineExceeded
90 }83 }
91- text := sanitizeBarScriptOutput(stdout.Bytes(), barScriptOutputLimit)84+ text := sanitizeBarScriptOutput(stdout, barScriptOutputLimit)
92 if err != nil {85 if err != nil {
93 return text, err86 return text, err
94 }87 }
95 return text, nil88 return text, nil
96 }89 }
97 90
98-type boundedBuffer struct {
99- buf bytes.Buffer
100- limit int
101-}
102-
103-func (b *boundedBuffer) Write(p []byte) (int, error) {
104- if b.limit <= 0 {
105- return len(p), nil
106- }
107- remaining := b.limit - b.buf.Len()
108- if remaining > 0 {
109- if len(p) < remaining {
110- remaining = len(p)
111- }
112- _, _ = b.buf.Write(p[:remaining])
113- }
114- return len(p), nil
115-}
116-
117-func (b *boundedBuffer) Bytes() []byte { return b.buf.Bytes() }
118-
119 func sanitizeBarScriptOutput(raw []byte, limit int) string {91 func sanitizeBarScriptOutput(raw []byte, limit int) string {
120 if limit <= 0 || limit > len(raw) {92 if limit <= 0 || limit > len(raw) {
121 limit = len(raw)93 limit = len(raw)
@@ -209,5 +181,3 @@ func trimUTF8Bytes(s string, limit int) string {
209 }181 }
210 return s[:limit]182 return s[:limit]
211 }183 }
212-
213-var _ io.Writer = (*boundedBuffer)(nil)
M internal/usecase/daemon/bar_script_test.go +58 -39
@@ -3,10 +3,14 @@ package daemon
33 import (
44 "context"
55 "errors"
6- "os"
6+ "slices"
77 "strings"
88 "testing"
99 "time"
10+
11+ "github.com/bnema/vev/internal/ports"
12+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
13+ "github.com/stretchr/testify/mock"
1014 )
1115
1216 func TestBarScriptSanitizeOutput(t *testing.T) {
@@ -79,49 +83,60 @@ func TestBarScriptContextEnv(t *testing.T) {
7983 }
8084
8185 func TestBarScriptRunner(t *testing.T) {
82- runner := barScriptRunner{timeout: 50 * time.Millisecond, baseEnv: os.Environ()}
83- tests := []struct {
84- name string
85- command string
86- want string
87- wantErr error
88- }{
89- {name: "empty command", command: "", want: ""},
90- {name: "success first line", command: "printf 'ok\\nignored'", want: "ok"},
91- {name: "timeout", command: "sleep 1; printf late", wantErr: context.DeadlineExceeded},
92- }
93- for _, tt := range tests {
94- t.Run(tt.name, func(t *testing.T) {
95- got, err := runner.run(context.Background(), tt.command, barScriptContext{})
96- if tt.wantErr != nil {
97- if !errors.Is(err, tt.wantErr) {
98- t.Fatalf("run err = %v, want %v", err, tt.wantErr)
99- }
100- } else if err != nil {
101- t.Fatalf("run err = %v, want nil", err)
102- }
103- if got != tt.want {
104- t.Fatalf("run output = %q, want %q", got, tt.want)
105- }
86+ t.Run("empty command skips port runner", func(t *testing.T) {
87+ runner := barScriptRunner{}
88+ got, err := runner.run(context.Background(), "", barScriptContext{})
89+ if err != nil {
90+ t.Fatalf("run err = %v, want nil", err)
91+ }
92+ if got != "" {
93+ t.Fatalf("run output = %q, want empty", got)
94+ }
95+ })
96+
97+ t.Run("success sanitizes first line and passes command spec", func(t *testing.T) {
98+ portRunner := portsmocks.NewMockShellCommandRunner(t)
99+ portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool {
100+ return spec.Command == "printf 'ok\\nignored'" &&
101+ spec.Timeout == 50*time.Millisecond &&
102+ spec.StdoutLimit == barScriptOutputLimit &&
103+ containsEnv(spec.Env, "VEV_ANCHOR=top-right") &&
104+ containsEnv(spec.Env, "VEV_COLS=120")
105+ })).Return([]byte("ok\nignored"), nil)
106+ runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond, baseEnv: []string{"PATH=/bin", "VEV_ANCHOR=old"}}
107+
108+ got, err := runner.run(context.Background(), "printf 'ok\\nignored'", barScriptContext{Anchor: "top-right", Cols: 120})
109+ if err != nil {
110+ t.Fatalf("run err = %v, want nil", err)
111+ }
112+ if got != "ok" {
113+ t.Fatalf("run output = %q, want %q", got, "ok")
114+ }
115+ })
116+
117+ t.Run("timeout propagates deadline", func(t *testing.T) {
118+ portRunner := portsmocks.NewMockShellCommandRunner(t)
119+ portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.CommandSpec) ([]byte, error) {
120+ <-ctx.Done()
121+ return nil, ctx.Err()
106122 })
107- }
108-}
123+ runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond}
109124
110-func TestBarScriptRunnerBackgroundChildStdoutTimeout(t *testing.T) {
111- runner := barScriptRunner{timeout: 50 * time.Millisecond, baseEnv: os.Environ()}
112- start := time.Now()
113- _, err := runner.run(context.Background(), "sleep 1 &", barScriptContext{})
114- if err == nil {
115- t.Fatalf("background child run err = nil, want timeout/wait error")
116- }
117- if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
118- t.Fatalf("background child run elapsed = %s, want <= 500ms", elapsed)
119- }
125+ _, err := runner.run(context.Background(), "sleep 1", barScriptContext{})
126+ if !errors.Is(err, context.DeadlineExceeded) {
127+ t.Fatalf("run err = %v, want %v", err, context.DeadlineExceeded)
128+ }
129+ })
120130 }
121131
122132 func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) {
123- runner := barScriptRunner{timeout: time.Second, baseEnv: os.Environ()}
124- got, err := runner.run(context.Background(), "yes a | head -c 4096", barScriptContext{})
133+ portRunner := portsmocks.NewMockShellCommandRunner(t)
134+ portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool {
135+ return spec.StdoutLimit == barScriptOutputLimit
136+ })).Return([]byte(strings.Repeat("a", 4096)), nil)
137+ runner := barScriptRunner{runner: portRunner, timeout: time.Second}
138+
139+ got, err := runner.run(context.Background(), "long-output", barScriptContext{})
125140 if err != nil {
126141 t.Fatalf("run bounded output: %v", err)
127142 }
@@ -129,3 +144,7 @@ func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) {
129144 t.Fatalf("output length = %d, want <= %d", len(got), barScriptDisplayLimit)
130145 }
131146 }
147+
148+func containsEnv(env []string, entry string) bool {
149+ return slices.Contains(env, entry)
150+}
@@ -3,10 +3,14 @@ package daemon
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6- "os"6+ "slices"
7 "strings"7 "strings"
8 "testing"8 "testing"
9 "time"9 "time"
10+
11+ "github.com/bnema/vev/internal/ports"
12+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
13+ "github.com/stretchr/testify/mock"
10 )14 )
11 15
12 func TestBarScriptSanitizeOutput(t *testing.T) {16 func TestBarScriptSanitizeOutput(t *testing.T) {
@@ -79,49 +83,60 @@ func TestBarScriptContextEnv(t *testing.T) {
79 }83 }
80 84
81 func TestBarScriptRunner(t *testing.T) {85 func TestBarScriptRunner(t *testing.T) {
82- runner := barScriptRunner{timeout: 50 * time.Millisecond, baseEnv: os.Environ()}86+ t.Run("empty command skips port runner", func(t *testing.T) {
83- tests := []struct {87+ runner := barScriptRunner{}
84- name string88+ got, err := runner.run(context.Background(), "", barScriptContext{})
85- command string89+ if err != nil {
86- want string90+ t.Fatalf("run err = %v, want nil", err)
87- wantErr error91+ }
88- }{92+ if got != "" {
89- {name: "empty command", command: "", want: ""},93+ t.Fatalf("run output = %q, want empty", got)
90- {name: "success first line", command: "printf 'ok\\nignored'", want: "ok"},94+ }
91- {name: "timeout", command: "sleep 1; printf late", wantErr: context.DeadlineExceeded},95+ })
92- }96+
93- for _, tt := range tests {97+ t.Run("success sanitizes first line and passes command spec", func(t *testing.T) {
94- t.Run(tt.name, func(t *testing.T) {98+ portRunner := portsmocks.NewMockShellCommandRunner(t)
95- got, err := runner.run(context.Background(), tt.command, barScriptContext{})99+ portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool {
96- if tt.wantErr != nil {100+ return spec.Command == "printf 'ok\\nignored'" &&
97- if !errors.Is(err, tt.wantErr) {101+ spec.Timeout == 50*time.Millisecond &&
98- t.Fatalf("run err = %v, want %v", err, tt.wantErr)102+ spec.StdoutLimit == barScriptOutputLimit &&
99- }103+ containsEnv(spec.Env, "VEV_ANCHOR=top-right") &&
100- } else if err != nil {104+ containsEnv(spec.Env, "VEV_COLS=120")
101- t.Fatalf("run err = %v, want nil", err)105+ })).Return([]byte("ok\nignored"), nil)
102- }106+ runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond, baseEnv: []string{"PATH=/bin", "VEV_ANCHOR=old"}}
103- if got != tt.want {107+
104- t.Fatalf("run output = %q, want %q", got, tt.want)108+ got, err := runner.run(context.Background(), "printf 'ok\\nignored'", barScriptContext{Anchor: "top-right", Cols: 120})
105- }109+ if err != nil {
110+ t.Fatalf("run err = %v, want nil", err)
111+ }
112+ if got != "ok" {
113+ t.Fatalf("run output = %q, want %q", got, "ok")
114+ }
115+ })
116+
117+ t.Run("timeout propagates deadline", func(t *testing.T) {
118+ portRunner := portsmocks.NewMockShellCommandRunner(t)
119+ portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.CommandSpec) ([]byte, error) {
120+ <-ctx.Done()
121+ return nil, ctx.Err()
106 })122 })
107- }123+ runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond}
108-}
109 124
110-func TestBarScriptRunnerBackgroundChildStdoutTimeout(t *testing.T) {125+ _, err := runner.run(context.Background(), "sleep 1", barScriptContext{})
111- runner := barScriptRunner{timeout: 50 * time.Millisecond, baseEnv: os.Environ()}126+ if !errors.Is(err, context.DeadlineExceeded) {
112- start := time.Now()127+ t.Fatalf("run err = %v, want %v", err, context.DeadlineExceeded)
113- _, err := runner.run(context.Background(), "sleep 1 &", barScriptContext{})128+ }
114- if err == nil {129+ })
115- t.Fatalf("background child run err = nil, want timeout/wait error")
116- }
117- if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
118- t.Fatalf("background child run elapsed = %s, want <= 500ms", elapsed)
119- }
120 }130 }
121 131
122 func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) {132 func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) {
123- runner := barScriptRunner{timeout: time.Second, baseEnv: os.Environ()}133+ portRunner := portsmocks.NewMockShellCommandRunner(t)
124- got, err := runner.run(context.Background(), "yes a | head -c 4096", barScriptContext{})134+ portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool {
135+ return spec.StdoutLimit == barScriptOutputLimit
136+ })).Return([]byte(strings.Repeat("a", 4096)), nil)
137+ runner := barScriptRunner{runner: portRunner, timeout: time.Second}
138+
139+ got, err := runner.run(context.Background(), "long-output", barScriptContext{})
125 if err != nil {140 if err != nil {
126 t.Fatalf("run bounded output: %v", err)141 t.Fatalf("run bounded output: %v", err)
127 }142 }
@@ -129,3 +144,7 @@ func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) {
129 t.Fatalf("output length = %d, want <= %d", len(got), barScriptDisplayLimit)144 t.Fatalf("output length = %d, want <= %d", len(got), barScriptDisplayLimit)
130 }145 }
131 }146 }
147+
148+func containsEnv(env []string, entry string) bool {
149+ return slices.Contains(env, entry)
150+}
M internal/usecase/daemon/daemon.go +9 -1
@@ -225,6 +225,15 @@ func WithTempDir(dir string) Option {
225225 }
226226 }
227227
228+// WithBarScriptCommandRunner installs the shell command runner used by bar scripts.
229+func WithBarScriptCommandRunner(runner ports.ShellCommandRunner) Option {
230+ return func(d *Daemon) {
231+ if runner != nil {
232+ d.barScripts.runner = barScriptRunner{runner: runner, baseEnv: d.baseEnv}
233+ }
234+ }
235+}
236+
228237 // WithConfig applies the initial user configuration.
229238 func WithConfig(cfg domain.Config) Option {
230239 return func(d *Daemon) {
@@ -258,7 +267,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
258267 animWake: make(chan struct{}, 1),
259268 barScripts: &barScriptState{
260269 cfg: barConfigFromDomain(domain.Defaults().Bar),
261- runner: barScriptRunner{baseEnv: os.Environ()},
262270 outputs: make(map[domain.SessionID]barScriptOutputs),
263271 lastRefresh: make(map[domain.SessionID]time.Time),
264272 lastContext: make(map[domain.SessionID]barScriptContext),
@@ -225,6 +225,15 @@ func WithTempDir(dir string) Option {
225 }225 }
226 }226 }
227 227
228+// WithBarScriptCommandRunner installs the shell command runner used by bar scripts.
229+func WithBarScriptCommandRunner(runner ports.ShellCommandRunner) Option {
230+ return func(d *Daemon) {
231+ if runner != nil {
232+ d.barScripts.runner = barScriptRunner{runner: runner, baseEnv: d.baseEnv}
233+ }
234+ }
235+}
236+
228 // WithConfig applies the initial user configuration.237 // WithConfig applies the initial user configuration.
229 func WithConfig(cfg domain.Config) Option {238 func WithConfig(cfg domain.Config) Option {
230 return func(d *Daemon) {239 return func(d *Daemon) {
@@ -258,7 +267,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
258 animWake: make(chan struct{}, 1),267 animWake: make(chan struct{}, 1),
259 barScripts: &barScriptState{268 barScripts: &barScriptState{
260 cfg: barConfigFromDomain(domain.Defaults().Bar),269 cfg: barConfigFromDomain(domain.Defaults().Bar),
261- runner: barScriptRunner{baseEnv: os.Environ()},
262 outputs: make(map[domain.SessionID]barScriptOutputs),270 outputs: make(map[domain.SessionID]barScriptOutputs),
263 lastRefresh: make(map[domain.SessionID]time.Time),271 lastRefresh: make(map[domain.SessionID]time.Time),
264 lastContext: make(map[domain.SessionID]barScriptContext),272 lastContext: make(map[domain.SessionID]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