001/*
002 * SPDX-License-Identifier: Apache-2.0
003 *
004 * Copyright 2023-2026 The Enola <https://enola.dev> Authors
005 *
006 * Licensed under the Apache License, Version 2.0 (the "License");
007 * you may not use this file except in compliance with the License.
008 * You may obtain a copy of the License at
009 *
010 *     https://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package dev.enola.cli;
019
020import dev.enola.ai.adk.web.AdkHttpServer;
021import dev.enola.chat.sshd.EnolaSshServer;
022import dev.enola.common.FreedesktopDirectories;
023import dev.enola.common.context.TLC;
024import dev.enola.core.grpc.EnolaGrpcServer;
025import dev.enola.core.proto.EnolaServiceGrpc;
026import dev.enola.web.*;
027import dev.enola.web.netty.NettyHttpServer;
028
029import org.jspecify.annotations.Nullable;
030
031import picocli.CommandLine;
032
033@CommandLine.Command(name = "server", description = "Start HTTP, SSH and/or gRPC Server/s")
034public class ServerCommand extends CommandWithModel {
035
036    @CommandLine.ArgGroup(exclusive = false, multiplicity = "1")
037    HttpAndOrGrpcPorts ports;
038
039    @CommandLine.ArgGroup(exclusive = false)
040    @Nullable AiOptions aiOptions;
041
042    @CommandLine.Option(
043            names = {"--immediateExitOnlyForTest"},
044            defaultValue = "false",
045            hidden = true)
046    boolean immediateExitOnlyForTest;
047
048    private @Nullable EnolaGrpcServer grpcServer;
049    private @Nullable WebServer httpServer;
050    private @Nullable AutoCloseable chatServer;
051    private @Nullable EnolaSshServer sshServer;
052
053    @Override
054    protected void run(EnolaServiceGrpc.EnolaServiceBlockingStub service) throws Exception {
055        try (var ctx = TLC.open()) {
056            setup(ctx);
057            runInContext(service);
058        }
059    }
060
061    private void runInContext(EnolaServiceGrpc.EnolaServiceBlockingStub service) throws Exception {
062        var out = spec.commandLine().getOut();
063
064        // gRPC API
065        if (ports.grpcPort != null) {
066            grpcServer = new EnolaGrpcServer(esp, esp.getEnolaService());
067            grpcServer.start(ports.grpcPort);
068            out.println("gRPC API server now available on port " + grpcServer.getPort());
069        }
070
071        // HTML UI + JSON REST API
072        if (ports.httpPort != null) {
073            var handlers = new WebHandlers();
074            new UI(service, getMetadataProvider(new EnolaThingProvider(service)))
075                    .register(handlers);
076            handlers.register("/api", new RestAPI(service));
077            httpServer = new NettyHttpServer(ports.httpPort, handlers);
078            httpServer.start();
079            out.println(
080                    "HTTP JSON REST API + HTML UI server started; open http://"
081                            + httpServer.getInetAddress()
082                            + "/ui ...");
083        }
084
085        // Chat (ADK) UI
086        if (ports.chatPort != null) {
087            var agents = AI.load(rp, aiOptions);
088            chatServer = AdkHttpServer.start(agents, ports.chatPort);
089            out.println(
090                    "HTTP Chat UI server started; open http://localhost:"
091                            + ports.chatPort
092                            + " ...");
093        }
094
095        // SSH Server
096        if (ports.sshPort != null) {
097            var hostKeyPath = FreedesktopDirectories.HOSTKEY_PATH;
098            sshServer = new EnolaSshServer(ports.sshPort, hostKeyPath);
099            out.println("SSH server (" + hostKeyPath + ") running on port " + sshServer.port());
100        }
101
102        if (!immediateExitOnlyForTest) {
103            Thread.currentThread().join();
104        } else {
105            close();
106        }
107    }
108
109    public void close() throws Exception {
110        if (grpcServer != null) {
111            grpcServer.close();
112        }
113        if (httpServer != null) {
114            httpServer.close();
115        }
116        if (chatServer != null) {
117            chatServer.close();
118        }
119        if (sshServer != null) {
120            sshServer.close();
121        }
122        // TODO Thread.currentThread().interrupt();
123    }
124
125    static class HttpAndOrGrpcPorts {
126        @CommandLine.Option(
127                names = {"--httpPort"},
128                description = "HTTP Port of Enola UI")
129        @Nullable Integer httpPort;
130
131        @CommandLine.Option(
132                names = {"--chatPort"},
133                description = "HTTP Port of Chat UI")
134        @Nullable Integer chatPort;
135
136        @CommandLine.Option(
137                names = {"--sshPort"},
138                description = "SSH (Chat) Port")
139        @Nullable Integer sshPort;
140
141        @CommandLine.Option(
142                names = {"--grpcPort"},
143                description = "gRPC API Port")
144        @Nullable Integer grpcPort;
145    }
146}