v0.7.0 4c16dd8

cmd.go RawHistoryFolder

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}

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