detached 596dbfb

refactor(remote): add transport selection port

commit
596dbfb8e56b833c54f6b821708fab291f34e95c
author
brice · · 2026-07-06 20:49 UTC
parent
fd55831
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +275 -46
M internal/app/run.go +64 -17
@@ -326,26 +326,54 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
326326 defer func() { _ = logCloser.Close() }()
327327
328328 return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{
329- localDialer: defaultLocalDialer,
330- remoteDialer: defaultRemoteDialer,
331- runClient: client.Run,
332- createDetached: createDetachedLocalSession,
333- clipboard: clipboard.New(),
329+ localDialer: defaultLocalDialer,
330+ remoteDialerFactory: defaultRemoteDialerFactory(),
331+ selectedRemoteTransport: os.Getenv(envRemoteTransport),
332+ runClient: client.Run,
333+ createDetached: createDetachedLocalSession,
334+ clipboard: clipboard.New(),
334335 })
335336 }
336337
338+const envRemoteTransport = "VEV_REMOTE_TRANSPORT"
339+
337340 func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} }
338341
339-func defaultRemoteDialer(target, session string, log *slog.Logger) ports.Dialer {
340- return dgram.NewRemoteDialerWithLogger(target, session, log)
342+func defaultRemoteDialerFactory() ports.RemoteDialerFactory { return remoteDialerFactory{} }
343+
344+type remoteDialerFactory struct{}
345+
346+func (remoteDialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) {
347+ switch mode {
348+ case ports.RemoteTransportUDP:
349+ return dgram.NewRemoteDialerWithLogger(target, session, log), nil
350+ case ports.RemoteTransportStdio:
351+ if log != nil {
352+ log.Info("remote transport selected", "mode", string(ports.RemoteTransportStdio))
353+ }
354+ return sshStdioDialer{target: target, session: session, log: log}, nil
355+ default:
356+ return nil, fmt.Errorf("vev: unsupported remote transport %q", mode)
357+ }
358+}
359+
360+type sshStdioDialer struct {
361+ target string
362+ session string
363+ log *slog.Logger
364+}
365+
366+func (d sshStdioDialer) Dial(ctx context.Context) (ports.Transport, error) {
367+ return sshstdio.DialContext(ctx, d.target, d.session, d.log)
341368 }
342369
343370 type runAttachDeps struct {
344- attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests
345- localDialer func() ports.Dialer
346- remoteDialer func(target, session string, log *slog.Logger) ports.Dialer
347- runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error
348- createDetached func(context.Context, string) error
371+ attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests
372+ localDialer func() ports.Dialer
373+ remoteDialerFactory ports.RemoteDialerFactory
374+ selectedRemoteTransport string
375+ runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error
376+ createDetached func(context.Context, string) error
349377 // clipboard reads a clipboard image on a remote attach's Ctrl+V (see
350378 // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
351379 // Only used for the remote-dialer branch below; local attaches never
@@ -353,6 +381,17 @@ type runAttachDeps struct {
353381 clipboard ports.ClipboardReader
354382 }
355383
384+func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error) {
385+ switch value {
386+ case "", string(ports.RemoteTransportUDP):
387+ return ports.RemoteTransportUDP, nil
388+ case string(ports.RemoteTransportStdio):
389+ return ports.RemoteTransportStdio, nil
390+ default:
391+ return "", fmt.Errorf("vev: invalid remote transport %q (want %q or %q)", value, ports.RemoteTransportUDP, ports.RemoteTransportStdio)
392+ }
393+}
394+
356395 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {
357396 if activeSession != "" {
358397 if remoteTarget == "" && intent == ports.IntentNew {
@@ -366,14 +405,22 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
366405 runClient = client.Run
367406 }
368407 if remoteTarget != "" {
369- remoteDialer := deps.remoteDialer
370- if remoteDialer == nil {
371- remoteDialer = defaultRemoteDialer
408+ mode, err := remoteTransportModeFromEnv(deps.selectedRemoteTransport)
409+ if err != nil {
410+ return err
411+ }
412+ factory := deps.remoteDialerFactory
413+ if factory == nil {
414+ factory = defaultRemoteDialerFactory()
372415 }
373416 if log != nil {
374- log.Info("attaching to remote session", "target", remoteTarget, "name", name)
417+ log.Info("attaching to remote session", "target", remoteTarget, "name", name, "transport", string(mode))
418+ }
419+ dialer, err := factory.DialerForRemote(remoteTarget, name, mode, log)
420+ if err != nil {
421+ return err
375422 }
376- return runClient(ctx, remoteDialer(remoteTarget, name, log), term.New(), clock.New(), intent, name, true, deps.clipboard, log)
423+ return runClient(ctx, dialer, term.New(), clock.New(), intent, name, true, deps.clipboard, log)
377424 }
378425
379426 attachLocal := deps.attachLocal
@@ -326,26 +326,54 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
326 defer func() { _ = logCloser.Close() }()326 defer func() { _ = logCloser.Close() }()
327 327
328 return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{328 return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{
329- localDialer: defaultLocalDialer,329+ localDialer: defaultLocalDialer,
330- remoteDialer: defaultRemoteDialer,330+ remoteDialerFactory: defaultRemoteDialerFactory(),
331- runClient: client.Run,331+ selectedRemoteTransport: os.Getenv(envRemoteTransport),
332- createDetached: createDetachedLocalSession,332+ runClient: client.Run,
333- clipboard: clipboard.New(),333+ createDetached: createDetachedLocalSession,
334+ clipboard: clipboard.New(),
334 })335 })
335 }336 }
336 337
338+const envRemoteTransport = "VEV_REMOTE_TRANSPORT"
339+
337 func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} }340 func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} }
338 341
339-func defaultRemoteDialer(target, session string, log *slog.Logger) ports.Dialer {342+func defaultRemoteDialerFactory() ports.RemoteDialerFactory { return remoteDialerFactory{} }
340- return dgram.NewRemoteDialerWithLogger(target, session, log)343+
344+type remoteDialerFactory struct{}
345+
346+func (remoteDialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) {
347+ switch mode {
348+ case ports.RemoteTransportUDP:
349+ return dgram.NewRemoteDialerWithLogger(target, session, log), nil
350+ case ports.RemoteTransportStdio:
351+ if log != nil {
352+ log.Info("remote transport selected", "mode", string(ports.RemoteTransportStdio))
353+ }
354+ return sshStdioDialer{target: target, session: session, log: log}, nil
355+ default:
356+ return nil, fmt.Errorf("vev: unsupported remote transport %q", mode)
357+ }
358+}
359+
360+type sshStdioDialer struct {
361+ target string
362+ session string
363+ log *slog.Logger
364+}
365+
366+func (d sshStdioDialer) Dial(ctx context.Context) (ports.Transport, error) {
367+ return sshstdio.DialContext(ctx, d.target, d.session, d.log)
341 }368 }
342 369
343 type runAttachDeps struct {370 type runAttachDeps struct {
344- attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests371+ attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests
345- localDialer func() ports.Dialer372+ localDialer func() ports.Dialer
346- remoteDialer func(target, session string, log *slog.Logger) ports.Dialer373+ remoteDialerFactory ports.RemoteDialerFactory
347- runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error374+ selectedRemoteTransport string
348- createDetached func(context.Context, string) error375+ runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error
376+ createDetached func(context.Context, string) error
349 // clipboard reads a clipboard image on a remote attach's Ctrl+V (see377 // clipboard reads a clipboard image on a remote attach's Ctrl+V (see
350 // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).378 // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
351 // Only used for the remote-dialer branch below; local attaches never379 // Only used for the remote-dialer branch below; local attaches never
@@ -353,6 +381,17 @@ type runAttachDeps struct {
353 clipboard ports.ClipboardReader381 clipboard ports.ClipboardReader
354 }382 }
355 383
384+func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error) {
385+ switch value {
386+ case "", string(ports.RemoteTransportUDP):
387+ return ports.RemoteTransportUDP, nil
388+ case string(ports.RemoteTransportStdio):
389+ return ports.RemoteTransportStdio, nil
390+ default:
391+ return "", fmt.Errorf("vev: invalid remote transport %q (want %q or %q)", value, ports.RemoteTransportUDP, ports.RemoteTransportStdio)
392+ }
393+}
394+
356 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {395 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {
357 if activeSession != "" {396 if activeSession != "" {
358 if remoteTarget == "" && intent == ports.IntentNew {397 if remoteTarget == "" && intent == ports.IntentNew {
@@ -366,14 +405,22 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
366 runClient = client.Run405 runClient = client.Run
367 }406 }
368 if remoteTarget != "" {407 if remoteTarget != "" {
369- remoteDialer := deps.remoteDialer408+ mode, err := remoteTransportModeFromEnv(deps.selectedRemoteTransport)
370- if remoteDialer == nil {409+ if err != nil {
371- remoteDialer = defaultRemoteDialer410+ return err
411+ }
412+ factory := deps.remoteDialerFactory
413+ if factory == nil {
414+ factory = defaultRemoteDialerFactory()
372 }415 }
373 if log != nil {416 if log != nil {
374- log.Info("attaching to remote session", "target", remoteTarget, "name", name)417+ log.Info("attaching to remote session", "target", remoteTarget, "name", name, "transport", string(mode))
418+ }
419+ dialer, err := factory.DialerForRemote(remoteTarget, name, mode, log)
420+ if err != nil {
421+ return err
375 }422 }
376- return runClient(ctx, remoteDialer(remoteTarget, name, log), term.New(), clock.New(), intent, name, true, deps.clipboard, log)423+ return runClient(ctx, dialer, term.New(), clock.New(), intent, name, true, deps.clipboard, log)
377 }424 }
378 425
379 attachLocal := deps.attachLocal426 attachLocal := deps.attachLocal
M internal/app/run_test.go +86 -29
@@ -16,8 +16,10 @@ import (
1616 "github.com/bnema/vev/internal/domain"
1717 "github.com/bnema/vev/internal/persist"
1818 "github.com/bnema/vev/internal/ports"
19+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
1920 "github.com/bnema/vev/internal/usecase/client"
2021 "github.com/bnema/vev/internal/usecase/confirm"
22+ "github.com/stretchr/testify/mock"
2123 "github.com/stretchr/testify/require"
2224 )
2325
@@ -436,40 +438,93 @@ func (d namedDialer) Dial(context.Context) (ports.Transport, error) {
436438 // never actually invoked in these wiring tests.
437439 type fakeClipboardReader struct{ ports.ClipboardReader }
438440
439-func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {
440- // The local client still owns terminal capability detection for remote attach;
441- // sshstdio is only a framed transport to `vev _stdio` on the remote host.
442- var gotTarget, gotSession, gotDialer string
443- var gotRemote bool
444- var gotClipboard ports.ClipboardReader
445- clip := &fakeClipboardReader{}
446- err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
447- remoteDialer: func(target, session string, _ *slog.Logger) ports.Dialer {
448- gotTarget, gotSession = target, session
449- return namedDialer{name: "remote"}
450- },
451- clipboard: clip,
452- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
453- gotDialer = d.(namedDialer).name
454- gotRemote = remote
455- gotClipboard = clipboard
456- if intent != ports.IntentAttach || name != "work" {
457- t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)
441+func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
442+ tests := []struct {
443+ name string
444+ selectedTransport string
445+ wantMode ports.RemoteTransportMode
446+ }{
447+ {name: "default remote mode is udp", selectedTransport: "", wantMode: ports.RemoteTransportUDP},
448+ {name: "explicit stdio mode", selectedTransport: "stdio", wantMode: ports.RemoteTransportStdio},
449+ }
450+
451+ for _, tt := range tests {
452+ t.Run(tt.name, func(t *testing.T) {
453+ var gotDialer string
454+ var gotRemote bool
455+ var gotClipboard ports.ClipboardReader
456+ clip := &fakeClipboardReader{}
457+ factory := portsmocks.NewMockRemoteDialerFactory(t)
458+ factory.EXPECT().DialerForRemote("remote.example", "work", tt.wantMode, mock.Anything).Return(namedDialer{name: "remote"}, nil)
459+
460+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
461+ remoteDialerFactory: factory,
462+ selectedRemoteTransport: tt.selectedTransport,
463+ clipboard: clip,
464+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
465+ gotDialer = d.(namedDialer).name
466+ gotRemote = remote
467+ gotClipboard = clipboard
468+ if intent != ports.IntentAttach || name != "work" {
469+ t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)
470+ }
471+ return nil
472+ },
473+ })
474+ if err != nil {
475+ t.Fatalf("runAttachWithDeps returned error: %v", err)
476+ }
477+ if gotDialer != "remote" {
478+ t.Fatalf("remote dialer = %q, want remote", gotDialer)
479+ }
480+ if !gotRemote {
481+ t.Fatal("remote attach must pass remote=true to runClient")
482+ }
483+ if gotClipboard != ports.ClipboardReader(clip) {
484+ t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard)
458485 }
486+ })
487+ }
488+}
489+
490+func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.T) {
491+ factory := portsmocks.NewMockRemoteDialerFactory(t)
492+ runClientCalled := false
493+
494+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
495+ remoteDialerFactory: factory,
496+ selectedRemoteTransport: "serial",
497+ runClient: func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error {
498+ runClientCalled = true
459499 return nil
460500 },
461501 })
462- if err != nil {
463- t.Fatalf("runAttachWithDeps returned error: %v", err)
502+ if err == nil || err.Error() != `vev: invalid remote transport "serial" (want "udp" or "stdio")` {
503+ t.Fatalf("runAttachWithDeps error = %v, want invalid transport", err)
464504 }
465- if gotTarget != "remote.example" || gotSession != "work" || gotDialer != "remote" {
466- t.Fatalf("remote dialer wiring = target %q session %q dialer %q", gotTarget, gotSession, gotDialer)
505+ if runClientCalled {
506+ t.Fatal("runClient called after invalid remote transport")
467507 }
468- if !gotRemote {
469- t.Fatal("remote attach must pass remote=true to runClient")
508+}
509+
510+func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {
511+ factoryErr := errors.New("factory failed")
512+ factory := portsmocks.NewMockRemoteDialerFactory(t)
513+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(nil, factoryErr)
514+ runClientCalled := false
515+
516+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
517+ remoteDialerFactory: factory,
518+ runClient: func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error {
519+ runClientCalled = true
520+ return nil
521+ },
522+ })
523+ if !errors.Is(err, factoryErr) {
524+ t.Fatalf("runAttachWithDeps error = %v, want %v", err, factoryErr)
470525 }
471- if gotClipboard != ports.ClipboardReader(clip) {
472- t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard)
526+ if runClientCalled {
527+ t.Fatal("runClient called after remote factory error")
473528 }
474529 }
475530
@@ -477,9 +532,11 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
477532 var gotDialer string
478533 gotRemote := true
479534 gotClipboard := ports.ClipboardReader(&fakeClipboardReader{})
535+ factory := portsmocks.NewMockRemoteDialerFactory(t)
480536 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{
481- localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
482- clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach
537+ localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
538+ remoteDialerFactory: factory,
539+ clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach
483540 runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
484541 gotDialer = d.(namedDialer).name
485542 gotRemote = remote
@@ -16,8 +16,10 @@ import (
16 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
17 "github.com/bnema/vev/internal/persist"17 "github.com/bnema/vev/internal/persist"
18 "github.com/bnema/vev/internal/ports"18 "github.com/bnema/vev/internal/ports"
19+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
19 "github.com/bnema/vev/internal/usecase/client"20 "github.com/bnema/vev/internal/usecase/client"
20 "github.com/bnema/vev/internal/usecase/confirm"21 "github.com/bnema/vev/internal/usecase/confirm"
22+ "github.com/stretchr/testify/mock"
21 "github.com/stretchr/testify/require"23 "github.com/stretchr/testify/require"
22 )24 )
23 25
@@ -436,40 +438,93 @@ func (d namedDialer) Dial(context.Context) (ports.Transport, error) {
436 // never actually invoked in these wiring tests.438 // never actually invoked in these wiring tests.
437 type fakeClipboardReader struct{ ports.ClipboardReader }439 type fakeClipboardReader struct{ ports.ClipboardReader }
438 440
439-func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {441+func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
440- // The local client still owns terminal capability detection for remote attach;442+ tests := []struct {
441- // sshstdio is only a framed transport to `vev _stdio` on the remote host.443+ name string
442- var gotTarget, gotSession, gotDialer string444+ selectedTransport string
443- var gotRemote bool445+ wantMode ports.RemoteTransportMode
444- var gotClipboard ports.ClipboardReader446+ }{
445- clip := &fakeClipboardReader{}447+ {name: "default remote mode is udp", selectedTransport: "", wantMode: ports.RemoteTransportUDP},
446- err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{448+ {name: "explicit stdio mode", selectedTransport: "stdio", wantMode: ports.RemoteTransportStdio},
447- remoteDialer: func(target, session string, _ *slog.Logger) ports.Dialer {449+ }
448- gotTarget, gotSession = target, session450+
449- return namedDialer{name: "remote"}451+ for _, tt := range tests {
450- },452+ t.Run(tt.name, func(t *testing.T) {
451- clipboard: clip,453+ var gotDialer string
452- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {454+ var gotRemote bool
453- gotDialer = d.(namedDialer).name455+ var gotClipboard ports.ClipboardReader
454- gotRemote = remote456+ clip := &fakeClipboardReader{}
455- gotClipboard = clipboard457+ factory := portsmocks.NewMockRemoteDialerFactory(t)
456- if intent != ports.IntentAttach || name != "work" {458+ factory.EXPECT().DialerForRemote("remote.example", "work", tt.wantMode, mock.Anything).Return(namedDialer{name: "remote"}, nil)
457- t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)459+
460+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
461+ remoteDialerFactory: factory,
462+ selectedRemoteTransport: tt.selectedTransport,
463+ clipboard: clip,
464+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
465+ gotDialer = d.(namedDialer).name
466+ gotRemote = remote
467+ gotClipboard = clipboard
468+ if intent != ports.IntentAttach || name != "work" {
469+ t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)
470+ }
471+ return nil
472+ },
473+ })
474+ if err != nil {
475+ t.Fatalf("runAttachWithDeps returned error: %v", err)
476+ }
477+ if gotDialer != "remote" {
478+ t.Fatalf("remote dialer = %q, want remote", gotDialer)
479+ }
480+ if !gotRemote {
481+ t.Fatal("remote attach must pass remote=true to runClient")
482+ }
483+ if gotClipboard != ports.ClipboardReader(clip) {
484+ t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard)
458 }485 }
486+ })
487+ }
488+}
489+
490+func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.T) {
491+ factory := portsmocks.NewMockRemoteDialerFactory(t)
492+ runClientCalled := false
493+
494+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
495+ remoteDialerFactory: factory,
496+ selectedRemoteTransport: "serial",
497+ runClient: func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error {
498+ runClientCalled = true
459 return nil499 return nil
460 },500 },
461 })501 })
462- if err != nil {502+ if err == nil || err.Error() != `vev: invalid remote transport "serial" (want "udp" or "stdio")` {
463- t.Fatalf("runAttachWithDeps returned error: %v", err)503+ t.Fatalf("runAttachWithDeps error = %v, want invalid transport", err)
464 }504 }
465- if gotTarget != "remote.example" || gotSession != "work" || gotDialer != "remote" {505+ if runClientCalled {
466- t.Fatalf("remote dialer wiring = target %q session %q dialer %q", gotTarget, gotSession, gotDialer)506+ t.Fatal("runClient called after invalid remote transport")
467 }507 }
468- if !gotRemote {508+}
469- t.Fatal("remote attach must pass remote=true to runClient")509+
510+func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {
511+ factoryErr := errors.New("factory failed")
512+ factory := portsmocks.NewMockRemoteDialerFactory(t)
513+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(nil, factoryErr)
514+ runClientCalled := false
515+
516+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
517+ remoteDialerFactory: factory,
518+ runClient: func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error {
519+ runClientCalled = true
520+ return nil
521+ },
522+ })
523+ if !errors.Is(err, factoryErr) {
524+ t.Fatalf("runAttachWithDeps error = %v, want %v", err, factoryErr)
470 }525 }
471- if gotClipboard != ports.ClipboardReader(clip) {526+ if runClientCalled {
472- t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard)527+ t.Fatal("runClient called after remote factory error")
473 }528 }
474 }529 }
475 530
@@ -477,9 +532,11 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
477 var gotDialer string532 var gotDialer string
478 gotRemote := true533 gotRemote := true
479 gotClipboard := ports.ClipboardReader(&fakeClipboardReader{})534 gotClipboard := ports.ClipboardReader(&fakeClipboardReader{})
535+ factory := portsmocks.NewMockRemoteDialerFactory(t)
480 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{536 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{
481- localDialer: func() ports.Dialer { return namedDialer{name: "local"} },537+ localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
482- clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach538+ remoteDialerFactory: factory,
539+ clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach
483 runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {540 runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
484 gotDialer = d.(namedDialer).name541 gotDialer = d.(namedDialer).name
485 gotRemote = remote542 gotRemote = remote
M internal/ports/mocks/mocks.go +108 -0
@@ -7,6 +7,7 @@ package portsmocks
77 import (
88 "context"
99 "io"
10+ "log/slog"
1011 "time"
1112
1213 "github.com/bnema/vev/internal/domain"
@@ -2534,3 +2535,110 @@ func (_c *MockClipboardReader_ReadImage_Call) RunAndReturn(run func(ctx context.
25342535 _c.Call.Return(run)
25352536 return _c
25362537 }
2538+
2539+// NewMockRemoteDialerFactory creates a new instance of MockRemoteDialerFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2540+// The first argument is typically a *testing.T value.
2541+func NewMockRemoteDialerFactory(t interface {
2542+ mock.TestingT
2543+ Cleanup(func())
2544+}) *MockRemoteDialerFactory {
2545+ mock := &MockRemoteDialerFactory{}
2546+ mock.Mock.Test(t)
2547+
2548+ t.Cleanup(func() { mock.AssertExpectations(t) })
2549+
2550+ return mock
2551+}
2552+
2553+// MockRemoteDialerFactory is an autogenerated mock type for the RemoteDialerFactory type
2554+type MockRemoteDialerFactory struct {
2555+ mock.Mock
2556+}
2557+
2558+type MockRemoteDialerFactory_Expecter struct {
2559+ mock *mock.Mock
2560+}
2561+
2562+func (_m *MockRemoteDialerFactory) EXPECT() *MockRemoteDialerFactory_Expecter {
2563+ return &MockRemoteDialerFactory_Expecter{mock: &_m.Mock}
2564+}
2565+
2566+// DialerForRemote provides a mock function for the type MockRemoteDialerFactory
2567+func (_mock *MockRemoteDialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) {
2568+ ret := _mock.Called(target, session, mode, log)
2569+
2570+ if len(ret) == 0 {
2571+ panic("no return value specified for DialerForRemote")
2572+ }
2573+
2574+ var r0 ports.Dialer
2575+ var r1 error
2576+ if returnFunc, ok := ret.Get(0).(func(string, string, ports.RemoteTransportMode, *slog.Logger) (ports.Dialer, error)); ok {
2577+ return returnFunc(target, session, mode, log)
2578+ }
2579+ if returnFunc, ok := ret.Get(0).(func(string, string, ports.RemoteTransportMode, *slog.Logger) ports.Dialer); ok {
2580+ r0 = returnFunc(target, session, mode, log)
2581+ } else {
2582+ if ret.Get(0) != nil {
2583+ r0 = ret.Get(0).(ports.Dialer)
2584+ }
2585+ }
2586+ if returnFunc, ok := ret.Get(1).(func(string, string, ports.RemoteTransportMode, *slog.Logger) error); ok {
2587+ r1 = returnFunc(target, session, mode, log)
2588+ } else {
2589+ r1 = ret.Error(1)
2590+ }
2591+ return r0, r1
2592+}
2593+
2594+// MockRemoteDialerFactory_DialerForRemote_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DialerForRemote'
2595+type MockRemoteDialerFactory_DialerForRemote_Call struct {
2596+ *mock.Call
2597+}
2598+
2599+// DialerForRemote is a helper method to define mock.On call
2600+// - target string
2601+// - session string
2602+// - mode ports.RemoteTransportMode
2603+// - log *slog.Logger
2604+func (_e *MockRemoteDialerFactory_Expecter) DialerForRemote(target any, session any, mode any, log any) *MockRemoteDialerFactory_DialerForRemote_Call {
2605+ return &MockRemoteDialerFactory_DialerForRemote_Call{Call: _e.mock.On("DialerForRemote", target, session, mode, log)}
2606+}
2607+
2608+func (_c *MockRemoteDialerFactory_DialerForRemote_Call) Run(run func(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger)) *MockRemoteDialerFactory_DialerForRemote_Call {
2609+ _c.Call.Run(func(args mock.Arguments) {
2610+ var arg0 string
2611+ if args[0] != nil {
2612+ arg0 = args[0].(string)
2613+ }
2614+ var arg1 string
2615+ if args[1] != nil {
2616+ arg1 = args[1].(string)
2617+ }
2618+ var arg2 ports.RemoteTransportMode
2619+ if args[2] != nil {
2620+ arg2 = args[2].(ports.RemoteTransportMode)
2621+ }
2622+ var arg3 *slog.Logger
2623+ if args[3] != nil {
2624+ arg3 = args[3].(*slog.Logger)
2625+ }
2626+ run(
2627+ arg0,
2628+ arg1,
2629+ arg2,
2630+ arg3,
2631+ )
2632+ })
2633+ return _c
2634+}
2635+
2636+func (_c *MockRemoteDialerFactory_DialerForRemote_Call) Return(dialer ports.Dialer, err error) *MockRemoteDialerFactory_DialerForRemote_Call {
2637+ _c.Call.Return(dialer, err)
2638+ return _c
2639+}
2640+
2641+func (_c *MockRemoteDialerFactory_DialerForRemote_Call) RunAndReturn(run func(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error)) *MockRemoteDialerFactory_DialerForRemote_Call {
2642+ _c.Call.Return(run)
2643+ return _c
2644+}
@@ -7,6 +7,7 @@ package portsmocks
7 import (7 import (
8 "context"8 "context"
9 "io"9 "io"
10+ "log/slog"
10 "time"11 "time"
11 12
12 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
@@ -2534,3 +2535,110 @@ func (_c *MockClipboardReader_ReadImage_Call) RunAndReturn(run func(ctx context.
2534 _c.Call.Return(run)2535 _c.Call.Return(run)
2535 return _c2536 return _c
2536 }2537 }
2538+
2539+// NewMockRemoteDialerFactory creates a new instance of MockRemoteDialerFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2540+// The first argument is typically a *testing.T value.
2541+func NewMockRemoteDialerFactory(t interface {
2542+ mock.TestingT
2543+ Cleanup(func())
2544+}) *MockRemoteDialerFactory {
2545+ mock := &MockRemoteDialerFactory{}
2546+ mock.Mock.Test(t)
2547+
2548+ t.Cleanup(func() { mock.AssertExpectations(t) })
2549+
2550+ return mock
2551+}
2552+
2553+// MockRemoteDialerFactory is an autogenerated mock type for the RemoteDialerFactory type
2554+type MockRemoteDialerFactory struct {
2555+ mock.Mock
2556+}
2557+
2558+type MockRemoteDialerFactory_Expecter struct {
2559+ mock *mock.Mock
2560+}
2561+
2562+func (_m *MockRemoteDialerFactory) EXPECT() *MockRemoteDialerFactory_Expecter {
2563+ return &MockRemoteDialerFactory_Expecter{mock: &_m.Mock}
2564+}
2565+
2566+// DialerForRemote provides a mock function for the type MockRemoteDialerFactory
2567+func (_mock *MockRemoteDialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) {
2568+ ret := _mock.Called(target, session, mode, log)
2569+
2570+ if len(ret) == 0 {
2571+ panic("no return value specified for DialerForRemote")
2572+ }
2573+
2574+ var r0 ports.Dialer
2575+ var r1 error
2576+ if returnFunc, ok := ret.Get(0).(func(string, string, ports.RemoteTransportMode, *slog.Logger) (ports.Dialer, error)); ok {
2577+ return returnFunc(target, session, mode, log)
2578+ }
2579+ if returnFunc, ok := ret.Get(0).(func(string, string, ports.RemoteTransportMode, *slog.Logger) ports.Dialer); ok {
2580+ r0 = returnFunc(target, session, mode, log)
2581+ } else {
2582+ if ret.Get(0) != nil {
2583+ r0 = ret.Get(0).(ports.Dialer)
2584+ }
2585+ }
2586+ if returnFunc, ok := ret.Get(1).(func(string, string, ports.RemoteTransportMode, *slog.Logger) error); ok {
2587+ r1 = returnFunc(target, session, mode, log)
2588+ } else {
2589+ r1 = ret.Error(1)
2590+ }
2591+ return r0, r1
2592+}
2593+
2594+// MockRemoteDialerFactory_DialerForRemote_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DialerForRemote'
2595+type MockRemoteDialerFactory_DialerForRemote_Call struct {
2596+ *mock.Call
2597+}
2598+
2599+// DialerForRemote is a helper method to define mock.On call
2600+// - target string
2601+// - session string
2602+// - mode ports.RemoteTransportMode
2603+// - log *slog.Logger
2604+func (_e *MockRemoteDialerFactory_Expecter) DialerForRemote(target any, session any, mode any, log any) *MockRemoteDialerFactory_DialerForRemote_Call {
2605+ return &MockRemoteDialerFactory_DialerForRemote_Call{Call: _e.mock.On("DialerForRemote", target, session, mode, log)}
2606+}
2607+
2608+func (_c *MockRemoteDialerFactory_DialerForRemote_Call) Run(run func(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger)) *MockRemoteDialerFactory_DialerForRemote_Call {
2609+ _c.Call.Run(func(args mock.Arguments) {
2610+ var arg0 string
2611+ if args[0] != nil {
2612+ arg0 = args[0].(string)
2613+ }
2614+ var arg1 string
2615+ if args[1] != nil {
2616+ arg1 = args[1].(string)
2617+ }
2618+ var arg2 ports.RemoteTransportMode
2619+ if args[2] != nil {
2620+ arg2 = args[2].(ports.RemoteTransportMode)
2621+ }
2622+ var arg3 *slog.Logger
2623+ if args[3] != nil {
2624+ arg3 = args[3].(*slog.Logger)
2625+ }
2626+ run(
2627+ arg0,
2628+ arg1,
2629+ arg2,
2630+ arg3,
2631+ )
2632+ })
2633+ return _c
2634+}
2635+
2636+func (_c *MockRemoteDialerFactory_DialerForRemote_Call) Return(dialer ports.Dialer, err error) *MockRemoteDialerFactory_DialerForRemote_Call {
2637+ _c.Call.Return(dialer, err)
2638+ return _c
2639+}
2640+
2641+func (_c *MockRemoteDialerFactory_DialerForRemote_Call) RunAndReturn(run func(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error)) *MockRemoteDialerFactory_DialerForRemote_Call {
2642+ _c.Call.Return(run)
2643+ return _c
2644+}
A internal/ports/remote.go +17 -0
@@ -0,0 +1,17 @@
1+package ports
2+
3+import "log/slog"
4+
5+// RemoteTransportMode selects the transport used for remote attach after CLI parsing.
6+type RemoteTransportMode string
7+
8+const (
9+ RemoteTransportUDP RemoteTransportMode = "udp"
10+ RemoteTransportStdio RemoteTransportMode = "stdio"
11+)
12+
13+// RemoteDialerFactory builds the transport-specific dialer for a remote target.
14+// Implementations live in adapters; app wiring consumes this interface.
15+type RemoteDialerFactory interface {
16+ DialerForRemote(target string, session string, mode RemoteTransportMode, log *slog.Logger) (Dialer, error)
17+}
@@ -0,0 +1,17 @@
1+package ports
2+
3+import "log/slog"
4+
5+// RemoteTransportMode selects the transport used for remote attach after CLI parsing.
6+type RemoteTransportMode string
7+
8+const (
9+ RemoteTransportUDP RemoteTransportMode = "udp"
10+ RemoteTransportStdio RemoteTransportMode = "stdio"
11+)
12+
13+// RemoteDialerFactory builds the transport-specific dialer for a remote target.
14+// Implementations live in adapters; app wiring consumes this interface.
15+type RemoteDialerFactory interface {
16+ DialerForRemote(target string, session string, mode RemoteTransportMode, log *slog.Logger) (Dialer, error)
17+}

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