Skip to content

Commit 275f694

Browse files
committed
feat: align Comfy CLI/MCP surface and harden subprocess lifecycle
1 parent d72247c commit 275f694

21 files changed

Lines changed: 1514 additions & 1588 deletions

‎.github/workflows/ci.yml‎

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,9 @@
1-
# CI workflow for the feature/3.0.x line (JDK 21)
2-
#
3-
# Triggers:
4-
# - push / pull_request on the feature/3.0.x branch
5-
# - manual workflow_dispatch
6-
#
7-
# Runs `./mvnw -B clean verify` (Maven 4 via the checked-in wrapper — the
8-
# runner's bundled Maven 3 cannot parse the POM 4.1.0 model) which includes
9-
# the JaCoCo coverage gate (90% line coverage, haltOnFailure=false).
1+
# CI for the Java 21 / Jackson 3 line.
102
name: CI
113

124
on:
135
push:
14-
branches: [feature/3.0.x]
6+
branches: [feature/3.0.x, hardening/3.0.x]
157
pull_request:
168
branches: [feature/3.0.x]
179
workflow_dispatch:
@@ -35,7 +27,7 @@ jobs:
3527
java-version: '21'
3628
cache: maven
3729

38-
- name: Build and verify with JaCoCo coverage gate
30+
- name: Build and verify
3931
run: ./mvnw -B --no-transfer-progress clean verify
4032

4133
- name: Upload JaCoCo coverage report

‎src/main/java/io/github/easy4j/comfy/ComfyClient.java‎

