Skip to content

Commit 11edfd8

Browse files
feat: add --no-ui flag to serve command to disable web UI
Reviewed and approved by Principal Engineer. All blocking issues resolved: - Domain boundary violation fixed (rebased to backend-only commits) - extractPositionalArg BOOLEAN_FLAGS fix — --no-ui no longer consumes positional path arg - Static resources correctly disabled via spring.web.resources.add-mappings=false - SpaController conditionally registered via @ConditionalOnProperty - Full test coverage including regression test for arg parsing Co-Authored-By: Paperclip <noreply@paperclip.ing>
1 parent 85333f1 commit 11edfd8

8 files changed

Lines changed: 214 additions & 10 deletions

File tree

src/main/java/io/github/randomcodespace/iq/CodeIqApplication.java

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,15 @@ public static void main(String[] args) {
6767
System.setProperty("server.port", portStr);
6868
}
6969

70+
// Disable web UI if --no-ui flag is present
71+
boolean noUi = Arrays.asList(args).contains("--no-ui");
72+
if (noUi) {
73+
System.setProperty("codeiq.ui.enabled", "false");
74+
// Also disable Spring Boot's static resource handler so no
75+
// static files (index.html, JS, CSS bundles) are served.
76+
System.setProperty("spring.web.resources.add-mappings", "false");
77+
}
78+
7079
// Resolve codebase root so Neo4j points to the correct graph.db
7180
String codebasePath = extractPositionalArg(args, "serve");
7281
java.nio.file.Path root = java.nio.file.Path.of(
@@ -125,9 +134,18 @@ private static String extractFlag(String[] args, String flagName) {
125134
return null;
126135
}
127136

137+
/**
138+
* Boolean (no-value) flags for the serve command.
139+
* These must NOT consume the next token as their value.
140+
*/
141+
private static final java.util.Set<String> BOOLEAN_FLAGS = java.util.Set.of(
142+
"--no-ui", "--help", "-h", "--version"
143+
);
144+
128145
/**
129146
* Extract the first positional argument after the command name.
130147
* Skips flags (--name value pairs) to find positional args.
148+
* Boolean flags (no value) are not allowed to consume the next token.
131149
*/
132150
private static String extractPositionalArg(String[] args, String command) {
133151
boolean foundCommand = false;
@@ -142,17 +160,17 @@ private static String extractPositionalArg(String[] args, String command) {
142160
continue;
143161
}
144162
if (foundCommand) {
145-
// Skip --flag value pairs
146-
if (arg.startsWith("--") && !arg.contains("=")) {
163+
// Skip --flag value pairs, but not boolean flags that take no value
164+
if (arg.startsWith("--") && !arg.contains("=") && !BOOLEAN_FLAGS.contains(arg)) {
147165
skipNext = true;
148166
continue;
149167
}
150-
if (arg.startsWith("-") && arg.length() == 2) {
168+
if (arg.startsWith("-") && arg.length() == 2 && !BOOLEAN_FLAGS.contains(arg)) {
151169
skipNext = true; // short flag like -p 8080
152170
continue;
153171
}
154172
if (arg.startsWith("-")) {
155-
continue; // --flag=value or -flag
173+
continue; // --flag=value, boolean flag, or unknown short flag
156174
}
157175
return arg;
158176
}

src/main/java/io/github/randomcodespace/iq/cli/ServeCommand.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ public class ServeCommand implements Callable<Integer> {
4242
@Option(names = {"--graph"}, description = "Path to shared graph directory (overrides default)")
4343
private Path graphPath;
4444

45+
@Option(names = {"--no-ui"}, defaultValue = "false",
46+
description = "Disable the web UI (React SPA). API and MCP endpoints remain active.")
47+
private boolean noUi;
48+
4549
@Autowired
4650
private CodeIqConfig config;
4751

@@ -73,7 +77,11 @@ public Integer call() {
7377

7478
CliOutput.step("\uD83D\uDE80", "@|bold,green Server started|@");
7579
System.out.println();
76-
CliOutput.info(" URL: http://" + host + ":" + port);
80+
if (noUi) {
81+
CliOutput.info(" Web UI: disabled (API and MCP active at :" + port + ")");
82+
} else {
83+
CliOutput.info(" Web UI: http://" + host + ":" + port + " (React SPA)");
84+
}
7785
CliOutput.info(" REST API: http://" + host + ":" + port + "/api");
7886
CliOutput.info(" MCP: http://" + host + ":" + port + "/mcp");
7987
CliOutput.info(" Health: http://" + host + ":" + port + "/actuator/health");
@@ -94,4 +102,5 @@ public Integer call() {
94102
public int getPort() { return port; }
95103
public String getHost() { return host; }
96104
public Path getGraphPath() { return graphPath; }
105+
public boolean isNoUi() { return noUi; }
97106
}

src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ public class CodeIqConfig {
2828
/** Graph configuration sub-properties. */
2929
private Graph graph = new Graph();
3030

31+
/** Whether to serve the React web UI. Set to false via --no-ui flag. */
32+
private boolean uiEnabled = true;
33+
3134
public static class Graph {
3235
private String path = ".osscodeiq/graph.db";
3336

@@ -95,4 +98,12 @@ public Graph getGraph() {
9598
public void setGraph(Graph graph) {
9699
this.graph = graph;
97100
}
101+
102+
public boolean isUiEnabled() {
103+
return uiEnabled;
104+
}
105+
106+
public void setUiEnabled(boolean uiEnabled) {
107+
this.uiEnabled = uiEnabled;
108+
}
98109
}

src/main/java/io/github/randomcodespace/iq/web/SpaController.java

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.github.randomcodespace.iq.web;
22

3+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
34
import org.springframework.context.annotation.Profile;
45
import org.springframework.stereotype.Controller;
56
import org.springframework.web.bind.annotation.GetMapping;
@@ -8,20 +9,21 @@
89
* Catch-all controller that forwards unmatched routes to index.html
910
* for React Router client-side routing (HTML5 pushState).
1011
* <p>
11-
* Only matches paths without a file extension (e.g. /topology, /explorer/class)
12+
* Only matches paths without a file extension (e.g. /graph, /explorer/class)
1213
* so static assets (.js, .css, .html, .svg) are served normally.
14+
* <p>
15+
* Disabled when {@code codeiq.ui.enabled=false} (i.e. {@code --no-ui} flag passed to serve).
1316
*/
1417
@Controller
1518
@Profile("serving")
19+
@ConditionalOnProperty(name = "codeiq.ui.enabled", havingValue = "true", matchIfMissing = true)
1620
public class SpaController {
1721

1822
@GetMapping(value = {
19-
"/topology",
20-
"/topology/**",
23+
"/graph",
24+
"/graph/**",
2125
"/explorer",
2226
"/explorer/**",
23-
"/flow",
24-
"/flow/**",
2527
"/console",
2628
"/console/**",
2729
"/api-docs",

src/main/resources/application.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ codeiq:
3030
max-depth: 10
3131
max-radius: 10
3232
batch-size: 500
33+
ui:
34+
enabled: true
3335

3436
spring.ai.mcp.server:
3537
name: code-iq
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package io.github.randomcodespace.iq;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import java.lang.reflect.Method;
6+
7+
import static org.junit.jupiter.api.Assertions.assertEquals;
8+
import static org.junit.jupiter.api.Assertions.assertNull;
9+
10+
/**
11+
* Unit tests for CodeIqApplication argument parsing helper methods.
12+
* These are called in main() before the Spring context starts.
13+
*/
14+
class CodeIqApplicationArgParsingTest {
15+
16+
private static String extractPositionalArg(String[] args, String command) throws Exception {
17+
Method m = CodeIqApplication.class.getDeclaredMethod("extractPositionalArg", String[].class, String.class);
18+
m.setAccessible(true);
19+
return (String) m.invoke(null, args, command);
20+
}
21+
22+
@Test
23+
void extractsPathAfterCommand() throws Exception {
24+
String result = extractPositionalArg(new String[]{"serve", "/my/repo"}, "serve");
25+
assertEquals("/my/repo", result);
26+
}
27+
28+
@Test
29+
void pathNotSwallowedByBooleanNoUiFlag() throws Exception {
30+
// Regression: --no-ui is boolean; must not consume /repo as its value.
31+
String result = extractPositionalArg(new String[]{"serve", "--no-ui", "/my/repo"}, "serve");
32+
assertEquals("/my/repo", result);
33+
}
34+
35+
@Test
36+
void pathStillExtractedWhenPortFlagPrecedes() throws Exception {
37+
String result = extractPositionalArg(new String[]{"serve", "--port", "9090", "/my/repo"}, "serve");
38+
assertEquals("/my/repo", result);
39+
}
40+
41+
@Test
42+
void pathStillExtractedWithNoUiAndPort() throws Exception {
43+
String result = extractPositionalArg(new String[]{"serve", "--no-ui", "--port", "9090", "/my/repo"}, "serve");
44+
assertEquals("/my/repo", result);
45+
}
46+
47+
@Test
48+
void returnsNullWhenNoPath() throws Exception {
49+
String result = extractPositionalArg(new String[]{"serve", "--no-ui"}, "serve");
50+
assertNull(result);
51+
}
52+
}

src/test/java/io/github/randomcodespace/iq/cli/ServeCommandTest.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,30 @@ void customPortIsParsed() {
4848
cmdLine.parseArgs("--port", "9090");
4949
assertEquals(9090, cmd.getPort());
5050
}
51+
52+
@Test
53+
void noUiDefaultsToFalse() {
54+
var cmd = new ServeCommand();
55+
var cmdLine = new CommandLine(cmd);
56+
cmdLine.parseArgs();
57+
assertEquals(false, cmd.isNoUi());
58+
}
59+
60+
@Test
61+
void noUiFlagIsRecognized() {
62+
var cmd = new ServeCommand();
63+
var cmdLine = new CommandLine(cmd);
64+
cmdLine.parseArgs("--no-ui");
65+
assertEquals(true, cmd.isNoUi());
66+
}
67+
68+
@Test
69+
void pathNotSwallowedWhenNoUiPrecedesPath() {
70+
// Regression: --no-ui is boolean and must not consume the next positional arg.
71+
var cmd = new ServeCommand();
72+
var cmdLine = new CommandLine(cmd);
73+
cmdLine.parseArgs("--no-ui", "/some/repo");
74+
assertEquals(true, cmd.isNoUi());
75+
assertEquals("/some/repo", cmd.getPath().toString());
76+
}
5177
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package io.github.randomcodespace.iq.web;
2+
3+
import org.junit.jupiter.api.Test;
4+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
5+
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
6+
import org.springframework.context.annotation.Profile;
7+
import org.springframework.web.bind.annotation.GetMapping;
8+
9+
import java.lang.reflect.Method;
10+
import java.util.Arrays;
11+
import java.util.List;
12+
13+
import static org.assertj.core.api.Assertions.assertThat;
14+
15+
/**
16+
* Verifies that SpaController is conditionally registered based on codeiq.ui.enabled,
17+
* and that static resource serving is also disabled via spring.web.resources.add-mappings=false.
18+
*/
19+
class SpaControllerConditionalTest {
20+
21+
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
22+
.withUserConfiguration(SpaController.class)
23+
.withSystemProperties("spring.profiles.active=serving");
24+
25+
@Test
26+
void spaControllerHasConditionalOnPropertyAnnotation() {
27+
var annotation = SpaController.class.getAnnotation(ConditionalOnProperty.class);
28+
assertThat(annotation).isNotNull();
29+
assertThat(annotation.name()).contains("codeiq.ui.enabled");
30+
assertThat(annotation.havingValue()).isEqualTo("true");
31+
assertThat(annotation.matchIfMissing()).isTrue();
32+
}
33+
34+
@Test
35+
void spaControllerRegisteredWhenUiEnabledTrue() {
36+
contextRunner
37+
.withPropertyValues("codeiq.ui.enabled=true")
38+
.run(context -> assertThat(context).hasSingleBean(SpaController.class));
39+
}
40+
41+
@Test
42+
void spaControllerRegisteredWhenPropertyAbsent() {
43+
contextRunner
44+
.run(context -> assertThat(context).hasSingleBean(SpaController.class));
45+
}
46+
47+
@Test
48+
void spaControllerNotRegisteredWhenUiEnabledFalse() {
49+
contextRunner
50+
.withPropertyValues("codeiq.ui.enabled=false")
51+
.run(context -> assertThat(context).doesNotHaveBean(SpaController.class));
52+
}
53+
54+
@Test
55+
void spaControllerHasProfileAnnotation() {
56+
var annotation = SpaController.class.getAnnotation(Profile.class);
57+
assertThat(annotation).isNotNull();
58+
assertThat(annotation.value()).contains("serving");
59+
}
60+
61+
@Test
62+
void staticResourcesDisabledWhenUiDisabled() {
63+
// When --no-ui is active, CodeIqApplication sets both properties.
64+
// Verify that spring.web.resources.add-mappings=false combined with
65+
// codeiq.ui.enabled=false leaves no SpaController in the context.
66+
contextRunner
67+
.withPropertyValues("codeiq.ui.enabled=false", "spring.web.resources.add-mappings=false")
68+
.run(context -> assertThat(context).doesNotHaveBean(SpaController.class));
69+
}
70+
71+
@Test
72+
void spaControllerExplicitRoutesContainGraph() {
73+
// Verify that /graph routes are present and /topology, /flow routes are removed.
74+
Method forwardMethod = Arrays.stream(SpaController.class.getDeclaredMethods())
75+
.filter(m -> m.isAnnotationPresent(GetMapping.class))
76+
.filter(m -> m.getName().equals("forward"))
77+
.findFirst()
78+
.orElse(null);
79+
assertThat(forwardMethod).isNotNull();
80+
List<String> routes = Arrays.asList(forwardMethod.getAnnotation(GetMapping.class).value());
81+
assertThat(routes).contains("/graph", "/graph/**");
82+
assertThat(routes).doesNotContain("/topology", "/topology/**", "/flow", "/flow/**");
83+
}
84+
}

0 commit comments

Comments
 (0)