diff --git a/src/main/java/io/github/randomcodespace/iq/CodeIqApplication.java b/src/main/java/io/github/randomcodespace/iq/CodeIqApplication.java index a8047172..d2d304ed 100644 --- a/src/main/java/io/github/randomcodespace/iq/CodeIqApplication.java +++ b/src/main/java/io/github/randomcodespace/iq/CodeIqApplication.java @@ -67,6 +67,15 @@ public static void main(String[] args) { System.setProperty("server.port", portStr); } + // Disable web UI if --no-ui flag is present + boolean noUi = Arrays.asList(args).contains("--no-ui"); + if (noUi) { + System.setProperty("codeiq.ui.enabled", "false"); + // Also disable Spring Boot's static resource handler so no + // static files (index.html, JS, CSS bundles) are served. + System.setProperty("spring.web.resources.add-mappings", "false"); + } + // Resolve codebase root so Neo4j points to the correct graph.db String codebasePath = extractPositionalArg(args, "serve"); java.nio.file.Path root = java.nio.file.Path.of( @@ -125,9 +134,18 @@ private static String extractFlag(String[] args, String flagName) { return null; } + /** + * Boolean (no-value) flags for the serve command. + * These must NOT consume the next token as their value. + */ + private static final java.util.Set BOOLEAN_FLAGS = java.util.Set.of( + "--no-ui", "--help", "-h", "--version" + ); + /** * Extract the first positional argument after the command name. * Skips flags (--name value pairs) to find positional args. + * Boolean flags (no value) are not allowed to consume the next token. */ private static String extractPositionalArg(String[] args, String command) { boolean foundCommand = false; @@ -142,17 +160,17 @@ private static String extractPositionalArg(String[] args, String command) { continue; } if (foundCommand) { - // Skip --flag value pairs - if (arg.startsWith("--") && !arg.contains("=")) { + // Skip --flag value pairs, but not boolean flags that take no value + if (arg.startsWith("--") && !arg.contains("=") && !BOOLEAN_FLAGS.contains(arg)) { skipNext = true; continue; } - if (arg.startsWith("-") && arg.length() == 2) { + if (arg.startsWith("-") && arg.length() == 2 && !BOOLEAN_FLAGS.contains(arg)) { skipNext = true; // short flag like -p 8080 continue; } if (arg.startsWith("-")) { - continue; // --flag=value or -flag + continue; // --flag=value, boolean flag, or unknown short flag } return arg; } diff --git a/src/main/java/io/github/randomcodespace/iq/cli/ServeCommand.java b/src/main/java/io/github/randomcodespace/iq/cli/ServeCommand.java index 1e783dfd..0963148b 100644 --- a/src/main/java/io/github/randomcodespace/iq/cli/ServeCommand.java +++ b/src/main/java/io/github/randomcodespace/iq/cli/ServeCommand.java @@ -42,6 +42,10 @@ public class ServeCommand implements Callable { @Option(names = {"--graph"}, description = "Path to shared graph directory (overrides default)") private Path graphPath; + @Option(names = {"--no-ui"}, defaultValue = "false", + description = "Disable the web UI (React SPA). API and MCP endpoints remain active.") + private boolean noUi; + @Autowired private CodeIqConfig config; @@ -73,7 +77,11 @@ public Integer call() { CliOutput.step("\uD83D\uDE80", "@|bold,green Server started|@"); System.out.println(); - CliOutput.info(" URL: http://" + host + ":" + port); + if (noUi) { + CliOutput.info(" Web UI: disabled (API and MCP active at :" + port + ")"); + } else { + CliOutput.info(" Web UI: http://" + host + ":" + port + " (React SPA)"); + } CliOutput.info(" REST API: http://" + host + ":" + port + "/api"); CliOutput.info(" MCP: http://" + host + ":" + port + "/mcp"); CliOutput.info(" Health: http://" + host + ":" + port + "/actuator/health"); @@ -94,4 +102,5 @@ public Integer call() { public int getPort() { return port; } public String getHost() { return host; } public Path getGraphPath() { return graphPath; } + public boolean isNoUi() { return noUi; } } diff --git a/src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java b/src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java index 9505b815..3dc57eee 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java +++ b/src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java @@ -28,6 +28,9 @@ public class CodeIqConfig { /** Graph configuration sub-properties. */ private Graph graph = new Graph(); + /** Whether to serve the React web UI. Set to false via --no-ui flag. */ + private boolean uiEnabled = true; + public static class Graph { private String path = ".osscodeiq/graph.db"; @@ -95,4 +98,12 @@ public Graph getGraph() { public void setGraph(Graph graph) { this.graph = graph; } + + public boolean isUiEnabled() { + return uiEnabled; + } + + public void setUiEnabled(boolean uiEnabled) { + this.uiEnabled = uiEnabled; + } } diff --git a/src/main/java/io/github/randomcodespace/iq/web/SpaController.java b/src/main/java/io/github/randomcodespace/iq/web/SpaController.java index 60ab167b..cd198449 100644 --- a/src/main/java/io/github/randomcodespace/iq/web/SpaController.java +++ b/src/main/java/io/github/randomcodespace/iq/web/SpaController.java @@ -1,5 +1,6 @@ package io.github.randomcodespace.iq.web; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @@ -8,20 +9,21 @@ * Catch-all controller that forwards unmatched routes to index.html * for React Router client-side routing (HTML5 pushState). *

