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/ports"
16 commandusecase "github.com/bnema/vev/internal/usecase/command"
17 "github.com/bnema/vev/internal/usecase/daemon"
18)
19
20type cmdInvocation struct {
21 slug string
22 args []string
23 session string
24 self bool
25 jsonOut bool
26 help bool
27}
28
29type exitCoded struct {
30 code int
31 err error
32}
33
34func (e *exitCoded) Error() string { return e.err.Error() }
35func (e *exitCoded) Unwrap() error { return e.err }
36
37func exitCode(err error) int {
38 if err == nil {
39 return 0
40 }
41 if coded, ok := errors.AsType[*exitCoded](err); ok {
42 return coded.code
43 }
44 if _, ok := errors.AsType[*usageError](err); ok {
45 return 2
46 }
47 return 1
48}
49
50// ExitCode maps application outcomes to the CLI contract.
51func ExitCode(err error) int { return exitCode(err) }
52
53func parseCmdArgs(args []string) (cmdInvocation, error) {
54 invocation := cmdInvocation{}
55 index := 0
56 for index < len(args) && invocation.slug == "" {
57 switch args[index] {
58 case "--help", "-h":
59 invocation.help = true
60 index++
61 case "-s":
62 if index+1 >= len(args) || args[index+1] == "" {
63 return cmdInvocation{}, usagef("`cmd -s` requires a session name")
64 }
65 invocation.session = args[index+1]
66 index += 2
67 case "--self":
68 invocation.self = true
69 index++
70 case "--json":
71 invocation.jsonOut = true
72 index++
73 default:
74 if strings.HasPrefix(args[index], "-") {
75 return cmdInvocation{}, usagef("unknown flag %q for `cmd`", args[index])
76 }
77 invocation.slug = args[index]
78 index++
79 }
80 }
81 if invocation.slug == "" {
82 invocation.help = true
83 return invocation, nil
84 }
85 if invocation.self && invocation.session != "" {
86 return cmdInvocation{}, usagef("`cmd --self` cannot be combined with `-s`")
87 }
88 cmd, ok := commandusecase.BySlug(invocation.slug)
89 if !ok || !cmd.Scriptable {
90 return cmdInvocation{}, usagef("unknown command %q; see `vev cmd --help`", invocation.slug)
91 }
92 for _, arg := range args[index:] {
93 switch arg {
94 case "--help", "-h":
95 invocation.help = true
96 return invocation, nil
97 case "--json":
98 invocation.jsonOut = true
99 default:
100 invocation.args = append(invocation.args, arg)
101 }
102 }
103 return invocation, nil
104}
105
106func cmdHelp(invocation cmdInvocation) string {
107 if invocation.slug != "" {
108 cmd, _ := commandusecase.BySlug(invocation.slug)
109 return fmt.Sprintf("usage: vev cmd [-s <session>] [--self] %s\n\n%s", cmd.Usage, cmd.Desc)
110 }
111 var out strings.Builder
112 out.WriteString("usage: vev cmd [-s <session>] [--self] <command> [args]\n\ncommands:\n")
113 writer := tabwriter.NewWriter(&out, 0, 4, 2, ' ', 0)
114 commands := commandusecase.Registry()
115 sort.Slice(commands, func(i, j int) bool { return commands[i].Slug < commands[j].Slug })
116 for _, cmd := range commands {
117 if cmd.Scriptable {
118 _, _ = fmt.Fprintf(writer, " %s\t%s\n", cmd.Usage, cmd.Desc)
119 }
120 }
121 _ = writer.Flush()
122 return out.String()
123}
124
125var errDaemonUnreachable = errors.New("daemon not running")
126
127type cmdDeps struct {
128 stdout io.Writer
129 getenv func(string) string
130 dial func(context.Context, string) (ports.Transport, error)
131 ensure func(context.Context, string) (ports.Transport, error)
132 clock ports.Clock
133}
134
135func runCmd(ctx context.Context, invocation cmdInvocation) error {
136 return runCmdWithDeps(ctx, invocation, cmdDeps{
137 stdout: os.Stdout,
138 getenv: os.Getenv,
139 dial: realDial,
140 ensure: func(ctx context.Context, dir string) (ports.Transport, error) {
141 return ensureDaemonWithLifecycle(ctx, dir, realDial, realSpawn, defaultBackoff)
142 },
143 clock: clock.New(),
144 })
145}
146
147func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error {
148 if invocation.help {
149 _, err := fmt.Fprintln(deps.stdout, cmdHelp(invocation))
150 return err
151 }
152 request := ports.CommandRequest{
153 Version: ports.ProtocolVersion,
154 Self: invocation.self,
155 Slug: invocation.slug,
156 Args: invocation.args,
157 TargetSession: invocation.session,
158 JSON: invocation.jsonOut,
159 }
160 if identity, ok := parseVEVEnv(deps.getenv("VEV")); ok {
161 if request.TargetSession == "" {
162 request.TargetSession = identity.session
163 }
164 if invocation.self || invocation.session == "" {
165 request.TargetTab = identity.tab
166 request.TargetPane = identity.pane
167 }
168 } else if invocation.self {
169 return usagef("--self requires running inside a vev pane")
170 }
171
172 if _, err := ports.MarshalCommandRequest(request); errors.Is(err, ports.ErrTooManyCommandArgs) {
173 return usagef("too many command arguments")
174 } else if err != nil {
175 return fmt.Errorf("encoding command: %w", err)
176 }
177
178 dial := deps.dial
179 if invocation.slug == "remote-catalog" && deps.ensure != nil {
180 dial = deps.ensure
181 }
182 transport, err := dial(ctx, ipc.SocketDir())
183 if err != nil {
184 return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)}
185 }
186 defer func() { _ = transport.Close() }()
187
188 tracker := daemon.NewCommandRequestTracker()
189 const generation = uint64(1)
190 requestID, outcome := tracker.Publish(generation)
191 request.RequestID = requestID
192 payload, err := ports.MarshalCommandRequest(request)
193 if err != nil {
194 tracker.Remove(requestID, generation)
195 return fmt.Errorf("encoding command: %w", err)
196 }
197 if err := transport.Send(ports.Frame{Type: ports.MsgCommand, Payload: payload}); err != nil {
198 tracker.Remove(requestID, generation)
199 return &exitCoded{code: 3, err: fmt.Errorf("sending command: %w", err)}
200 }
201 go func() {
202 reply, recvErr := transport.Recv()
203 if recvErr != nil {
204 tracker.Fail(requestID, generation, fmt.Errorf("reading command reply: %w", recvErr))
205 return
206 }
207 if reply.Type != ports.MsgCommandResult {
208 tracker.Fail(requestID, generation, fmt.Errorf("unexpected command reply type %d", reply.Type))
209 return
210 }
211 result, decodeErr := ports.UnmarshalCommandResult(reply.Payload)
212 if decodeErr != nil {
213 tracker.Fail(requestID, generation, fmt.Errorf("decoding command reply: %w", decodeErr))
214 return
215 }
216 result.RequestID = requestID
217 tracker.Complete(generation, result)
218 }()
219 commandClock := deps.clock
220 if commandClock == nil {
221 commandClock = clock.New()
222 }
223 result, err := tracker.Wait(ctx, commandClock, requestID, generation, outcome)
224 if err != nil {
225 return &exitCoded{code: 3, err: err}
226 }
227 if !result.OK {
228 if result.Code == ports.ErrInvalidCommandArgs {
229 return &exitCoded{code: 2, err: errors.New(result.Text)}
230 }
231 return errors.New(result.Text)
232 }
233 if result.Output != "" {
234 if _, err := io.WriteString(deps.stdout, result.Output); err != nil {
235 return err
236 }
237 if !strings.HasSuffix(result.Output, "\n") {
238 _, err = fmt.Fprintln(deps.stdout)
239 return err
240 }
241 }
242 return nil
243}