1package app
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "os"
9 "sort"
10 "strings"
11 "text/tabwriter"
12
13 "github.com/bnema/vev/internal/adapters/clock"
14 "github.com/bnema/vev/internal/adapters/ipc"
15 "github.com/bnema/vev/internal/adapters/sessionwire"
16 "github.com/bnema/vev/internal/ports"
17 "github.com/bnema/vev/internal/protocol"
18 "github.com/bnema/vev/internal/protocol/wire"
19 clientusecase "github.com/bnema/vev/internal/usecase/client"
20 commandusecase "github.com/bnema/vev/internal/usecase/command"
21 "github.com/bnema/vev/internal/usecase/daemon"
22)
23
24type cmdInvocation struct {
25 slug string
26 args []string
27 session string
28 self bool
29 jsonOut bool
30 help bool
31}
32
33type exitCoded struct {
34 code int
35 err error
36}
37
38func (e *exitCoded) Error() string { return e.err.Error() }
39func (e *exitCoded) Unwrap() error { return e.err }
40
41func exitCode(err error) int {
42 if err == nil {
43 return 0
44 }
45 if coded, ok := errors.AsType[*exitCoded](err); ok {
46 return coded.code
47 }
48 if _, ok := errors.AsType[*usageError](err); ok {
49 return 2
50 }
51 return 1
52}
53
54// ExitCode maps application outcomes to the CLI contract.
55func ExitCode(err error) int { return exitCode(err) }
56
57func parseCmdArgs(args []string) (cmdInvocation, error) {
58 invocation := cmdInvocation{}
59 index := 0
60 for index < len(args) && invocation.slug == "" {
61 switch args[index] {
62 case "--help", "-h":
63 invocation.help = true
64 index++
65 case "-s":
66 if index+1 >= len(args) || args[index+1] == "" {
67 return cmdInvocation{}, usagef("`cmd -s` requires a session name")
68 }
69 invocation.session = args[index+1]
70 index += 2
71 case "--self":
72 invocation.self = true
73 index++
74 case "--json":
75 invocation.jsonOut = true
76 index++
77 default:
78 if strings.HasPrefix(args[index], "-") {
79 return cmdInvocation{}, usagef("unknown flag %q for `cmd`", args[index])
80 }
81 invocation.slug = args[index]
82 index++
83 }
84 }
85 if invocation.slug == "" {
86 invocation.help = true
87 return invocation, nil
88 }
89 if invocation.self && invocation.session != "" {
90 return cmdInvocation{}, usagef("`cmd --self` cannot be combined with `-s`")
91 }
92 cmd, ok := commandusecase.BySlug(invocation.slug)
93 if !ok || !cmd.Scriptable {
94 return cmdInvocation{}, usagef("unknown command %q; see `vev cmd --help`", invocation.slug)
95 }
96 for _, arg := range args[index:] {
97 switch arg {
98 case "--help", "-h":
99 invocation.help = true
100 return invocation, nil
101 case "--json":
102 invocation.jsonOut = true
103 default:
104 invocation.args = append(invocation.args, arg)
105 }
106 }
107 return invocation, nil
108}
109
110func cmdHelp(invocation cmdInvocation) string {
111 if invocation.slug != "" {
112 cmd, _ := commandusecase.BySlug(invocation.slug)
113 return fmt.Sprintf("usage: vev cmd [-s <session>] [--self] %s\n\n%s", cmd.Usage, cmd.Desc)
114 }
115 var out strings.Builder
116 out.WriteString("usage: vev cmd [-s <session>] [--self] <command> [args]\n\ncommands:\n")
117 writer := tabwriter.NewWriter(&out, 0, 4, 2, ' ', 0)
118 commands := commandusecase.Registry()
119 sort.Slice(commands, func(i, j int) bool { return commands[i].Slug < commands[j].Slug })
120 for _, cmd := range commands {
121 if cmd.Scriptable {
122 _, _ = fmt.Fprintf(writer, " %s\t%s\n", cmd.Usage, cmd.Desc)
123 }
124 }
125 _ = writer.Flush()
126 return out.String()
127}
128
129var errDaemonUnreachable = errors.New("daemon not running")
130
131// errCommandNotSent reports a command request that was never placed on the wire
132// because its caller was already canceled. The outcome is definite: the daemon
133// cannot have acted, so it must not be reported as indeterminate.
134var errCommandNotSent = errors.New("command request not sent")
135
136// errCommandOutcomeUnknown reports a command whose reply was lost, wrong-typed,
137// uncorrelated, malformed, or never observed before the deadline. The daemon may
138// already have committed the command, so the outcome is indeterminate, the
139// request is never replayed, and a late result cannot change the verdict.
140var errCommandOutcomeUnknown = errors.New("vev: command outcome unknown")
141
142// commandOutcomeUnknown classifies one observation detail as an indeterminate
143// command outcome while preserving that detail's exact message and unwrap chain.
144type commandOutcomeUnknown struct{ detail error }
145
146func (e *commandOutcomeUnknown) Error() string {
147 return "command outcome unknown: " + e.detail.Error()
148}
149func (e *commandOutcomeUnknown) Unwrap() error { return e.detail }
150
151// Is reports the shared unknown-outcome sentinel so callers classify a lost
152// reply without matching on its observation text.
153func (*commandOutcomeUnknown) Is(target error) bool { return target == errCommandOutcomeUnknown }
154
155type cmdDeps struct {
156 stdout io.Writer
157 getenv func(string) string
158 connect func(context.Context) (ports.BrokerService, error)
159 dial func(context.Context, string) (wire.Transport, error)
160 ensure func(context.Context, string) (wire.Transport, error)
161 clock ports.Clock
162}
163
164func runCmd(ctx context.Context, invocation cmdInvocation) error {
165 return runCmdWithDeps(ctx, invocation, cmdDeps{
166 stdout: os.Stdout,
167 getenv: os.Getenv,
168 connect: connectProductionBroker,
169 dial: realDial,
170 ensure: func(ctx context.Context, dir string) (wire.Transport, error) {
171 return ensureDaemonWithLifecycle(ctx, dir, realDial, realSpawn, defaultBackoff)
172 },
173 clock: clock.New(),
174 })
175}
176
177func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error {
178 if invocation.help {
179 _, err := fmt.Fprintln(deps.stdout, cmdHelp(invocation))
180 return err
181 }
182 request := protocol.CommandRequest{
183 Version: protocol.Version,
184 Self: invocation.self,
185 Slug: invocation.slug,
186 Args: invocation.args,
187 TargetSession: invocation.session,
188 JSON: invocation.jsonOut,
189 }
190 if identity, ok := parseVEVEnv(deps.getenv("VEV")); ok {
191 if request.TargetSession == "" {
192 request.TargetSession = identity.session
193 }
194 if invocation.self || invocation.session == "" {
195 request.TargetTab = identity.tab
196 request.TargetPane = identity.pane
197 }
198 } else if invocation.self {
199 return usagef("--self requires running inside a vev pane")
200 }
201
202 if len(request.Args) > 65535 {
203 return usagef("too many command arguments")
204 }
205
206 if deps.connect != nil {
207 service, err := deps.connect(ctx)
208 if err != nil {
209 return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)}
210 }
211 defer func() { _ = service.Close() }()
212 commandClock := deps.clock
213 if commandClock == nil {
214 commandClock = clock.New()
215 }
216 operations, err := clientusecase.NewBrokerOperations(service, commandClock)
217 if err != nil {
218 return err
219 }
220 result, err := operations.Command(ctx, localBrokerOperationRoute(service.Snapshot()), request)
221 if err != nil {
222 return err
223 }
224 return renderCommandResult(deps.stdout, result)
225 }
226
227 transport, err := deps.dial(ctx, ipc.SocketDir())
228 if err != nil {
229 return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)}
230 }
231 defer func() { _ = transport.Close() }()
232
233 tracker := daemon.NewCommandRequestTracker()
234 const generation = uint64(1)
235 requestID, outcome := tracker.Publish(generation)
236 request.RequestID = requestID
237 connection := sessionwire.NewClientConnection(transport)
238 // A caller already canceled before the send has a definite not-sent outcome:
239 // the request is never placed on the wire, so nothing can commit and the
240 // request is never replayed.
241 if ctxErr := ctx.Err(); ctxErr != nil {
242 tracker.Remove(requestID, generation)
243 return fmt.Errorf("%w: %w", errCommandNotSent, ctxErr)
244 }
245 if err := connection.SendClient(request); err != nil {
246 tracker.Remove(requestID, generation)
247 return &exitCoded{code: 3, err: fmt.Errorf("sending command: %w", err)}
248 }
249 go func() {
250 reply, recvErr := connection.ReceiveServer()
251 if recvErr != nil {
252 tracker.Fail(requestID, generation, fmt.Errorf("reading command reply: %w", recvErr))
253 return
254 }
255 result, ok := reply.(protocol.CommandResult)
256 if !ok {
257 tracker.Fail(requestID, generation, fmt.Errorf("unexpected command reply %T", reply))
258 return
259 }
260 if result.RequestID != requestID {
261 tracker.Fail(requestID, generation, fmt.Errorf("unexpected command reply request ID %d", result.RequestID))
262 return
263 }
264 if !result.Valid() {
265 tracker.Fail(requestID, generation, errors.New("malformed command outcome"))
266 return
267 }
268 tracker.Complete(generation, result)
269 }()
270 commandClock := deps.clock
271 if commandClock == nil {
272 commandClock = clock.New()
273 }
274 result, err := tracker.Wait(ctx, commandClock, requestID, generation, outcome)
275 if err != nil {
276 // A reply that never arrives (loss, EOF, cancellation, or deadline) leaves
277 // the outcome indeterminate: the request was sent, so it is never replayed.
278 return &exitCoded{code: 3, err: &commandOutcomeUnknown{detail: err}}
279 }
280 if result.Outcome == protocol.CommandOutcomeUnknown {
281 text := result.Text
282 if text == "" {
283 text = "daemon did not report a final outcome"
284 }
285 return &exitCoded{code: 3, err: &commandOutcomeUnknown{detail: errors.New(text)}}
286 }
287 if result.Outcome != protocol.CommandSucceeded {
288 if result.Text == "" {
289 result.Text = "command failed"
290 }
291 if result.Code == protocol.ErrInvalidCommandArgs {
292 return &exitCoded{code: 2, err: errors.New(result.Text)}
293 }
294 return errors.New(result.Text)
295 }
296 if result.Output != "" {
297 if _, err := io.WriteString(deps.stdout, result.Output); err != nil {
298 return err
299 }
300 if !strings.HasSuffix(result.Output, "\n") {
301 _, err = fmt.Fprintln(deps.stdout)
302 return err
303 }
304 }
305 return nil
306}
307
308func renderCommandResult(stdout io.Writer, result protocol.CommandResult) error {
309 if result.Outcome == protocol.CommandOutcomeUnknown {
310 text := result.Text
311 if text == "" {
312 text = "daemon did not report a final outcome"
313 }
314 return &exitCoded{code: 3, err: &commandOutcomeUnknown{detail: errors.New(text)}}
315 }
316 if result.Outcome != protocol.CommandSucceeded {
317 if result.Text == "" {
318 result.Text = "command failed"
319 }
320 if result.Code == protocol.ErrInvalidCommandArgs {
321 return &exitCoded{code: 2, err: errors.New(result.Text)}
322 }
323 return errors.New(result.Text)
324 }
325 if result.Output != "" {
326 if _, err := io.WriteString(stdout, result.Output); err != nil {
327 return err
328 }
329 if !strings.HasSuffix(result.Output, "\n") {
330 _, err := fmt.Fprintln(stdout)
331 return err
332 }
333 }
334 return nil
335}