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("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, productionCmdDeps())
166}
167
168// productionCmdDeps wires control commands to the real broker and daemon.
169func productionCmdDeps() cmdDeps {
170 return cmdDeps{
171 stdout: os.Stdout,
172 getenv: os.Getenv,
173 connect: connectProductionBroker,
174 dial: realDial,
175 ensure: func(ctx context.Context, dir string) (wire.Transport, error) {
176 return ensureDaemonWithLifecycle(ctx, dir, realDial, realSpawn, defaultBackoff)
177 },
178 clock: clock.New(),
179 }
180}
181
182func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error {
183 if invocation.help {
184 _, err := fmt.Fprintln(deps.stdout, cmdHelp(invocation))
185 return err
186 }
187 request := protocol.CommandRequest{
188 Version: protocol.Version,
189 Self: invocation.self,
190 Slug: invocation.slug,
191 Args: invocation.args,
192 TargetSession: invocation.session,
193 JSON: invocation.jsonOut,
194 }
195 if identity, ok := parseVEVEnv(deps.getenv("VEV")); ok {
196 if request.TargetSession == "" {
197 request.TargetSession = identity.session
198 }
199 if invocation.self || invocation.session == "" {
200 request.TargetTab = identity.tab
201 request.TargetPane = identity.pane
202 }
203 } else if invocation.self {
204 return usagef("--self requires running inside a vev pane")
205 }
206
207 if len(request.Args) > 65535 {
208 return usagef("too many command arguments")
209 }
210
211 if deps.connect != nil {
212 service, err := deps.connect(ctx)
213 if err != nil {
214 return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)}
215 }
216 defer func() { _ = service.Close() }()
217 commandClock := deps.clock
218 if commandClock == nil {
219 commandClock = clock.New()
220 }
221 operations, err := clientusecase.NewBrokerOperations(service, commandClock)
222 if err != nil {
223 return err
224 }
225 result, err := operations.Command(ctx, localBrokerOperationRoute(service.Snapshot()), request)
226 if err != nil {
227 return err
228 }
229 return renderCommandResult(deps.stdout, result)
230 }
231
232 transport, err := deps.dial(ctx, ipc.SocketDir())
233 if err != nil {
234 return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)}
235 }
236 defer func() { _ = transport.Close() }()
237
238 tracker := daemon.NewCommandRequestTracker()
239 const generation = uint64(1)
240 requestID, outcome := tracker.Publish(generation)
241 request.RequestID = requestID
242 connection := sessionwire.NewClientConnection(transport)
243 // A caller already canceled before the send has a definite not-sent outcome:
244 // the request is never placed on the wire, so nothing can commit and the
245 // request is never replayed.
246 if ctxErr := ctx.Err(); ctxErr != nil {
247 tracker.Remove(requestID, generation)
248 return fmt.Errorf("%w: %w", errCommandNotSent, ctxErr)
249 }
250 if err := connection.SendClient(request); err != nil {
251 tracker.Remove(requestID, generation)
252 return &exitCoded{code: 3, err: fmt.Errorf("sending command: %w", err)}
253 }
254 go func() {
255 reply, recvErr := connection.ReceiveServer()
256 if recvErr != nil {
257 tracker.Fail(requestID, generation, fmt.Errorf("reading command reply: %w", recvErr))
258 return
259 }
260 result, ok := reply.(protocol.CommandResult)
261 if !ok {
262 tracker.Fail(requestID, generation, fmt.Errorf("unexpected command reply %T", reply))
263 return
264 }
265 if result.RequestID != requestID {
266 tracker.Fail(requestID, generation, fmt.Errorf("unexpected command reply request ID %d", result.RequestID))
267 return
268 }
269 if !result.Valid() {
270 tracker.Fail(requestID, generation, errors.New("malformed command outcome"))
271 return
272 }
273 tracker.Complete(generation, result)
274 }()
275 commandClock := deps.clock
276 if commandClock == nil {
277 commandClock = clock.New()
278 }
279 result, err := tracker.Wait(ctx, commandClock, requestID, generation, outcome)
280 if err != nil {
281 // A reply that never arrives (loss, EOF, cancellation, or deadline) leaves
282 // the outcome indeterminate: the request was sent, so it is never replayed.
283 return &exitCoded{code: 3, err: &commandOutcomeUnknown{detail: err}}
284 }
285 if result.Outcome == protocol.CommandOutcomeUnknown {
286 text := result.Text
287 if text == "" {
288 text = "daemon did not report a final outcome"
289 }
290 return &exitCoded{code: 3, err: &commandOutcomeUnknown{detail: errors.New(text)}}
291 }
292 if result.Outcome != protocol.CommandSucceeded {
293 if result.Text == "" {
294 result.Text = "command failed"
295 }
296 if result.Code == protocol.ErrInvalidCommandArgs {
297 return &exitCoded{code: 2, err: errors.New(result.Text)}
298 }
299 return errors.New(result.Text)
300 }
301 if result.Output != "" {
302 if _, err := io.WriteString(deps.stdout, result.Output); err != nil {
303 return err
304 }
305 if !strings.HasSuffix(result.Output, "\n") {
306 _, err = fmt.Fprintln(deps.stdout)
307 return err
308 }
309 }
310 return nil
311}
312
313func renderCommandResult(stdout io.Writer, result protocol.CommandResult) error {
314 if result.Outcome == protocol.CommandOutcomeUnknown {
315 text := result.Text
316 if text == "" {
317 text = "daemon did not report a final outcome"
318 }
319 return &exitCoded{code: 3, err: &commandOutcomeUnknown{detail: errors.New(text)}}
320 }
321 if result.Outcome != protocol.CommandSucceeded {
322 if result.Text == "" {
323 result.Text = "command failed"
324 }
325 if result.Code == protocol.ErrInvalidCommandArgs {
326 return &exitCoded{code: 2, err: errors.New(result.Text)}
327 }
328 return errors.New(result.Text)
329 }
330 if result.Output != "" {
331 if _, err := io.WriteString(stdout, result.Output); err != nil {
332 return err
333 }
334 if !strings.HasSuffix(result.Output, "\n") {
335 _, err := fmt.Fprintln(stdout)
336 return err
337 }
338 }
339 return nil
340}