package app import ( "context" "errors" "fmt" "io" "os" "sort" "strings" "text/tabwriter" "github.com/bnema/vev/internal/adapters/clock" "github.com/bnema/vev/internal/adapters/ipc" "github.com/bnema/vev/internal/adapters/sessionwire" "github.com/bnema/vev/internal/ports" "github.com/bnema/vev/internal/protocol" "github.com/bnema/vev/internal/protocol/wire" clientusecase "github.com/bnema/vev/internal/usecase/client" commandusecase "github.com/bnema/vev/internal/usecase/command" "github.com/bnema/vev/internal/usecase/daemon" ) type cmdInvocation struct { slug string args []string session string self bool jsonOut bool help bool } type exitCoded struct { code int err error } func (e *exitCoded) Error() string { return e.err.Error() } func (e *exitCoded) Unwrap() error { return e.err } func exitCode(err error) int { if err == nil { return 0 } if coded, ok := errors.AsType[*exitCoded](err); ok { return coded.code } if _, ok := errors.AsType[*usageError](err); ok { return 2 } return 1 } // ExitCode maps application outcomes to the CLI contract. func ExitCode(err error) int { return exitCode(err) } func parseCmdArgs(args []string) (cmdInvocation, error) { invocation := cmdInvocation{} index := 0 for index < len(args) && invocation.slug == "" { switch args[index] { case "--help", "-h": invocation.help = true index++ case "-s": if index+1 >= len(args) || args[index+1] == "" { return cmdInvocation{}, usagef("`cmd -s` requires a session name") } invocation.session = args[index+1] index += 2 case "--self": invocation.self = true index++ case "--json": invocation.jsonOut = true index++ default: if strings.HasPrefix(args[index], "-") { return cmdInvocation{}, usagef("unknown flag %q for `cmd`", args[index]) } invocation.slug = args[index] index++ } } if invocation.slug == "" { invocation.help = true return invocation, nil } if invocation.self && invocation.session != "" { return cmdInvocation{}, usagef("`cmd --self` cannot be combined with `-s`") } cmd, ok := commandusecase.BySlug(invocation.slug) if !ok || !cmd.Scriptable { return cmdInvocation{}, usagef("unknown command %q; see `vev cmd --help`", invocation.slug) } for _, arg := range args[index:] { switch arg { case "--help", "-h": invocation.help = true return invocation, nil case "--json": invocation.jsonOut = true default: invocation.args = append(invocation.args, arg) } } return invocation, nil } func cmdHelp(invocation cmdInvocation) string { if invocation.slug != "" { cmd, _ := commandusecase.BySlug(invocation.slug) return fmt.Sprintf("usage: vev cmd [-s ] [--self] %s\n\n%s", cmd.Usage, cmd.Desc) } var out strings.Builder out.WriteString("usage: vev cmd [-s ] [--self] [args]\n\ncommands:\n") writer := tabwriter.NewWriter(&out, 0, 4, 2, ' ', 0) commands := commandusecase.Registry() sort.Slice(commands, func(i, j int) bool { return commands[i].Slug < commands[j].Slug }) for _, cmd := range commands { if cmd.Scriptable { _, _ = fmt.Fprintf(writer, " %s\t%s\n", cmd.Usage, cmd.Desc) } } _ = writer.Flush() return out.String() } var errDaemonUnreachable = errors.New("daemon not running") // errCommandNotSent reports a command request that was never placed on the wire // because its caller was already canceled. The outcome is definite: the daemon // cannot have acted, so it must not be reported as indeterminate. var errCommandNotSent = errors.New("command request not sent") // errCommandOutcomeUnknown reports a command whose reply was lost, wrong-typed, // uncorrelated, malformed, or never observed before the deadline. The daemon may // already have committed the command, so the outcome is indeterminate, the // request is never replayed, and a late result cannot change the verdict. var errCommandOutcomeUnknown = errors.New("command outcome unknown") // commandOutcomeUnknown classifies one observation detail as an indeterminate // command outcome while preserving that detail's exact message and unwrap chain. type commandOutcomeUnknown struct{ detail error } func (e *commandOutcomeUnknown) Error() string { return "command outcome unknown: " + e.detail.Error() } func (e *commandOutcomeUnknown) Unwrap() error { return e.detail } // Is reports the shared unknown-outcome sentinel so callers classify a lost // reply without matching on its observation text. func (*commandOutcomeUnknown) Is(target error) bool { return target == errCommandOutcomeUnknown } type cmdDeps struct { stdout io.Writer getenv func(string) string connect func(context.Context) (ports.BrokerService, error) dial func(context.Context, string) (wire.Transport, error) ensure func(context.Context, string) (wire.Transport, error) clock ports.Clock } func runCmd(ctx context.Context, invocation cmdInvocation) error { return runCmdWithDeps(ctx, invocation, productionCmdDeps()) } // productionCmdDeps wires control commands to the real broker and daemon. func productionCmdDeps() cmdDeps { return cmdDeps{ stdout: os.Stdout, getenv: os.Getenv, connect: connectProductionBroker, dial: realDial, ensure: func(ctx context.Context, dir string) (wire.Transport, error) { return ensureDaemonWithLifecycle(ctx, dir, realDial, realSpawn, defaultBackoff) }, clock: clock.New(), } } func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error { if invocation.help { _, err := fmt.Fprintln(deps.stdout, cmdHelp(invocation)) return err } request := protocol.CommandRequest{ Version: protocol.Version, Self: invocation.self, Slug: invocation.slug, Args: invocation.args, TargetSession: invocation.session, JSON: invocation.jsonOut, } if identity, ok := parseVEVEnv(deps.getenv("VEV")); ok { if request.TargetSession == "" { request.TargetSession = identity.session } if invocation.self || invocation.session == "" { request.TargetTab = identity.tab request.TargetPane = identity.pane } } else if invocation.self { return usagef("--self requires running inside a vev pane") } if len(request.Args) > 65535 { return usagef("too many command arguments") } if deps.connect != nil { service, err := deps.connect(ctx) if err != nil { return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)} } defer func() { _ = service.Close() }() commandClock := deps.clock if commandClock == nil { commandClock = clock.New() } operations, err := clientusecase.NewBrokerOperations(service, commandClock) if err != nil { return err } result, err := operations.Command(ctx, localBrokerOperationRoute(service.Snapshot()), request) if err != nil { return err } return renderCommandResult(deps.stdout, result) } transport, err := deps.dial(ctx, ipc.SocketDir()) if err != nil { return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)} } defer func() { _ = transport.Close() }() tracker := daemon.NewCommandRequestTracker() const generation = uint64(1) requestID, outcome := tracker.Publish(generation) request.RequestID = requestID connection := sessionwire.NewClientConnection(transport) // A caller already canceled before the send has a definite not-sent outcome: // the request is never placed on the wire, so nothing can commit and the // request is never replayed. if ctxErr := ctx.Err(); ctxErr != nil { tracker.Remove(requestID, generation) return fmt.Errorf("%w: %w", errCommandNotSent, ctxErr) } if err := connection.SendClient(request); err != nil { tracker.Remove(requestID, generation) return &exitCoded{code: 3, err: fmt.Errorf("sending command: %w", err)} } go func() { reply, recvErr := connection.ReceiveServer() if recvErr != nil { tracker.Fail(requestID, generation, fmt.Errorf("reading command reply: %w", recvErr)) return } result, ok := reply.(protocol.CommandResult) if !ok { tracker.Fail(requestID, generation, fmt.Errorf("unexpected command reply %T", reply)) return } if result.RequestID != requestID { tracker.Fail(requestID, generation, fmt.Errorf("unexpected command reply request ID %d", result.RequestID)) return } if !result.Valid() { tracker.Fail(requestID, generation, errors.New("malformed command outcome")) return } tracker.Complete(generation, result) }() commandClock := deps.clock if commandClock == nil { commandClock = clock.New() } result, err := tracker.Wait(ctx, commandClock, requestID, generation, outcome) if err != nil { // A reply that never arrives (loss, EOF, cancellation, or deadline) leaves // the outcome indeterminate: the request was sent, so it is never replayed. return &exitCoded{code: 3, err: &commandOutcomeUnknown{detail: err}} } if result.Outcome == protocol.CommandOutcomeUnknown { text := result.Text if text == "" { text = "daemon did not report a final outcome" } return &exitCoded{code: 3, err: &commandOutcomeUnknown{detail: errors.New(text)}} } if result.Outcome != protocol.CommandSucceeded { if result.Text == "" { result.Text = "command failed" } if result.Code == protocol.ErrInvalidCommandArgs { return &exitCoded{code: 2, err: errors.New(result.Text)} } return errors.New(result.Text) } if result.Output != "" { if _, err := io.WriteString(deps.stdout, result.Output); err != nil { return err } if !strings.HasSuffix(result.Output, "\n") { _, err = fmt.Fprintln(deps.stdout) return err } } return nil } func renderCommandResult(stdout io.Writer, result protocol.CommandResult) error { if result.Outcome == protocol.CommandOutcomeUnknown { text := result.Text if text == "" { text = "daemon did not report a final outcome" } return &exitCoded{code: 3, err: &commandOutcomeUnknown{detail: errors.New(text)}} } if result.Outcome != protocol.CommandSucceeded { if result.Text == "" { result.Text = "command failed" } if result.Code == protocol.ErrInvalidCommandArgs { return &exitCoded{code: 2, err: errors.New(result.Text)} } return errors.New(result.Text) } if result.Output != "" { if _, err := io.WriteString(stdout, result.Output); err != nil { return err } if !strings.HasSuffix(result.Output, "\n") { _, err := fmt.Fprintln(stdout) return err } } return nil }