- * Only matches paths without a file extension (e.g. /topology, /explorer/class) + * Only matches paths without a file extension (e.g. /graph, /explorer/class) * so static assets (.js, .css, .html, .svg) are served normally. + *

+ * Disabled when {@code codeiq.ui.enabled=false} (i.e. {@code --no-ui} flag passed to serve). */ @Controller @Profile("serving") +@ConditionalOnProperty(name = "codeiq.ui.enabled", havingValue = "true", matchIfMissing = true) public class SpaController { @GetMapping(value = { - "/topology", - "/topology/**", + "/graph", + "/graph/**", "/explorer", "/explorer/**", - "/flow", - "/flow/**", "/console", "/console/**", "/api-docs", diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f227ae8d..f3d38490 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -30,6 +30,8 @@ codeiq: max-depth: 10 max-radius: 10 batch-size: 500 + ui: + enabled: true spring.ai.mcp.server: name: code-iq diff --git a/src/test/java/io/github/randomcodespace/iq/CodeIqApplicationArgParsingTest.java b/src/test/java/io/github/randomcodespace/iq/CodeIqApplicationArgParsingTest.java new file mode 100644 index 00000000..bc404706 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/CodeIqApplicationArgParsingTest.java @@ -0,0 +1,52 @@ +package io.github.randomcodespace.iq; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Unit tests for CodeIqApplication argument parsing helper methods. + * These are called in main() before the Spring context starts. + */ +class CodeIqApplicationArgParsingTest { + + private static String extractPositionalArg(String[] args, String command) throws Exception { + Method m = CodeIqApplication.class.getDeclaredMethod("extractPositionalArg", String[].class, String.class); + m.setAccessible(true); + return (String) m.invoke(null, args, command); + } + + @Test + void extractsPathAfterCommand() throws Exception { + String result = extractPositionalArg(new String[]{"serve", "/my/repo"}, "serve"); + assertEquals("/my/repo", result); + } + + @Test + void pathNotSwallowedByBooleanNoUiFlag() throws Exception { + // Regression: --no-ui is boolean; must not consume /repo as its value. + String result = extractPositionalArg(new String[]{"serve", "--no-ui", "/my/repo"}, "serve"); + assertEquals("/my/repo", result); + } + + @Test + void pathStillExtractedWhenPortFlagPrecedes() throws Exception { + String result = extractPositionalArg(new String[]{"serve", "--port", "9090", "/my/repo"}, "serve"); + assertEquals("/my/repo", result); + } + + @Test + void pathStillExtractedWithNoUiAndPort() throws Exception { + String result = extractPositionalArg(new String[]{"serve", "--no-ui", "--port", "9090", "/my/repo"}, "serve"); + assertEquals("/my/repo", result); + } + + @Test + void returnsNullWhenNoPath() throws Exception { + String result = extractPositionalArg(new String[]{"serve", "--no-ui"}, "serve"); + assertNull(result); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/cli/ServeCommandTest.java b/src/test/java/io/github/randomcodespace/iq/cli/ServeCommandTest.java index fc730f7f..0c85f3c9 100644 --- a/src/test/java/io/github/randomcodespace/iq/cli/ServeCommandTest.java +++ b/src/test/java/io/github/randomcodespace/iq/cli/ServeCommandTest.java @@ -48,4 +48,30 @@ void customPortIsParsed() { cmdLine.parseArgs("--port", "9090"); assertEquals(9090, cmd.getPort()); } + + @Test + void noUiDefaultsToFalse() { + var cmd = new ServeCommand(); + var cmdLine = new CommandLine(cmd); + cmdLine.parseArgs(); + assertEquals(false, cmd.isNoUi()); + } + + @Test + void noUiFlagIsRecognized() { + var cmd = new ServeCommand(); + var cmdLine = new CommandLine(cmd); + cmdLine.parseArgs("--no-ui"); + assertEquals(true, cmd.isNoUi()); + } + + @Test + void pathNotSwallowedWhenNoUiPrecedesPath() { + // Regression: --no-ui is boolean and must not consume the next positional arg. + var cmd = new ServeCommand(); + var cmdLine = new CommandLine(cmd); + cmdLine.parseArgs("--no-ui", "/some/repo"); + assertEquals(true, cmd.isNoUi()); + assertEquals("/some/repo", cmd.getPath().toString()); + } } diff --git a/src/test/java/io/github/randomcodespace/iq/web/SpaControllerConditionalTest.java b/src/test/java/io/github/randomcodespace/iq/web/SpaControllerConditionalTest.java new file mode 100644 index 00000000..5a5b20cd --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/web/SpaControllerConditionalTest.java @@ -0,0 +1,84 @@ +package io.github.randomcodespace.iq.web; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.web.bind.annotation.GetMapping; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that SpaController is conditionally registered based on codeiq.ui.enabled, + * and that static resource serving is also disabled via spring.web.resources.add-mappings=false. + */ +class SpaControllerConditionalTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(SpaController.class) + .withSystemProperties("spring.profiles.active=serving"); + + @Test + void spaControllerHasConditionalOnPropertyAnnotation() { + var annotation = SpaController.class.getAnnotation(ConditionalOnProperty.class); + assertThat(annotation).isNotNull(); + assertThat(annotation.name()).contains("codeiq.ui.enabled"); + assertThat(annotation.havingValue()).isEqualTo("true"); + assertThat(annotation.matchIfMissing()).isTrue(); + } + + @Test + void spaControllerRegisteredWhenUiEnabledTrue() { + contextRunner + .withPropertyValues("codeiq.ui.enabled=true") + .run(context -> assertThat(context).hasSingleBean(SpaController.class)); + } + + @Test + void spaControllerRegisteredWhenPropertyAbsent() { + contextRunner + .run(context -> assertThat(context).hasSingleBean(SpaController.class)); + } + + @Test + void spaControllerNotRegisteredWhenUiEnabledFalse() { + contextRunner + .withPropertyValues("codeiq.ui.enabled=false") + .run(context -> assertThat(context).doesNotHaveBean(SpaController.class)); + } + + @Test + void spaControllerHasProfileAnnotation() { + var annotation = SpaController.class.getAnnotation(Profile.class); + assertThat(annotation).isNotNull(); + assertThat(annotation.value()).contains("serving"); + } + + @Test + void staticResourcesDisabledWhenUiDisabled() { + // When --no-ui is active, CodeIqApplication sets both properties. + // Verify that spring.web.resources.add-mappings=false combined with + // codeiq.ui.enabled=false leaves no SpaController in the context. + contextRunner + .withPropertyValues("codeiq.ui.enabled=false", "spring.web.resources.add-mappings=false") + .run(context -> assertThat(context).doesNotHaveBean(SpaController.class)); + } + + @Test + void spaControllerExplicitRoutesContainGraph() { + // Verify that /graph routes are present and /topology, /flow routes are removed. + Method forwardMethod = Arrays.stream(SpaController.class.getDeclaredMethods()) + .filter(m -> m.isAnnotationPresent(GetMapping.class)) + .filter(m -> m.getName().equals("forward")) + .findFirst() + .orElse(null); + assertThat(forwardMethod).isNotNull(); + List routes = Arrays.asList(forwardMethod.getAnnotation(GetMapping.class).value()); + assertThat(routes).contains("/graph", "/graph/**"); + assertThat(routes).doesNotContain("/topology", "/topology/**", "/flow", "/flow/**"); + } +}