1// Package app wires vev's runtime together via dependency injection. It is
2// the single place where usecases (client, daemon) are connected to concrete
3// adapters (ipc transport/listener, terminal); usecases themselves never
4// import adapters.
5package app
6
7import (
8 "context"
9 "errors"
10 "fmt"
11 "io"
12 "log/slog"
13 _ "net/http/pprof"
14 "os"
15 "path/filepath"
16 "time"
17
18 "github.com/bnema/vev/internal/adapters/clock"
19 "github.com/bnema/vev/internal/adapters/config"
20 "github.com/bnema/vev/internal/domain"
21 "github.com/bnema/vev/internal/logging"
22 "github.com/bnema/vev/internal/platform"
23 "github.com/bnema/vev/internal/protocol"
24 "github.com/bnema/vev/internal/usecase/client"
25)
26
27// Build metadata. Defaults describe a plain `go build`; releases overwrite
28// them via -ldflags "-X github.com/bnema/vev/internal/app.version=..." (and
29// .commit / .date) — see .goreleaser.yaml.
30var (
31 version = "0.1.0-dev"
32 commit = "none"
33 date = "unknown"
34)
35
36// versionLine renders the --version output.
37func versionLine() string {
38 return fmt.Sprintf("vev %s (commit %s, built %s)", version, commit, date)
39}
40
41// Run is the entry point invoked by main. It parses args into a command and
42// dispatches it.
43func Run(args []string) error {
44 if err := platform.ActivateDevelopmentEnvironment(); err != nil {
45 return err
46 }
47 cmd, err := parseArgs(args)
48 if err != nil {
49 return err
50 }
51 return dispatch(context.Background(), cmd)
52}
53
54// dispatch runs the parsed command.
55func dispatch(ctx context.Context, cmd command) error {
56 switch cmd.kind {
57 case kindHelp:
58 fmt.Println(usageText)
59 return nil
60 case kindVersion:
61 fmt.Println(versionLine())
62 return nil
63 case kindWebRenew:
64 return renewWebToken(ctx)
65 case kindWebDaemon:
66 return launchWebDaemon(ctx, cmd.web)
67 case kindWebServe:
68 return runWebDaemon(ctx, cmd.web)
69 case kindDaemon:
70 return runDaemon()
71 case kindDaemonLauncher:
72 return runDaemonLauncher()
73 case kindBrokerMuxStdio:
74 return runBrokerMuxStdioCommand(ctx, cmd.brokerMux)
75 case kindBrokerMuxQUICBootstrap:
76 return runBrokerMuxQUICBootstrapCommand(ctx, cmd.brokerMux)
77 case kindBrokerMuxQUICProxy:
78 return runBrokerMuxQUICProxyCommand(ctx, cmd.brokerMux)
79 case kindProductionBrokerServe:
80 return runBrokerServeCommand(ctx, cmd.brokerServe)
81 case kindProductionBrokerLauncher:
82 return runProductionBrokerLauncherCommand(ctx)
83 case kindBrokerReady:
84 return runBrokerReady(ctx, cmd.brokerReady, os.Stdout)
85 case kindDaemonStop:
86 return requestDaemonStop(ctx)
87 case kindUIRemoteCleanup:
88 return runUIRemoteCleanup(ctx)
89 case kindUIDriver:
90 return runUIDriver(ctx, cmd.uiDriver)
91 case kindList:
92 return runList(ctx, cmd)
93 case kindHost:
94 return runHostCommand(ctx, cmd, defaultRemoteHostDeps())
95 case kindKill:
96 if cmd.killAll {
97 return runKillAll(ctx, os.Stdout)
98 }
99 return runKill(ctx, cmd.name, cmd.killSessions)
100 case kindCmd:
101 return runCmd(ctx, cmd.cmd)
102 case kindEnv:
103 return runEnv(ctx, cmd.env)
104 case kindAttach:
105 return runAttachWithOptions(ctx, cmd.intent, cmd.name, cmd.remoteTarget, cmd.ui)
106 default:
107 return usagef("unhandled command")
108 }
109}
110
111func configureLogging(component logging.Component, rotateAtRuntime bool) (*slog.Logger, io.Closer, error) {
112 return logging.Setup(logging.Config{
113 Dir: platform.StateDir(),
114 Component: component,
115 Level: logging.EnvLevel(),
116 MaxBytes: logging.DefaultMaxBytes,
117 RotateAtRuntime: rotateAtRuntime,
118 })
119}
120
121func logConfigWarnings(log *slog.Logger, warnings []domain.Warning) {
122 for _, warning := range warnings {
123 if warning.Line > 0 {
124 log.Warn("config warning", "line", warning.Line, "msg", warning.Msg)
125 continue
126 }
127 log.Warn("config warning", "msg", warning.Msg)
128 }
129}
130
131func loadConfigOrDefaults(log *slog.Logger, path string) domain.Config {
132 cfg, warnings, err := config.Load(path)
133 if err != nil {
134 if log != nil {
135 log.Warn("loading config failed; using defaults", "path", path, "err", err)
136 }
137 cfg = domain.Defaults()
138 }
139 if log != nil {
140 logConfigWarnings(log, warnings)
141 }
142 return cfg
143}
144
145const (
146 uiRemoteCleanupCommand = "_ui-cleanup"
147 // daemonStopCommand is the hidden, environment-scoped graceful stop of
148 // this environment's daemon, for harnesses and scripts. Users run
149 // `kill --all` instead.
150 daemonStopCommand = "_daemon-stop"
151)
152
153const daemonStopTimeout = 2 * time.Second
154
155var errDaemonNotRunning = errors.New("no daemon running")
156
157func runUIRemoteCleanup(ctx context.Context) error {
158 root := os.Getenv("VEV_ENV_ROOT")
159 if !filepath.IsAbs(root) || filepath.Clean(root) == string(filepath.Separator) {
160 return errors.New("remote UI cleanup requires a private absolute launch root")
161 }
162 if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir == "" || runtimeDir != filepath.Join(root, "runtime") {
163 return errors.New("remote UI cleanup requires the launch runtime directory")
164 }
165 if err := requestDaemonStop(ctx); err != nil && !errors.Is(err, errDaemonNotRunning) {
166 return err
167 }
168 return nil
169}
170
171// connectDaemonStopBroker is the broker connection seam for requestDaemonStop.
172var connectDaemonStopBroker = connectExistingBroker
173
174func requestDaemonStop(ctx context.Context) error {
175 ctx, cancel := context.WithTimeout(ctx, daemonStopTimeout)
176 defer cancel()
177 service, err := connectDaemonStopBroker(ctx)
178 if err != nil {
179 if errors.Is(err, errBrokerAbsent) {
180 return stopDaemonWithoutBroker(ctx)
181 }
182 return unreachableBrokerError(err)
183 }
184 defer func() { _ = service.Close() }()
185 operations, err := client.NewBrokerOperations(service, clock.New())
186 if err != nil {
187 return err
188 }
189 result, err := operations.StopDaemon(ctx, localBrokerOperationRoute(service.Snapshot()))
190 if err != nil {
191 return err
192 }
193 return brokerKillResultError(result)
194}
195
196func unreachableBrokerError(err error) error {
197 return &exitCoded{code: 3, err: fmt.Errorf("%w: %v", errDaemonUnreachable, err)}
198}
199
200// stopDaemonWithoutBroker asks an existing local daemon to stop directly. A
201// daemon routinely outlives the idle broker, so "no broker" never means "no
202// daemon".
203func stopDaemonWithoutBroker(ctx context.Context) error {
204 reply, err := exchangeLocalDaemon(ctx, protocol.Kill{RequestID: 1, Scope: protocol.KillDaemon})
205 if errors.Is(err, errNoLocalDaemon) {
206 return errDaemonNotRunning
207 }
208 if err != nil {
209 return fmt.Errorf("stop local daemon: %w", err)
210 }
211 result, ok := reply.(protocol.KillResult)
212 if !ok {
213 return fmt.Errorf("unexpected daemon stop reply %T", reply)
214 }
215 return brokerKillResultError(result)
216}