v0.5.0 c37833b

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("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}

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