Lines changed: 44 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -2,186 +2,99 @@
22
* Copyright (c) 2018-present, easy-4-java (https://github.com/easy-4-java).
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
5-
* you may not use this file except in compliance with the License.
6-
* You may obtain a copy of the License at
7-
*
8-
* http://www.apache.org/licenses/LICENSE-2.0
9-
*
10-
* Unless required by applicable law or agreed to in writing, software
11-
* distributed under the License is distributed on an "AS IS" BASIS,
12-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13-
* See the License for the specific language governing permissions and
14-
* limitations under the License.
155
*/
166
package io.github.easy4j.comfy;
177

188
import java.util.Objects;
199

20-
import org.slf4j.Logger;
21-
import org.slf4j.LoggerFactory;
22-
2310
import io.github.easy4j.comfy.cli.ComfyCli;
2411
import io.github.easy4j.comfy.cli.ComfyCliExecutor;
2512
import io.github.easy4j.comfy.cli.ComfyCliResult;
13+
import io.github.easy4j.comfy.model.ComfyCliEnvelope;
14+
import tools.jackson.databind.DeserializationFeature;
2615
import tools.jackson.databind.JsonNode;
16+
import tools.jackson.databind.ObjectMapper;
2717
import tools.jackson.databind.json.JsonMapper;
2818

2919
/**
30-
* High-level Java facade that wraps every local {@code comfy} CLI invocation
31-
* behind ergonomic, strongly-typed methods.
20+
* High-level Java facade for the local {@code comfy} CLI route.
3221
*
33-
* <p>This class is the recommended entry point for the CLI route. It owns a
34-
* single {@link ComfyClientConfig} and a single {@link ComfyCli}, forwarding
35-
* the configured defaults to every call. For the MCP route (spawn
36-
* {@code comfy-mcp} and speak JSON-RPC over stdio) use
37-
* {@code io.github.easy4j.comfy.mcp.ComfyMcpClient}.</p>
38-
*
39-
* @author <a href="https://github.com/loong10k">Loong Wan</a>
40-
* @since 1.0.0
41-
* @see ComfyClientConfig
42-
* @see ComfyCli
22+
* <p>The lower-level {@link ComfyCli} mirrors the CLI command tree; this class
23+
* adds parsed JSON helpers while retaining access to the raw mapper.</p>
4324
*/
4425
public class ComfyClient implements AutoCloseable {
4526

46-
private static final Logger log = LoggerFactory.getLogger(ComfyClient.class);
47-
private static final JsonMapper MAPPER = new JsonMapper();
27+
private static final ObjectMapper MAPPER =
28+
JsonMapper.builder().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).build();
4829

4930
private final ComfyClientConfig config;
5031
private final ComfyCli cli;
5132

52-
/**
53-
* Creates a new client backed by the given configuration. A default
54-
* {@link ComfyCli} and {@link ComfyCliExecutor} are constructed
55-
* automatically.
56-
*
57-
* @param config runtime configuration; must not be {@code null}.
58-
* @throws NullPointerException if {@code config} is {@code null}.
59-
*/
6033
public ComfyClient(ComfyClientConfig config) {
6134
this.config = Objects.requireNonNull(config, "config");
6235
this.config.validate();
6336
this.cli = new ComfyCli(this.config, new ComfyCliExecutor(this.config));
6437
}
6538

66-
/**
67-
* Creates a new client that delegates to the supplied {@link ComfyCli}.
68-
*
69-
* <p>This constructor exists primarily for testing &mdash; it lets a
70-
* caller substitute a {@link ComfyCli} backed by a mocked executor while
71-
* still using the default behaviour of the surrounding facade.</p>
72-
*
73-
* @param config runtime configuration; must not be {@code null}.
74-
* @param cli the CLI facade to delegate to; must not be {@code null}.
75-
* @throws NullPointerException if either argument is {@code null}.
76-
*/
7739
public ComfyClient(ComfyClientConfig config, ComfyCli cli) {
7840
this.config = Objects.requireNonNull(config, "config");
41+
this.config.validate();
7942
this.cli = Objects.requireNonNull(cli, "cli");
8043
}
8144

82-
/**
83-
* Runs {@code comfy --version}.
84-
*
85-
* @return the raw CLI invocation result; never {@code null}.
86-
*/
87-
public ComfyCliResult version() {
88-
return cli.version();
89-
}
45+
public ComfyCliResult version() { return cli.version(); }
46+
public ComfyCliResult help() { return cli.help(); }
47+
public boolean isAvailable() { return cli.executor().probe(); }
48+
public ComfyCliResult cloudLogin() { return cli.cloudLogin(); }
49+
public ComfyCliResult setup() { return cli.setupYes(); }
50+
public ComfyCliResult skillsInstall() { return cli.skillsInstall(); }
9051

9152
/**
92-
* Runs {@code comfy --help}.
93-
*
94-
* @return the raw CLI invocation result; never {@code null}.
95-
*/
96-
public ComfyCliResult help() {
97-
return cli.help();
98-
}
99-
100-
/**
101-
* Probes CLI availability with {@code comfy --version} and the configured
102-
* probe timeout.
103-
*
104-
* @return {@code true} when the local CLI is reachable.
105-
*/
106-
public boolean isAvailable() {
107-
return cli.executor().probe();
108-
}
109-
110-
/**
111-
* Sends a generation request ({@code comfy generate <model>}) with
112-
* {@code --json} so the standard output can be parsed as JSON.
113-
*
114-
* @param model the generation model alias.
115-
* @param options the generation options; must not be {@code null}.
116-
* @return the parsed JSON root of the {@code --json} output; never
117-
* {@code null}.
118-
* @throws ComfyException when the invocation fails or prints non-JSON.
53+
* Runs partner generation with command-level JSON output without mutating
54+
* the caller's reusable options object.
11955
*/
12056
public JsonNode generateJson(String model, ComfyCli.GenerateOptions options) {
121-
ComfyCli.GenerateOptions jsonOptions = options.json(true);
122-
ComfyCliResult result = cli.generate(model, jsonOptions);
123-
if (!result.isSuccess()) {
124-
throw new ComfyException("comfy generate failed: exit=" + result.getExitCode()
125-
+ " stderr=" + result.getStderr());
126-
}
57+
Objects.requireNonNull(options, "options");
58+
ComfyCliResult result = cli.generate(model, options.copy().json(true));
59+
requireSuccess(result, "comfy generate");
12760
try {
12861
return MAPPER.readTree(result.getStdout());
12962
} catch (Exception e) {
13063
throw new ComfyException("comfy generate --json printed non-JSON output", e);
13164
}
13265
}
13366

134-
/**
135-
* Runs {@code comfy cloud login} (browser OAuth).
136-
*
137-
* @return the raw CLI invocation result; never {@code null}.
138-
*/
139-
public ComfyCliResult cloudLogin() {
140-
return cli.cloudLogin();
67+
/** Executes any CLI command using the global uniform {@code --json} envelope. */
68+
public ComfyCliEnvelope executeJson(String... args) {
69+
ComfyCliResult result = cli.executeJson(args);
70+
requireSuccess(result, "comfy --json");
71+
if (result.isTruncated()) {
72+
throw new ComfyException("comfy --json output exceeded maxOutputBytes="
73+
+ config.getMaxOutputBytes());
74+
}
75+
try {
76+
return MAPPER.readValue(result.getStdout(), ComfyCliEnvelope.class);
77+
} catch (Exception e) {
78+
throw new ComfyException("comfy --json printed an invalid envelope", e);
79+
}
14180
}
14281

143-
/**
144-
* Runs {@code comfy setup -y} (non-interactive setup).
145-
*
146-
* @return the raw CLI invocation result; never {@code null}.
147-
*/
148-
public ComfyCliResult setup() {
149-
return cli.setupYes();
150-
}
82+
public ComfyCliEnvelope environment() { return executeJson("env"); }
83+
public ComfyCliEnvelope whichJson() { return executeJson("which"); }
84+
public ComfyCliEnvelope discover() { return executeJson("discover"); }
15185

152-
/**
153-
* Runs {@code comfy skills install}.
154-
*
155-
* @return the raw CLI invocation result; never {@code null}.
156-
*/
157-
public ComfyCliResult skillsInstall() {
158-
return cli.skillsInstall();
159-
}
86+
public ComfyCli cli() { return cli; }
87+
public ComfyClientConfig getConfig() { return config; }
16088

161-
/**
162-
* Returns the underlying {@link ComfyCli} for advanced callers.
163-
*
164-
* @return the CLI facade backing this client; never {@code null}.
165-
*/
166-
public ComfyCli cli() {
167-
return cli;
168-
}
169-
170-
/**
171-
* Returns the runtime configuration used by this client.
172-
*
173-
* @return the configuration; never {@code null}.
174-
*/
175-
public ComfyClientConfig getConfig() {
176-
return config;
89+
private static void requireSuccess(ComfyCliResult result, String operation) {
90+
if (!result.isSuccess()) {
91+
throw new ComfyException(operation + " failed: exit=" + result.getExitCode()
92+
+ " stderr=" + result.getStderr());
93+
}
17794
}
17895

179-
/**
180-
* Closes this client. The default implementation is a no-op because the
181-
* underlying {@link ComfyCliExecutor} does not hold any long-lived
182-
* resources.
183-
*/
18496
@Override
18597
public void close() {
98+
// CLI route owns no persistent subprocess or executor.
18699
}
187100
}

‎src/main/java/io/github/easy4j/comfy/ComfyClientConfig.java‎

Lines changed: 24 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,6 @@
22
* Copyright (c) 2018-present, easy-4-java (https://github.com/easy-4-java).
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
5-
* you may not use this file except in compliance with the License.
6-
* You may obtain a copy of the License at
7-
*
8-
* http://www.apache.org/licenses/LICENSE-2.0
9-
*
10-
* Unless required by applicable law or agreed to in writing, software
11-
* distributed under the License is distributed on an "AS IS" BASIS,
12-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13-
* See the License for the specific language governing permissions and
14-
* limitations under the License.
155
*/
166
package io.github.easy4j.comfy;
177

@@ -21,51 +11,43 @@
2111
import lombok.Data;
2212

2313
/**
24-
* Configuration for the comfy CLI subprocess route.
14+
* Runtime configuration for the local {@code comfy} CLI subprocess route.
2515
*
26-
* <p>Plain POJO (Spring {@code @ConfigurationProperties}-bindable).</p>
27-
*
28-
* @author <a href="https://github.com/loong10k">Loong Wan</a>
29-
* @since 1.0.0
30-
* @see ComfyClient
16+
* <p>Credentials belong in {@link #environment}, never in argv, because
17+
* command-line arguments can be visible to other local processes.</p>
3118
*/
3219
@Data
3320
public class ComfyClientConfig {
3421

35-
/** Name or absolute path of the local {@code comfy} CLI executable. */
3622
private String localExecutable = "comfy";
37-
38-
/**
39-
* Extra environment variables for the child process (e.g.
40-
* {@code COMFY_API_KEY}, {@code COMFY_WHERE}); merged over the parent
41-
* environment. Credentials must travel here — never as command line
42-
* arguments, which are visible in {@code ps} output.
43-
*/
4423
private Map<String, String> environment;
45-
46-
/** Command execution timeout in seconds (generation runs can be long). */
4724
private int localTimeoutSeconds = 600;
48-
49-
/** Timeout in seconds used by {@link ComfyCliExecutor#probe()} when verifying CLI availability. */
5025
private int localProbeTimeoutSeconds = 5;
51-
52-
/**
53-
* Default routing forwarded as {@code --where <where>} to commands that
54-
* accept it: {@code local} or {@code cloud}. The CLI also honours the
55-
* {@code COMFY_WHERE} environment variable via {@link #environment}.
56-
*/
26+
private int maxOutputBytes = 16 * 1024 * 1024;
5727
private String defaultWhere;
5828

59-
/**
60-
* Validates the configuration.
61-
*
62-
* @throws IllegalStateException when {@code defaultWhere} is neither
63-
* {@code local} nor {@code cloud}.
64-
*/
6529
public void validate() {
6630
Objects.requireNonNull(localExecutable, "localExecutable");
67-
if (defaultWhere != null && !"local".equals(defaultWhere) && !"cloud".equals(defaultWhere)) {
68-
throw new IllegalStateException("defaultWhere must be 'local' or 'cloud': " + defaultWhere);
31+
if (localExecutable.trim().isEmpty()) {
32+
throw new IllegalStateException("localExecutable must not be blank");
33+
}
34+
if (localTimeoutSeconds <= 0) {
35+
throw new IllegalStateException("localTimeoutSeconds must be > 0");
36+
}
37+
if (localProbeTimeoutSeconds <= 0) {
38+
throw new IllegalStateException("localProbeTimeoutSeconds must be > 0");
39+
}
40+
if (maxOutputBytes == 0 || maxOutputBytes < -1) {
41+
throw new IllegalStateException("maxOutputBytes must be -1 (unbounded) or > 0");
42+
}
43+
if (defaultWhere != null) {
44+
requireWhere(defaultWhere);
45+
}
46+
}
47+
48+
public static void requireWhere(String where) {
49+
if (!"local".equals(where) && !"cloud".equals(where)) {
50+
throw new IllegalArgumentException("where must be 'local' or 'cloud': " + where);
6951
}
7052
}
7153
}

0 commit comments

Comments
 (0)