fix(app): prompt before restarting stale daemon
- commit
- 206ab7a12c563a21a23b27b22be2eed25580b524
- author
- brice · · 2026-07-03 08:45 UTC
- parent
- acdb950
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +215 -5
M internal/app/run.go +65 -4
| @@ -25,6 +25,7 @@ import ( | |||
| 25 | 25 | "github.com/bnema/vev/internal/ports" | |
| 26 | 26 | "github.com/bnema/vev/internal/usecase/client" | |
| 27 | 27 | "github.com/bnema/vev/internal/usecase/daemon" | |
| 28 | + | "github.com/bnema/vev/internal/usecase/prompt" | |
| 28 | 29 | ) | |
| 29 | 30 | ||
| 30 | 31 | // cmdKind identifies which sub-command the CLI parsed. | |
| @@ -243,12 +244,57 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err | |||
| 243 | 244 | } | |
| 244 | 245 | defer func() { _ = logFile.Close() }() | |
| 245 | 246 | ||
| 246 | - | var transport ports.Transport | |
| 247 | 247 | if remoteTarget != "" { | |
| 248 | - | transport, err = sshstdio.Dial(remoteTarget, name) | |
| 249 | - | } else { | |
| 250 | - | transport, err = ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff) | |
| 248 | + | transport, err := sshstdio.Dial(remoteTarget, name) | |
| 249 | + | if err != nil { | |
| 250 | + | return err | |
| 251 | + | } | |
| 252 | + | // client.Attach owns and closes transport. | |
| 253 | + | return client.Attach(ctx, transport, term.New(), intent, name) | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{ | |
| 257 | + | confirmer: prompt.NewConfirmer(os.Stdin, os.Stderr), | |
| 258 | + | attach: attachLocalDaemon, | |
| 259 | + | killDaemon: requestDaemonStop, | |
| 260 | + | }) | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | type attachRecoveryDeps struct { | |
| 264 | + | confirmer prompt.Confirmer | |
| 265 | + | attach func(context.Context, uint8, string) error | |
| 266 | + | killDaemon func(context.Context) error | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func runLocalAttachWithRecovery(ctx context.Context, intent uint8, name string, deps attachRecoveryDeps) error { | |
| 270 | + | err := deps.attach(ctx, intent, name) | |
| 271 | + | if !isDaemonVersionDrift(err) { | |
| 272 | + | return err | |
| 251 | 273 | } | |
| 274 | + | ||
| 275 | + | ok, promptErr := deps.confirmer.Confirm("Your vev version differs from the running daemon; kill it and restart?") | |
| 276 | + | if promptErr != nil { | |
| 277 | + | return fmt.Errorf("vev: reading confirmation: %w", promptErr) | |
| 278 | + | } | |
| 279 | + | if !ok { | |
| 280 | + | return err | |
| 281 | + | } | |
| 282 | + | if killErr := deps.killDaemon(ctx); killErr != nil { | |
| 283 | + | return killErr | |
| 284 | + | } | |
| 285 | + | return deps.attach(ctx, intent, name) | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func isDaemonVersionDrift(err error) bool { | |
| 289 | + | var protocolErr *client.ProtocolError | |
| 290 | + | if !errors.As(err, &protocolErr) { | |
| 291 | + | return false | |
| 292 | + | } | |
| 293 | + | return protocolErr.Code == ports.ErrVersionMismatch || protocolErr.Text == "malformed hello" | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func attachLocalDaemon(ctx context.Context, intent uint8, name string) error { | |
| 297 | + | transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff) | |
| 252 | 298 | if err != nil { | |
| 253 | 299 | return err | |
| 254 | 300 | } | |
| @@ -256,6 +302,21 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err | |||
| 256 | 302 | return client.Attach(ctx, transport, term.New(), intent, name) | |
| 257 | 303 | } | |
| 258 | 304 | ||
| 305 | + | func requestDaemonStop(_ context.Context) error { | |
| 306 | + | transport, err := realDial(ipc.SocketDir()) | |
| 307 | + | if err != nil { | |
| 308 | + | return fmt.Errorf("vev: no daemon running") | |
| 309 | + | } | |
| 310 | + | defer func() { _ = transport.Close() }() | |
| 311 | + | if err := transport.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})}); err != nil { | |
| 312 | + | return fmt.Errorf("vev: requesting daemon stop: %w", err) | |
| 313 | + | } | |
| 314 | + | if _, err := transport.Recv(); err != nil && !errors.Is(err, io.EOF) { | |
| 315 | + | return fmt.Errorf("vev: reading daemon stop reply: %w", err) | |
| 316 | + | } | |
| 317 | + | return nil | |
| 318 | + | } | |
| 319 | + | ||
| 259 | 320 | // runStdio is the hidden remote-side mode used by `ssh host vev _stdio`: it | |
| 260 | 321 | // connects to the per-user daemon (auto-spawning it if needed) and proxies the | |
| 261 | 322 | // framed protocol between process stdio and the daemon socket. | |
| @@ -25,6 +25,7 @@ import ( | |||||
| 25 | "github.com/bnema/vev/internal/ports" | 25 | "github.com/bnema/vev/internal/ports" | ||
| 26 | "github.com/bnema/vev/internal/usecase/client" | 26 | "github.com/bnema/vev/internal/usecase/client" | ||
| 27 | "github.com/bnema/vev/internal/usecase/daemon" | 27 | "github.com/bnema/vev/internal/usecase/daemon" | ||
| 28 | + | "github.com/bnema/vev/internal/usecase/prompt" | |||
| 28 | ) | 29 | ) | ||
| 29 | 30 | ||||
| 30 | // cmdKind identifies which sub-command the CLI parsed. | 31 | // cmdKind identifies which sub-command the CLI parsed. | ||
| @@ -243,12 +244,57 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err | |||||
| 243 | } | 244 | } | ||
| 244 | defer func() { _ = logFile.Close() }() | 245 | defer func() { _ = logFile.Close() }() | ||
| 245 | 246 | ||||
| 246 | - | var transport ports.Transport | |||
| 247 | if remoteTarget != "" { | 247 | if remoteTarget != "" { | ||
| 248 | - | transport, err = sshstdio.Dial(remoteTarget, name) | 248 | + | transport, err := sshstdio.Dial(remoteTarget, name) |
| 249 | - | } else { | 249 | + | if err != nil { |
| 250 | - | transport, err = ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff) | 250 | + | return err |
| 251 | + | } | |||
| 252 | + | // client.Attach owns and closes transport. | |||
| 253 | + | return client.Attach(ctx, transport, term.New(), intent, name) | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{ | |||
| 257 | + | confirmer: prompt.NewConfirmer(os.Stdin, os.Stderr), | |||
| 258 | + | attach: attachLocalDaemon, | |||
| 259 | + | killDaemon: requestDaemonStop, | |||
| 260 | + | }) | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | type attachRecoveryDeps struct { | |||
| 264 | + | confirmer prompt.Confirmer | |||
| 265 | + | attach func(context.Context, uint8, string) error | |||
| 266 | + | killDaemon func(context.Context) error | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | func runLocalAttachWithRecovery(ctx context.Context, intent uint8, name string, deps attachRecoveryDeps) error { | |||
| 270 | + | err := deps.attach(ctx, intent, name) | |||
| 271 | + | if !isDaemonVersionDrift(err) { | |||
| 272 | + | return err | |||
| 251 | } | 273 | } | ||
| 274 | + | ||||
| 275 | + | ok, promptErr := deps.confirmer.Confirm("Your vev version differs from the running daemon; kill it and restart?") | |||
| 276 | + | if promptErr != nil { | |||
| 277 | + | return fmt.Errorf("vev: reading confirmation: %w", promptErr) | |||
| 278 | + | } | |||
| 279 | + | if !ok { | |||
| 280 | + | return err | |||
| 281 | + | } | |||
| 282 | + | if killErr := deps.killDaemon(ctx); killErr != nil { | |||
| 283 | + | return killErr | |||
| 284 | + | } | |||
| 285 | + | return deps.attach(ctx, intent, name) | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | func isDaemonVersionDrift(err error) bool { | |||
| 289 | + | var protocolErr *client.ProtocolError | |||
| 290 | + | if !errors.As(err, &protocolErr) { | |||
| 291 | + | return false | |||
| 292 | + | } | |||
| 293 | + | return protocolErr.Code == ports.ErrVersionMismatch || protocolErr.Text == "malformed hello" | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func attachLocalDaemon(ctx context.Context, intent uint8, name string) error { | |||
| 297 | + | transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff) | |||
| 252 | if err != nil { | 298 | if err != nil { | ||
| 253 | return err | 299 | return err | ||
| 254 | } | 300 | } | ||
| @@ -256,6 +302,21 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err | |||||
| 256 | return client.Attach(ctx, transport, term.New(), intent, name) | 302 | return client.Attach(ctx, transport, term.New(), intent, name) | ||
| 257 | } | 303 | } | ||
| 258 | 304 | ||||
| 305 | + | func requestDaemonStop(_ context.Context) error { | |||
| 306 | + | transport, err := realDial(ipc.SocketDir()) | |||
| 307 | + | if err != nil { | |||
| 308 | + | return fmt.Errorf("vev: no daemon running") | |||
| 309 | + | } | |||
| 310 | + | defer func() { _ = transport.Close() }() | |||
| 311 | + | if err := transport.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})}); err != nil { | |||
| 312 | + | return fmt.Errorf("vev: requesting daemon stop: %w", err) | |||
| 313 | + | } | |||
| 314 | + | if _, err := transport.Recv(); err != nil && !errors.Is(err, io.EOF) { | |||
| 315 | + | return fmt.Errorf("vev: reading daemon stop reply: %w", err) | |||
| 316 | + | } | |||
| 317 | + | return nil | |||
| 318 | + | } | |||
| 319 | + | ||||
| 259 | // runStdio is the hidden remote-side mode used by `ssh host vev _stdio`: it | 320 | // runStdio is the hidden remote-side mode used by `ssh host vev _stdio`: it | ||
| 260 | // connects to the per-user daemon (auto-spawning it if needed) and proxies the | 321 | // connects to the per-user daemon (auto-spawning it if needed) and proxies the | ||
| 261 | // framed protocol between process stdio and the daemon socket. | 322 | // framed protocol between process stdio and the daemon socket. | ||
M internal/app/run_test.go +65 -0
| @@ -1,12 +1,15 @@ | |||
| 1 | 1 | package app | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "context" | |
| 5 | 6 | "errors" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "testing" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/vev/internal/ports" | |
| 11 | + | "github.com/bnema/vev/internal/usecase/client" | |
| 12 | + | "github.com/bnema/vev/internal/usecase/prompt" | |
| 10 | 13 | ) | |
| 11 | 14 | ||
| 12 | 15 | func TestParseArgs(t *testing.T) { | |
| @@ -93,6 +96,68 @@ func TestParseArgs(t *testing.T) { | |||
| 93 | 96 | } | |
| 94 | 97 | } | |
| 95 | 98 | ||
| 99 | + | func TestRunLocalAttachPromptsAndRestartsOnProtocolMismatch(t *testing.T) { | |
| 100 | + | var prompts bytes.Buffer | |
| 101 | + | answers := strings.NewReader("y\n") | |
| 102 | + | attachCalls := 0 | |
| 103 | + | killCalls := 0 | |
| 104 | + | ||
| 105 | + | err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{ | |
| 106 | + | confirmer: prompt.NewConfirmer(answers, &prompts), | |
| 107 | + | attach: func(context.Context, uint8, string) error { | |
| 108 | + | attachCalls++ | |
| 109 | + | if attachCalls == 1 { | |
| 110 | + | return &client.ProtocolError{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"} | |
| 111 | + | } | |
| 112 | + | return nil | |
| 113 | + | }, | |
| 114 | + | killDaemon: func(context.Context) error { | |
| 115 | + | killCalls++ | |
| 116 | + | return nil | |
| 117 | + | }, | |
| 118 | + | }) | |
| 119 | + | if err != nil { | |
| 120 | + | t.Fatalf("runLocalAttachWithRecovery returned error: %v", err) | |
| 121 | + | } | |
| 122 | + | if attachCalls != 2 { | |
| 123 | + | t.Fatalf("attach calls = %d, want 2", attachCalls) | |
| 124 | + | } | |
| 125 | + | if killCalls != 1 { | |
| 126 | + | t.Fatalf("kill calls = %d, want 1", killCalls) | |
| 127 | + | } | |
| 128 | + | if got := prompts.String(); !strings.Contains(got, "Your vev version differs") || !strings.Contains(got, "kill it") { | |
| 129 | + | t.Fatalf("prompt = %q, want version/kill prompt", got) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestRunLocalAttachDeclineKeepsOriginalError(t *testing.T) { | |
| 134 | + | answers := strings.NewReader("n\n") | |
| 135 | + | wantErr := &client.ProtocolError{Code: ports.ErrInternal, Text: "malformed hello"} | |
| 136 | + | attachCalls := 0 | |
| 137 | + | killCalls := 0 | |
| 138 | + | ||
| 139 | + | err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{ | |
| 140 | + | confirmer: prompt.NewConfirmer(answers, &bytes.Buffer{}), | |
| 141 | + | attach: func(context.Context, uint8, string) error { | |
| 142 | + | attachCalls++ | |
| 143 | + | return wantErr | |
| 144 | + | }, | |
| 145 | + | killDaemon: func(context.Context) error { | |
| 146 | + | killCalls++ | |
| 147 | + | return nil | |
| 148 | + | }, | |
| 149 | + | }) | |
| 150 | + | if !errors.Is(err, wantErr) { | |
| 151 | + | t.Fatalf("error = %v, want original %v", err, wantErr) | |
| 152 | + | } | |
| 153 | + | if attachCalls != 1 { | |
| 154 | + | t.Fatalf("attach calls = %d, want 1", attachCalls) | |
| 155 | + | } | |
| 156 | + | if killCalls != 0 { | |
| 157 | + | t.Fatalf("kill calls = %d, want 0", killCalls) | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 96 | 161 | func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) { | |
| 97 | 162 | t.Setenv("VEV", "outer") | |
| 98 | 163 | err := runAttach(context.Background(), ports.IntentEphemeral, "", "") | |
| @@ -1,12 +1,15 @@ | |||||
| 1 | package app | 1 | package app | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "context" | 5 | "context" | ||
| 5 | "errors" | 6 | "errors" | ||
| 6 | "strings" | 7 | "strings" | ||
| 7 | "testing" | 8 | "testing" | ||
| 8 | 9 | ||||
| 9 | "github.com/bnema/vev/internal/ports" | 10 | "github.com/bnema/vev/internal/ports" | ||
| 11 | + | "github.com/bnema/vev/internal/usecase/client" | |||
| 12 | + | "github.com/bnema/vev/internal/usecase/prompt" | |||
| 10 | ) | 13 | ) | ||
| 11 | 14 | ||||
| 12 | func TestParseArgs(t *testing.T) { | 15 | func TestParseArgs(t *testing.T) { | ||
| @@ -93,6 +96,68 @@ func TestParseArgs(t *testing.T) { | |||||
| 93 | } | 96 | } | ||
| 94 | } | 97 | } | ||
| 95 | 98 | ||||
| 99 | + | func TestRunLocalAttachPromptsAndRestartsOnProtocolMismatch(t *testing.T) { | |||
| 100 | + | var prompts bytes.Buffer | |||
| 101 | + | answers := strings.NewReader("y\n") | |||
| 102 | + | attachCalls := 0 | |||
| 103 | + | killCalls := 0 | |||
| 104 | + | ||||
| 105 | + | err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{ | |||
| 106 | + | confirmer: prompt.NewConfirmer(answers, &prompts), | |||
| 107 | + | attach: func(context.Context, uint8, string) error { | |||
| 108 | + | attachCalls++ | |||
| 109 | + | if attachCalls == 1 { | |||
| 110 | + | return &client.ProtocolError{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"} | |||
| 111 | + | } | |||
| 112 | + | return nil | |||
| 113 | + | }, | |||
| 114 | + | killDaemon: func(context.Context) error { | |||
| 115 | + | killCalls++ | |||
| 116 | + | return nil | |||
| 117 | + | }, | |||
| 118 | + | }) | |||
| 119 | + | if err != nil { | |||
| 120 | + | t.Fatalf("runLocalAttachWithRecovery returned error: %v", err) | |||
| 121 | + | } | |||
| 122 | + | if attachCalls != 2 { | |||
| 123 | + | t.Fatalf("attach calls = %d, want 2", attachCalls) | |||
| 124 | + | } | |||
| 125 | + | if killCalls != 1 { | |||
| 126 | + | t.Fatalf("kill calls = %d, want 1", killCalls) | |||
| 127 | + | } | |||
| 128 | + | if got := prompts.String(); !strings.Contains(got, "Your vev version differs") || !strings.Contains(got, "kill it") { | |||
| 129 | + | t.Fatalf("prompt = %q, want version/kill prompt", got) | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func TestRunLocalAttachDeclineKeepsOriginalError(t *testing.T) { | |||
| 134 | + | answers := strings.NewReader("n\n") | |||
| 135 | + | wantErr := &client.ProtocolError{Code: ports.ErrInternal, Text: "malformed hello"} | |||
| 136 | + | attachCalls := 0 | |||
| 137 | + | killCalls := 0 | |||
| 138 | + | ||||
| 139 | + | err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{ | |||
| 140 | + | confirmer: prompt.NewConfirmer(answers, &bytes.Buffer{}), | |||
| 141 | + | attach: func(context.Context, uint8, string) error { | |||
| 142 | + | attachCalls++ | |||
| 143 | + | return wantErr | |||
| 144 | + | }, | |||
| 145 | + | killDaemon: func(context.Context) error { | |||
| 146 | + | killCalls++ | |||
| 147 | + | return nil | |||
| 148 | + | }, | |||
| 149 | + | }) | |||
| 150 | + | if !errors.Is(err, wantErr) { | |||
| 151 | + | t.Fatalf("error = %v, want original %v", err, wantErr) | |||
| 152 | + | } | |||
| 153 | + | if attachCalls != 1 { | |||
| 154 | + | t.Fatalf("attach calls = %d, want 1", attachCalls) | |||
| 155 | + | } | |||
| 156 | + | if killCalls != 0 { | |||
| 157 | + | t.Fatalf("kill calls = %d, want 0", killCalls) | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | ||||
| 96 | func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) { | 161 | func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) { | ||
| 97 | t.Setenv("VEV", "outer") | 162 | t.Setenv("VEV", "outer") | ||
| 98 | err := runAttach(context.Background(), ports.IntentEphemeral, "", "") | 163 | err := runAttach(context.Background(), ports.IntentEphemeral, "", "") | ||
M internal/ports/frame.go +1 -1
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 4 | - | const ProtocolVersion uint16 = 2 | |
| 4 | + | const ProtocolVersion uint16 = 3 | |
| 5 | 5 | ||
| 6 | 6 | // MsgType identifies the kind of payload carried by a Frame. | |
| 7 | 7 | type MsgType uint8 | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | package ports | 1 | 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 = 2 | 4 | + | const ProtocolVersion uint16 = 3 |
| 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 uint8 | 7 | type MsgType uint8 | ||
A internal/usecase/prompt/confirm.go +43 -0
| @@ -0,0 +1,43 @@ | |||
| 1 | + | package prompt | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "fmt" | |
| 6 | + | "io" | |
| 7 | + | "strings" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Confirmer asks a yes/no question on a pair of streams. | |
| 11 | + | type Confirmer struct { | |
| 12 | + | in io.Reader | |
| 13 | + | out io.Writer | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // NewConfirmer builds a confirmation prompt. Only y/yes answers confirm; empty, | |
| 17 | + | // n/no, and unknown answers all decline. | |
| 18 | + | func NewConfirmer(in io.Reader, out io.Writer) Confirmer { | |
| 19 | + | return Confirmer{in: in, out: out} | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // Confirm writes question with a [y/N] default and reads one answer line. | |
| 23 | + | func (c Confirmer) Confirm(question string) (bool, error) { | |
| 24 | + | if c.out != nil { | |
| 25 | + | if _, err := fmt.Fprintf(c.out, "%s [y/N] ", question); err != nil { | |
| 26 | + | return false, err | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | answer, err := bufio.NewReader(c.in).ReadString('\n') | |
| 30 | + | if err != nil && !isEOFWithAnswer(err, answer) { | |
| 31 | + | return false, err | |
| 32 | + | } | |
| 33 | + | switch strings.ToLower(strings.TrimSpace(answer)) { | |
| 34 | + | case "y", "yes": | |
| 35 | + | return true, nil | |
| 36 | + | default: | |
| 37 | + | return false, nil | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func isEOFWithAnswer(err error, answer string) bool { | |
| 42 | + | return err == io.EOF && answer != "" | |
| 43 | + | } | |
| @@ -0,0 +1,43 @@ | |||||
| 1 | + | package prompt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "fmt" | |||
| 6 | + | "io" | |||
| 7 | + | "strings" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // Confirmer asks a yes/no question on a pair of streams. | |||
| 11 | + | type Confirmer struct { | |||
| 12 | + | in io.Reader | |||
| 13 | + | out io.Writer | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // NewConfirmer builds a confirmation prompt. Only y/yes answers confirm; empty, | |||
| 17 | + | // n/no, and unknown answers all decline. | |||
| 18 | + | func NewConfirmer(in io.Reader, out io.Writer) Confirmer { | |||
| 19 | + | return Confirmer{in: in, out: out} | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // Confirm writes question with a [y/N] default and reads one answer line. | |||
| 23 | + | func (c Confirmer) Confirm(question string) (bool, error) { | |||
| 24 | + | if c.out != nil { | |||
| 25 | + | if _, err := fmt.Fprintf(c.out, "%s [y/N] ", question); err != nil { | |||
| 26 | + | return false, err | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | answer, err := bufio.NewReader(c.in).ReadString('\n') | |||
| 30 | + | if err != nil && !isEOFWithAnswer(err, answer) { | |||
| 31 | + | return false, err | |||
| 32 | + | } | |||
| 33 | + | switch strings.ToLower(strings.TrimSpace(answer)) { | |||
| 34 | + | case "y", "yes": | |||
| 35 | + | return true, nil | |||
| 36 | + | default: | |||
| 37 | + | return false, nil | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func isEOFWithAnswer(err error, answer string) bool { | |||
| 42 | + | return err == io.EOF && answer != "" | |||
| 43 | + | } | |||
A internal/usecase/prompt/confirm_test.go +41 -0
| @@ -0,0 +1,41 @@ | |||
| 1 | + | package prompt | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestConfirmerAcceptsYes(t *testing.T) { | |
| 10 | + | var out bytes.Buffer | |
| 11 | + | ok, err := NewConfirmer(strings.NewReader("y\n"), &out).Confirm("Kill daemon?") | |
| 12 | + | if err != nil { | |
| 13 | + | t.Fatalf("Confirm returned error: %v", err) | |
| 14 | + | } | |
| 15 | + | if !ok { | |
| 16 | + | t.Fatal("Confirm returned false, want true") | |
| 17 | + | } | |
| 18 | + | if got := out.String(); !strings.Contains(got, "Kill daemon?") || !strings.Contains(got, "[y/N]") { | |
| 19 | + | t.Fatalf("prompt output = %q, want question and default", got) | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestConfirmerDefaultsNo(t *testing.T) { | |
| 24 | + | ok, err := NewConfirmer(strings.NewReader("\n"), &bytes.Buffer{}).Confirm("Kill daemon?") | |
| 25 | + | if err != nil { | |
| 26 | + | t.Fatalf("Confirm returned error: %v", err) | |
| 27 | + | } | |
| 28 | + | if ok { | |
| 29 | + | t.Fatal("Confirm returned true for empty answer, want false") | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestConfirmerRejectsUnknownAnswer(t *testing.T) { | |
| 34 | + | ok, err := NewConfirmer(strings.NewReader("later\n"), &bytes.Buffer{}).Confirm("Kill daemon?") | |
| 35 | + | if err != nil { | |
| 36 | + | t.Fatalf("Confirm returned error: %v", err) | |
| 37 | + | } | |
| 38 | + | if ok { | |
| 39 | + | t.Fatal("Confirm returned true for unknown answer, want false") | |
| 40 | + | } | |
| 41 | + | } | |
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | package prompt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestConfirmerAcceptsYes(t *testing.T) { | |||
| 10 | + | var out bytes.Buffer | |||
| 11 | + | ok, err := NewConfirmer(strings.NewReader("y\n"), &out).Confirm("Kill daemon?") | |||
| 12 | + | if err != nil { | |||
| 13 | + | t.Fatalf("Confirm returned error: %v", err) | |||
| 14 | + | } | |||
| 15 | + | if !ok { | |||
| 16 | + | t.Fatal("Confirm returned false, want true") | |||
| 17 | + | } | |||
| 18 | + | if got := out.String(); !strings.Contains(got, "Kill daemon?") || !strings.Contains(got, "[y/N]") { | |||
| 19 | + | t.Fatalf("prompt output = %q, want question and default", got) | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestConfirmerDefaultsNo(t *testing.T) { | |||
| 24 | + | ok, err := NewConfirmer(strings.NewReader("\n"), &bytes.Buffer{}).Confirm("Kill daemon?") | |||
| 25 | + | if err != nil { | |||
| 26 | + | t.Fatalf("Confirm returned error: %v", err) | |||
| 27 | + | } | |||
| 28 | + | if ok { | |||
| 29 | + | t.Fatal("Confirm returned true for empty answer, want false") | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestConfirmerRejectsUnknownAnswer(t *testing.T) { | |||
| 34 | + | ok, err := NewConfirmer(strings.NewReader("later\n"), &bytes.Buffer{}).Confirm("Kill daemon?") | |||
| 35 | + | if err != nil { | |||
| 36 | + | t.Fatalf("Confirm returned error: %v", err) | |||
| 37 | + | } | |||
| 38 | + | if ok { | |||
| 39 | + | t.Fatal("Confirm returned true for unknown answer, want false") | |||
| 40 | + | } | |||
| 41 | + | } | |||