diff --git a/apache-maven/src/assembly/component.xml b/apache-maven/src/assembly/component.xml
index 5f55a310c8bd..c615e8997fa4 100644
--- a/apache-maven/src/assembly/component.xml
+++ b/apache-maven/src/assembly/component.xml
@@ -89,6 +89,7 @@ under the License.
mvnencmvnshmvnup
+ mvnlogmvnDebugmvnencDebug
diff --git a/apache-maven/src/assembly/maven/bin/mvn b/apache-maven/src/assembly/maven/bin/mvn
index 0adc4eabeb2b..8710aa11d70d 100755
--- a/apache-maven/src/assembly/maven/bin/mvn
+++ b/apache-maven/src/assembly/maven/bin/mvn
@@ -303,6 +303,10 @@ handle_args() {
--up)
MAVEN_MAIN_CLASS="org.apache.maven.cling.MavenUpCling"
;;
+ --log)
+ MAVEN_MAIN_CLASS="org.apache.maven.cling.MavenLogCling"
+ ;;
+
*)
;;
esac
diff --git a/apache-maven/src/assembly/maven/bin/mvn.cmd b/apache-maven/src/assembly/maven/bin/mvn.cmd
index 74d4a5a984d2..427400313ccb 100644
--- a/apache-maven/src/assembly/maven/bin/mvn.cmd
+++ b/apache-maven/src/assembly/maven/bin/mvn.cmd
@@ -275,7 +275,10 @@ if "%~1"=="--debug" (
set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenShellCling"
) else if "%~1"=="--up" (
set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenUpCling"
+) else if "%~1"=="--log" (
+ set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenLogCling"
)
+
exit /b 0
:processArgs
diff --git a/apache-maven/src/assembly/maven/bin/mvnlog b/apache-maven/src/assembly/maven/bin/mvnlog
new file mode 100644
index 000000000000..8c7804c7098c
--- /dev/null
+++ b/apache-maven/src/assembly/maven/bin/mvnlog
@@ -0,0 +1,30 @@
+#!/bin/sh
+
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# -----------------------------------------------------------------------------
+# Apache Maven Log Script
+#
+# Environment Variable Prerequisites
+#
+# JAVA_HOME (Optional) Points to a Java installation.
+# MAVEN_OPTS (Optional) Java runtime options used when Maven is executed.
+# MAVEN_SKIP_RC (Optional) Flag to disable loading of mavenrc files.
+# -----------------------------------------------------------------------------
+
+"`dirname "$0"`/mvn" --log "$@"
diff --git a/apache-maven/src/assembly/maven/bin/mvnlog.cmd b/apache-maven/src/assembly/maven/bin/mvnlog.cmd
new file mode 100644
index 000000000000..fc10ed3398e6
--- /dev/null
+++ b/apache-maven/src/assembly/maven/bin/mvnlog.cmd
@@ -0,0 +1,37 @@
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+
+@REM -----------------------------------------------------------------------------
+@REM Apache Maven Log Script
+@REM
+@REM Environment Variable Prerequisites
+@REM
+@REM JAVA_HOME (Optional) Points to a Java installation.
+@REM MAVEN_OPTS (Optional) Java runtime options used when Maven is executed.
+@REM MAVEN_SKIP_RC (Optional) Flag to disable loading of mavenrc files.
+@REM -----------------------------------------------------------------------------
+
+@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
+@echo off
+@REM set title of command window
+title %0
+@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on'
+@if "%MAVEN_BATCH_ECHO%"=="on" echo %MAVEN_BATCH_ECHO%
+
+@setlocal
+
+@call "%~dp0"mvn.cmd --log %*
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java
index ee25ec63dab3..b08049250bce 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java
+++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java
@@ -253,6 +253,31 @@ static Builder mvnup(@Nonnull List args, @Nonnull MessageBuilderFactory
return builder(Tools.MVNUP_CMD, Tools.MVNUP_NAME, args, messageBuilderFactory);
}
+ /**
+ * Creates a new Builder instance for constructing a Maven Log Viewer Tool ParserRequest.
+ *
+ * @param args the command-line arguments
+ * @param messageBuilderFactory the factory for creating message builders
+ * @return a new Builder instance
+ */
+ @Nonnull
+ static Builder mvnlog(@Nonnull String[] args, @Nonnull MessageBuilderFactory messageBuilderFactory) {
+ return mvnlog(Arrays.asList(args), messageBuilderFactory);
+ }
+
+ /**
+ * Creates a new Builder instance for constructing a Maven Log Viewer Tool ParserRequest.
+ *
+ * @param args the command-line arguments
+ * @param messageBuilderFactory the factory for creating message builders
+ * @return a new Builder instance
+ */
+ @Nonnull
+ static Builder mvnlog(@Nonnull List args, @Nonnull MessageBuilderFactory messageBuilderFactory) {
+ return builder(Tools.MVNLOG_CMD, Tools.MVNLOG_NAME, args, messageBuilderFactory);
+ }
+
+
/**
* Creates a new Builder instance for constructing a ParserRequest.
*
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
index 7559d7ffee06..51dfad0b0bf4 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
+++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
@@ -42,4 +42,8 @@ private Tools() {}
public static final String MVNUP_CMD = "mvnup";
public static final String MVNUP_NAME = "Maven Upgrade Tool";
+
+ public static final String MVNLOG_CMD = "mvnlog";
+ public static final String MVNLOG_NAME = "Maven Log Viewer Tool";
}
+
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/LogOptions.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/LogOptions.java
new file mode 100644
index 000000000000..0a86b857fe91
--- /dev/null
+++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/LogOptions.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.api.cli.mvnlog;
+
+import java.util.Optional;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.cli.Options;
+
+/**
+ * Defines the options specific to the Maven build log viewer tool.
+ *
+ * @since 4.0.0
+ */
+@Experimental
+public interface LogOptions extends Options {
+ /**
+ * Should start the local HTTP server for interactive build report viewing.
+ *
+ * @return an {@link Optional} containing the boolean value {@code true} if specified, or empty
+ */
+ @Nonnull
+ Optional web();
+
+ /**
+ * Returns the port on which to start the local HTTP server.
+ *
+ * @return an {@link Optional} containing the port value, or empty if not specified
+ */
+ @Nonnull
+ Optional port();
+
+ /**
+ * Returns the specific build report file to view.
+ *
+ * @return an {@link Optional} containing the file path, or empty if not specified
+ */
+ @Nonnull
+ Optional file();
+}
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/package-info.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/package-info.java
new file mode 100644
index 000000000000..3fd00c3e4e86
--- /dev/null
+++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/package-info.java
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Provides the API for the Maven Log Viewer tool ({@code mvnlog}).
+ *
+ * @see org.apache.maven.api.cli.Tools#MVNLOG_CMD
+ * @see org.apache.maven.api.cli.Tools#MVNLOG_NAME
+ * @since 4.0.0
+ */
+package org.apache.maven.api.cli.mvnlog;
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/MavenLogCling.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/MavenLogCling.java
new file mode 100644
index 000000000000..f0de78af9a93
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/MavenLogCling.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.cling;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.cli.Invoker;
+import org.apache.maven.api.cli.Parser;
+import org.apache.maven.api.cli.ParserRequest;
+import org.apache.maven.cling.invoker.ProtoLookup;
+import org.apache.maven.cling.invoker.mvnlog.LogInvoker;
+import org.apache.maven.cling.invoker.mvnlog.LogParser;
+import org.codehaus.plexus.classworlds.ClassWorld;
+
+/**
+ * Maven build report / log viewer CLI.
+ */
+public class MavenLogCling extends ClingSupport {
+ /**
+ * Java entry point.
+ */
+ public static void main(String[] args) throws IOException {
+ int exitCode = new MavenLogCling().run(args, null, null, null, false);
+ System.exit(exitCode);
+ }
+
+ /**
+ * ClassWorld Launcher "enhanced" entry point.
+ */
+ public static int main(String[] args, ClassWorld world) throws IOException {
+ return new MavenLogCling(world).run(args, null, null, null, false);
+ }
+
+ /**
+ * ClassWorld Launcher "embedded" entry point.
+ */
+ public static int main(
+ String[] args,
+ ClassWorld world,
+ @Nullable InputStream stdIn,
+ @Nullable OutputStream stdOut,
+ @Nullable OutputStream stdErr)
+ throws IOException {
+ return new MavenLogCling(world).run(args, stdIn, stdOut, stdErr, true);
+ }
+
+ public MavenLogCling() {
+ super();
+ }
+
+ public MavenLogCling(ClassWorld classWorld) {
+ super(classWorld);
+ }
+
+ @Override
+ protected Invoker createInvoker() {
+ return new LogInvoker(
+ ProtoLookup.builder().addMapping(ClassWorld.class, classWorld).build(), null);
+ }
+
+ @Override
+ protected Parser createParser() {
+ return new LogParser();
+ }
+
+ @Override
+ protected ParserRequest.Builder createParserRequestBuilder(String[] args) {
+ return ParserRequest.mvnlog(args, createMessageBuilderFactory());
+ }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java
index 40d3bb4926a4..9de50d2d7d46 100644
--- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java
@@ -310,9 +310,14 @@ protected BuildEventListener determineBuildEventListener(C context) {
protected BuildEventListener doDetermineBuildEventListener(C context) {
Consumer writer = determineWriter(context);
- return new SimpleBuildEventListener(writer);
+ BuildEventListener delegate = new SimpleBuildEventListener(writer);
+ if (context.invokerRequest.command().equals("mvn")) {
+ return new org.apache.maven.logging.BuildReportEventListener(delegate);
+ }
+ return delegate;
}
+
protected final void createTerminal(C context) {
if (context.terminal == null) {
MessageUtils.systemInstall(builder -> doCreateTerminal(context, builder), terminal -> {
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/CommonsCliLogOptions.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/CommonsCliLogOptions.java
new file mode 100644
index 000000000000..983bc5331581
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/CommonsCliLogOptions.java
@@ -0,0 +1,120 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.cling.invoker.mvnlog;
+
+import java.util.Optional;
+import java.util.function.Consumer;
+
+import org.apache.commons.cli.CommandLine;
+import org.apache.commons.cli.Option;
+import org.apache.commons.cli.ParseException;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.cli.Options;
+import org.apache.maven.api.cli.ParserRequest;
+import org.apache.maven.api.cli.mvnlog.LogOptions;
+import org.apache.maven.cling.invoker.CommonsCliOptions;
+
+public class CommonsCliLogOptions extends CommonsCliOptions implements LogOptions {
+
+ public static CommonsCliLogOptions parse(String[] args) throws ParseException {
+ CLIManager cliManager = new CLIManager();
+ return new CommonsCliLogOptions(Options.SOURCE_CLI, cliManager, cliManager.parse(args));
+ }
+
+ protected CommonsCliLogOptions(String source, CLIManager cliManager, CommandLine commandLine) {
+ super(source, cliManager, commandLine);
+ }
+
+ @Override
+ @Nonnull
+ public Optional web() {
+ if (commandLine.hasOption(CLIManager.WEB)) {
+ return Optional.of(Boolean.TRUE);
+ }
+ return Optional.empty();
+ }
+
+ @Override
+ @Nonnull
+ public Optional port() {
+ if (commandLine.hasOption(CLIManager.PORT)) {
+ try {
+ return Optional.of(Integer.parseInt(commandLine.getOptionValue(CLIManager.PORT)));
+ } catch (NumberFormatException e) {
+ // Ignore, will default
+ }
+ }
+ return Optional.empty();
+ }
+
+ @Override
+ @Nonnull
+ public Optional file() {
+ if (commandLine.hasOption(CLIManager.FILE)) {
+ return Optional.of(commandLine.getOptionValue(CLIManager.FILE));
+ }
+ // Allow fallback to unflagged first argument
+ if (!commandLine.getArgList().isEmpty()) {
+ return Optional.of(commandLine.getArgList().get(0));
+ }
+ return Optional.empty();
+ }
+
+ @Override
+ public void displayHelp(ParserRequest request, Consumer printStream) {
+ super.displayHelp(request, printStream);
+ printStream.accept("");
+ printStream.accept("Options:");
+ printStream.accept(" -w, --web Start local HTTP server to view build reports interactively");
+ printStream.accept(" -p, --port Set custom HTTP server port (only valid with --web)");
+ printStream.accept(" -f, --file Path to specific build-report.json file to view");
+ printStream.accept(" -h, --help Display this help message");
+ printStream.accept("");
+ }
+
+ @Override
+ protected CommonsCliLogOptions copy(String source, CommonsCliOptions.CLIManager cliManager, CommandLine commandLine) {
+ return new CommonsCliLogOptions(source, (CLIManager) cliManager, commandLine);
+ }
+
+ protected static class CLIManager extends CommonsCliOptions.CLIManager {
+ public static final String WEB = "w";
+ public static final String PORT = "p";
+ public static final String FILE = "f";
+
+ @Override
+ protected void prepareOptions(org.apache.commons.cli.Options options) {
+ super.prepareOptions(options);
+ options.addOption(Option.builder(WEB)
+ .longOpt("web")
+ .desc("Start local HTTP server for interactive build report viewer")
+ .build());
+ options.addOption(Option.builder(PORT)
+ .longOpt("port")
+ .hasArg()
+ .desc("Custom port for the local HTTP server")
+ .build());
+ options.addOption(Option.builder(FILE)
+ .longOpt("file")
+ .hasArg()
+ .desc("Specific build-report.json file path")
+ .build());
+ }
+ }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogContext.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogContext.java
new file mode 100644
index 000000000000..1bbdaf1e16a8
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogContext.java
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.cling.invoker.mvnlog;
+
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.cli.InvokerRequest;
+import org.apache.maven.api.cli.mvnlog.LogOptions;
+import org.apache.maven.cling.invoker.LookupContext;
+
+public class LogContext extends LookupContext {
+ public LogContext(InvokerRequest invokerRequest, LogOptions logOptions) {
+ super(invokerRequest, true, logOptions);
+ }
+
+ @Nonnull
+ public LogOptions options() {
+ return (LogOptions) super.options();
+ }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogInvoker.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogInvoker.java
new file mode 100644
index 000000000000..fbe64842e922
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogInvoker.java
@@ -0,0 +1,422 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.cling.invoker.mvnlog;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpHandler;
+import com.sun.net.httpserver.HttpServer;
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.cli.InvokerRequest;
+import org.apache.maven.api.cli.mvnlog.LogOptions;
+import org.apache.maven.api.services.Lookup;
+import org.apache.maven.cling.invoker.LookupContext;
+import org.apache.maven.cling.invoker.LookupInvoker;
+
+public class LogInvoker extends LookupInvoker {
+
+ private static volatile long lastRequestTime = System.currentTimeMillis();
+
+ public LogInvoker(Lookup protoLookup, @Nullable Consumer contextConsumer) {
+ super(protoLookup, contextConsumer);
+ }
+
+ @Override
+ protected LogContext createContext(InvokerRequest invokerRequest) {
+ return new LogContext(invokerRequest, (LogOptions) invokerRequest.options().orElse(null));
+ }
+
+ @Override
+ protected int execute(LogContext context) throws Exception {
+ Path reportFile = findReportFile(context);
+ if (reportFile == null || !Files.exists(reportFile)) {
+ context.logger.error("No build reports found in .mvn/reports/ or ~/.mvn/reports/");
+ return 1;
+ }
+
+ if (context.options().web().orElse(false)) {
+ return startWebServer(context, reportFile);
+ } else {
+ return renderTerminalOutput(context, reportFile);
+ }
+ }
+
+ private Path findReportFile(LogContext context) {
+ if (context.options().file().isPresent()) {
+ return Paths.get(context.options().file().get());
+ }
+
+ // Check project .mvn/reports
+ Path reportsDir = Paths.get(".mvn", "reports");
+ Path report = findLatestReport(reportsDir);
+ if (report != null) {
+ return report;
+ }
+
+ // Check user home .mvn/reports
+ Path userReportsDir = Paths.get(System.getProperty("user.home"), ".mvn", "reports");
+ report = findLatestReport(userReportsDir);
+ if (report != null) {
+ return report;
+ }
+
+ return null;
+ }
+
+ private Path findLatestReport(Path dir) {
+ if (!Files.isDirectory(dir)) {
+ return null;
+ }
+ try (java.util.stream.Stream stream = Files.list(dir)) {
+ return stream
+ .filter(p -> p.getFileName().toString().startsWith("build-report-") && p.getFileName().toString().endsWith(".json"))
+ .max(java.util.Comparator.comparing(Path::toString))
+ .orElse(null);
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ private int renderTerminalOutput(LogContext context, Path reportFile) {
+ try {
+ String content = Files.readString(reportFile);
+ String status = parseJsonStringField(content, "status");
+ long startTime = parseJsonLongField(content, "startTime");
+ long durationMs = parseJsonLongField(content, "durationMs");
+ String mavenVersion = parseJsonStringField(content, "mavenVersion");
+ String goals = parseJsonArrayField(content, "goals");
+
+ context.logger.info("========================================================================");
+ if ("SUCCESS".equals(status)) {
+ context.logger.info("BUILD SUCCESS REPORT: " + reportFile.getFileName());
+ } else {
+ context.logger.error("BUILD FAILURE REPORT: " + reportFile.getFileName());
+ }
+ context.logger.info("========================================================================");
+ context.logger.info("Maven Version: " + mavenVersion);
+ context.logger.info("Start Time: " + new Date(startTime));
+ context.logger.info("Duration: " + formatDuration(durationMs));
+ context.logger.info("Goals: " + goals);
+ context.logger.info("------------------------------------------------------------------------");
+
+ // Simple parsing of modules
+ java.util.regex.Matcher m = java.util.regex.Pattern.compile(
+ "\"id\"\\s*:\\s*\"([^\"]+)\",\\s*\"status\"\\s*:\\s*\"([^\"]+)\",\\s*\"startTime\"\\s*:\\s*(\\d+),\\s*\"endTime\"\\s*:\\s*(\\d+),\\s*\"durationMs\"\\s*:\\s*(\\d+)"
+ ).matcher(content);
+
+ context.logger.info("Modules built:");
+ while (m.find()) {
+ String name = m.group(1);
+ String modStatus = m.group(2);
+ long dur = Long.parseLong(m.group(5));
+ String statusStr = "SUCCESS".equals(modStatus) ? "[SUCCESS]" : "[FAILED]";
+ context.logger.info(String.format(" %-40s %-10s (%s)", name, statusStr, formatDuration(dur)));
+ }
+ context.logger.info("========================================================================");
+
+ // Simple count of warnings / errors
+ int warnings = 0;
+ int errors = 0;
+ java.util.regex.Matcher pm = java.util.regex.Pattern.compile("\"severity\"\\s*:\\s*\"([^\"]+)\"").matcher(content);
+ while (pm.find()) {
+ if ("WARN".equals(pm.group(1))) warnings++;
+ if ("ERROR".equals(pm.group(1))) errors++;
+ }
+ if (warnings > 0 || errors > 0) {
+ context.logger.info(String.format("Build Problems: %d warnings, %d errors. Run with --web to explore.", warnings, errors));
+ context.logger.info("========================================================================");
+ }
+
+ return 0;
+ } catch (Exception e) {
+ context.logger.error("Failed to parse report file: " + e.getMessage());
+ return 1;
+ }
+ }
+
+ private int startWebServer(LogContext context, Path reportFile) {
+ int preferredPort = context.options().port().orElse(8000);
+ int actualPort = preferredPort;
+
+ HttpServer server = null;
+ // Port selection logic: try preferred port, fall back to random available port if busy
+ try {
+ server = HttpServer.create(new InetSocketAddress(preferredPort), 0);
+ } catch (IOException e) {
+ // Find random available port
+ try (ServerSocket socket = new ServerSocket(0)) {
+ actualPort = socket.getLocalPort();
+ server = HttpServer.create(new InetSocketAddress(actualPort), 0);
+ } catch (IOException ioException) {
+ context.logger.error("Failed to start web server on port " + preferredPort + " and failed to allocate dynamic port: " + ioException.getMessage());
+ return 1;
+ }
+ }
+
+ context.logger.info("Starting local HTTP server serving " + reportFile.getFileName());
+ context.logger.info("Open browser: http://localhost:" + actualPort);
+
+ // Serve Frontend
+ server.createContext("/", new HttpHandler() {
+ @Override
+ public void handle(HttpExchange exchange) throws IOException {
+ lastRequestTime = System.currentTimeMillis();
+ if (!"/".equals(exchange.getRequestURI().getPath())) {
+ exchange.sendResponseHeaders(404, -1);
+ return;
+ }
+ try (InputStream is = getClass().getResourceAsStream("/org/apache/maven/cling/invoker/mvnlog/report.html")) {
+ if (is == null) {
+ byte[] err = "report.html not found in classpath".getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().set("Content-Type", "text/plain");
+ exchange.sendResponseHeaders(404, err.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(err);
+ }
+ return;
+ }
+ byte[] bytes = is.readAllBytes();
+ exchange.getResponseHeaders().set("Content-Type", "text/html; charset=UTF-8");
+ exchange.sendResponseHeaders(200, bytes.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(bytes);
+ }
+ }
+ }
+ });
+
+ // Serve raw build-report.json content
+ server.createContext("/api/report", new HttpHandler() {
+ @Override
+ public void handle(HttpExchange exchange) throws IOException {
+ lastRequestTime = System.currentTimeMillis();
+ Path fileToServe = reportFile;
+ String query = exchange.getRequestURI().getQuery();
+ if (query != null && query.contains("id=")) {
+ String reqId = query.substring(query.indexOf("id=") + 3);
+ if (reqId.contains("&")) {
+ reqId = reqId.substring(0, reqId.indexOf("&"));
+ }
+ Path p1 = Paths.get(".mvn", "reports", reqId);
+ Path p2 = Paths.get(System.getProperty("user.home"), ".mvn", "reports", reqId);
+ if (Files.exists(p1)) {
+ fileToServe = p1;
+ } else if (Files.exists(p2)) {
+ fileToServe = p2;
+ }
+ }
+
+ if (!Files.exists(fileToServe)) {
+ exchange.sendResponseHeaders(404, -1);
+ return;
+ }
+
+ byte[] bytes = Files.readAllBytes(fileToServe);
+ exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8");
+ exchange.sendResponseHeaders(200, bytes.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(bytes);
+ }
+ }
+ });
+
+ // Serve lists of available reports
+ server.createContext("/api/reports", new HttpHandler() {
+ @Override
+ public void handle(HttpExchange exchange) throws IOException {
+ lastRequestTime = System.currentTimeMillis();
+ byte[] bytes = listReportsJson().getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8");
+ exchange.sendResponseHeaders(200, bytes.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(bytes);
+ }
+ }
+ });
+
+ server.setExecutor(Executors.newFixedThreadPool(4));
+ server.start();
+
+ // Auto-open browser
+ try {
+ if (java.awt.Desktop.isDesktopSupported() && java.awt.Desktop.getDesktop().isSupported(java.awt.Desktop.Action.BROWSE)) {
+ java.awt.Desktop.getDesktop().browse(URI.create("http://localhost:" + actualPort));
+ }
+ } catch (Exception e) {
+ // Ignore if headless/desktop not supported
+ }
+
+ // Self-terminating after 30 minutes of inactivity
+ ScheduledExecutorService shutdownExecutor = Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread t = new Thread(r, "mvnlog-shutdown-monitor");
+ t.setDaemon(true);
+ return t;
+ });
+
+ HttpServer finalServer = server;
+ shutdownExecutor.scheduleAtFixedRate(() -> {
+ if (System.currentTimeMillis() - lastRequestTime > 30 * 60 * 1000) {
+ context.logger.info("Terminating server due to 30 minutes of inactivity.");
+ finalServer.stop(0);
+ shutdownExecutor.shutdown();
+ System.exit(0);
+ }
+ }, 1, 1, TimeUnit.MINUTES);
+
+ // Keep server thread running
+ try {
+ Object lock = new Object();
+ synchronized (lock) {
+ lock.wait();
+ }
+ } catch (InterruptedException e) {
+ server.stop(0);
+ shutdownExecutor.shutdown();
+ }
+
+ return 0;
+ }
+
+ private String listReportsJson() {
+ List