From 79b00164e7280f51ad2e10fe7b82ab0f2ee4da9c Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Wed, 1 Apr 2026 13:36:59 +0000 Subject: [PATCH 1/3] feat: add --no-ui flag to serve command to disable web UI at launch When --no-ui is passed to `code-iq serve`, the React SPA is excluded and only the REST API and MCP server remain active. Useful for headless or CI environments. - CodeIqApplication.main() detects --no-ui before Spring context init and sets codeiq.ui.enabled=false as a system property - SpaController gains @ConditionalOnProperty so it is only registered when codeiq.ui.enabled=true (default: true / matchIfMissing=true) - CodeIqConfig gains uiEnabled field bound to codeiq.ui.enabled - application.yml sets codeiq.ui.enabled: true as default - ServeCommand: added --no-ui option; startup log now states whether Web UI is enabled or disabled - Tests: 2 new --no-ui flag tests in ServeCommandTest, 5 new SpaControllerConditionalTest tests (1447 total, 0 failures) Co-Authored-By: Paperclip --- .../randomcodespace/iq/CodeIqApplication.java | 6 +++ .../randomcodespace/iq/cli/ServeCommand.java | 11 +++- .../iq/config/CodeIqConfig.java | 11 ++++ .../randomcodespace/iq/web/SpaController.java | 4 ++ src/main/resources/application.yml | 2 + .../iq/cli/ServeCommandTest.java | 16 ++++++ .../iq/web/SpaControllerConditionalTest.java | 54 +++++++++++++++++++ 7 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 src/test/java/io/github/randomcodespace/iq/web/SpaControllerConditionalTest.java diff --git a/src/main/java/io/github/randomcodespace/iq/CodeIqApplication.java b/src/main/java/io/github/randomcodespace/iq/CodeIqApplication.java index a8047172..d4965c5e 100644 --- a/src/main/java/io/github/randomcodespace/iq/CodeIqApplication.java +++ b/src/main/java/io/github/randomcodespace/iq/CodeIqApplication.java @@ -67,6 +67,12 @@ 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"); + } + // 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( 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..7db0ea10 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; @@ -10,9 +11,12 @@ *

* Only matches paths without a file extension (e.g. /topology, /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 = { 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/cli/ServeCommandTest.java b/src/test/java/io/github/randomcodespace/iq/cli/ServeCommandTest.java index fc730f7f..09f751c3 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,20 @@ 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()); + } } 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..698de09c --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/web/SpaControllerConditionalTest.java @@ -0,0 +1,54 @@ +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 static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that SpaController is conditionally registered based on codeiq.ui.enabled. + */ +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"); + } +} From 0dab250d2c17884826917fb5ccabbbcb68a94572 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Wed, 1 Apr 2026 14:07:55 +0000 Subject: [PATCH 2/3] fix: disable static resources on --no-ui and clean up stale SpaController routes - CodeIqApplication: set spring.web.resources.add-mappings=false when --no-ui is active, preventing static file serving (index.html, JS, CSS bundles) - SpaController: replace stale /topology and /flow routes with /graph (matches the current Code Graph treemap tab added in 482ca24) - SpaControllerConditionalTest: add staticResourcesDisabledWhenUiDisabled test and spaControllerExplicitRoutesContainGraph test (1449 total, 0 failures) Co-Authored-By: Paperclip --- .../randomcodespace/iq/CodeIqApplication.java | 3 ++ .../randomcodespace/iq/web/SpaController.java | 8 ++--- .../iq/web/SpaControllerConditionalTest.java | 32 ++++++++++++++++++- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/main/java/io/github/randomcodespace/iq/CodeIqApplication.java b/src/main/java/io/github/randomcodespace/iq/CodeIqApplication.java index d4965c5e..fce05192 100644 --- a/src/main/java/io/github/randomcodespace/iq/CodeIqApplication.java +++ b/src/main/java/io/github/randomcodespace/iq/CodeIqApplication.java @@ -71,6 +71,9 @@ public static void main(String[] args) { 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 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 7db0ea10..cd198449 100644 --- a/src/main/java/io/github/randomcodespace/iq/web/SpaController.java +++ b/src/main/java/io/github/randomcodespace/iq/web/SpaController.java @@ -9,7 +9,7 @@ * 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). @@ -20,12 +20,10 @@ public class SpaController { @GetMapping(value = { - "/topology", - "/topology/**", + "/graph", + "/graph/**", "/explorer", "/explorer/**", - "/flow", - "/flow/**", "/console", "/console/**", "/api-docs", diff --git a/src/test/java/io/github/randomcodespace/iq/web/SpaControllerConditionalTest.java b/src/test/java/io/github/randomcodespace/iq/web/SpaControllerConditionalTest.java index 698de09c..5a5b20cd 100644 --- a/src/test/java/io/github/randomcodespace/iq/web/SpaControllerConditionalTest.java +++ b/src/test/java/io/github/randomcodespace/iq/web/SpaControllerConditionalTest.java @@ -4,11 +4,17 @@ 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. + * 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 { @@ -51,4 +57,28 @@ void spaControllerHasProfileAnnotation() { 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/**"); + } } From 0c51354a2c9187f43a836d8326f2f377c62773c5 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Wed, 1 Apr 2026 14:19:46 +0000 Subject: [PATCH 3/3] fix: correct extractPositionalArg to not swallow path after boolean --no-ui flag The arg parser incorrectly set skipNext=true for ALL --flag patterns, causing code-iq serve --no-ui /repo to silently drop /repo (treating it as the value of --no-ui). Boolean flags that take no value must not consume the next token. - Added BOOLEAN_FLAGS set (--no-ui, --help, -h, --version) to CodeIqApplication - extractPositionalArg now skips skipNext for flags in BOOLEAN_FLAGS - Added pathNotSwallowedWhenNoUiPrecedesPath test to ServeCommandTest - Added CodeIqApplicationArgParsingTest with 5 reflection-based unit tests for extractPositionalArg edge cases Co-Authored-By: Paperclip --- .../randomcodespace/iq/CodeIqApplication.java | 17 ++++-- .../iq/CodeIqApplicationArgParsingTest.java | 52 +++++++++++++++++++ .../iq/cli/ServeCommandTest.java | 10 ++++ 3 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 src/test/java/io/github/randomcodespace/iq/CodeIqApplicationArgParsingTest.java diff --git a/src/main/java/io/github/randomcodespace/iq/CodeIqApplication.java b/src/main/java/io/github/randomcodespace/iq/CodeIqApplication.java index fce05192..d2d304ed 100644 --- a/src/main/java/io/github/randomcodespace/iq/CodeIqApplication.java +++ b/src/main/java/io/github/randomcodespace/iq/CodeIqApplication.java @@ -134,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; @@ -151,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/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 09f751c3..0c85f3c9 100644 --- a/src/test/java/io/github/randomcodespace/iq/cli/ServeCommandTest.java +++ b/src/test/java/io/github/randomcodespace/iq/cli/ServeCommandTest.java @@ -64,4 +64,14 @@ void noUiFlagIsRecognized() { 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()); + } }