comfyui-mcp 0.28.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/comfyui/client.js +86 -0
- package/dist/comfyui/client.js.map +1 -1
- package/dist/orchestrator/backend-readiness.js +20 -0
- package/dist/orchestrator/backend-readiness.js.map +1 -1
- package/dist/orchestrator/index.js +294 -34
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/ollama-backend.js +15 -5
- package/dist/orchestrator/ollama-backend.js.map +1 -1
- package/dist/orchestrator/panel-tools.js +162 -42
- package/dist/orchestrator/panel-tools.js.map +1 -1
- package/dist/services/calc-evaluator.js +443 -0
- package/dist/services/calc-evaluator.js.map +1 -0
- package/dist/services/graph-query.js +228 -0
- package/dist/services/graph-query.js.map +1 -0
- package/dist/services/llamacpp-probe.js +88 -0
- package/dist/services/llamacpp-probe.js.map +1 -0
- package/dist/services/node-dev.js +718 -0
- package/dist/services/node-dev.js.map +1 -0
- package/dist/services/node-management.js +2 -2
- package/dist/services/node-management.js.map +1 -1
- package/dist/services/panel-secrets.js +2 -1
- package/dist/services/panel-secrets.js.map +1 -1
- package/dist/services/panel-settings.js +6 -0
- package/dist/services/panel-settings.js.map +1 -1
- package/dist/services/slot-compat.js +53 -0
- package/dist/services/slot-compat.js.map +1 -0
- package/dist/services/ui-bridge.js +97 -16
- package/dist/services/ui-bridge.js.map +1 -1
- package/dist/services/workflow-health.js +244 -0
- package/dist/services/workflow-health.js.map +1 -0
- package/dist/services/workflow-validator.js +28 -2
- package/dist/services/workflow-validator.js.map +1 -1
- package/dist/tools/calculate.js +97 -0
- package/dist/tools/calculate.js.map +1 -0
- package/dist/tools/comfyui-settings.js +68 -0
- package/dist/tools/comfyui-settings.js.map +1 -0
- package/dist/tools/index.js +6 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/node-dev.js +213 -0
- package/dist/tools/node-dev.js.map +1 -0
- package/dist/tools/workflow-dsl.js +87 -3
- package/dist/tools/workflow-dsl.js.map +1 -1
- package/dist/tools/workflow-library.js +128 -2
- package/dist/tools/workflow-library.js.map +1 -1
- package/dist/tools/workflow-validate.js +21 -4
- package/dist/tools/workflow-validate.js.map +1 -1
- package/package.json +2 -2
- package/plugin/skills/debug-render/SKILL.md +2 -2
- package/plugin/skills/director/SKILL.md +1 -1
- package/plugin/skills/workflow-layout/SKILL.md +5 -5
- package/scripts/gen-tool-docs.ts +8 -3
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { config } from "../config.js";
|
|
5
|
+
import { assertSafeRepoName, nonInteractiveGitEnv, } from "./node-management.js";
|
|
6
|
+
import { ComfyUIError } from "../utils/errors.js";
|
|
7
|
+
import { logger } from "../utils/logger.js";
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Path-jailed live custom-node dev tools.
|
|
10
|
+
//
|
|
11
|
+
// Port of filliptm/ComfyUI_FL-MCP's coding_tools.py (read/search/write/patch/
|
|
12
|
+
// git, hard-jailed to custom_nodes/ with bounded output) onto our stack, plus
|
|
13
|
+
// Windows symlink/junction/ADS safety and the seam-injected deps pattern used
|
|
14
|
+
// by node-authoring.ts. LOCAL-ONLY: every tool needs config.comfyuiPath.
|
|
15
|
+
//
|
|
16
|
+
// Design + rationale: docs/design/node-dev-tools.md.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
export class NodeDevError extends ComfyUIError {
|
|
19
|
+
constructor(message, details) {
|
|
20
|
+
super(message, "NODE_DEV_ERROR", details);
|
|
21
|
+
this.name = "NodeDevError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Refusal returned when a git write (commit/push) is attempted while the
|
|
26
|
+
* COMFYUI_MCP_ALLOW_GIT_WRITES flag is off. Structured so an agent can
|
|
27
|
+
* self-correct (see docs/design/node-dev-tools.md). The gates framework is
|
|
28
|
+
* deferred to ROADMAP Theme G; this narrow flag is what Theme G will absorb.
|
|
29
|
+
*/
|
|
30
|
+
export class GitWritesDisabledError extends ComfyUIError {
|
|
31
|
+
constructor(action) {
|
|
32
|
+
super(`node_pack_git "${action}" is disabled by configuration. Set the ` +
|
|
33
|
+
`environment variable COMFYUI_MCP_ALLOW_GIT_WRITES=1 (or "true") to ` +
|
|
34
|
+
`allow git commit/push from this server, then retry. Read-only actions ` +
|
|
35
|
+
`(status/diff/log) are always available.`, "DISABLED_BY_CONFIG");
|
|
36
|
+
this.name = "GitWritesDisabledError";
|
|
37
|
+
}
|
|
38
|
+
toToolResult() {
|
|
39
|
+
return {
|
|
40
|
+
isError: true,
|
|
41
|
+
content: [
|
|
42
|
+
{
|
|
43
|
+
type: "text",
|
|
44
|
+
text: JSON.stringify({
|
|
45
|
+
error: "DISABLED_BY_CONFIG",
|
|
46
|
+
disabled_by_config: true,
|
|
47
|
+
required_flag: "COMFYUI_MCP_ALLOW_GIT_WRITES=1",
|
|
48
|
+
message: this.message,
|
|
49
|
+
}),
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// FL-MCP output-bounding constants (its proven values).
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
export const READ_DEFAULT_LINES = 240;
|
|
59
|
+
export const READ_MAX_LINES = 800;
|
|
60
|
+
export const READ_DEFAULT_CHARS = 12_000;
|
|
61
|
+
export const READ_MAX_CHARS = 24_000;
|
|
62
|
+
/** Long lines are chunked at this width so a single minified line can't blow the budget. */
|
|
63
|
+
export const LONG_LINE_CHUNK = 1_000;
|
|
64
|
+
/** Per-match line cap for search results. */
|
|
65
|
+
export const SEARCH_LINE_MAX = 600;
|
|
66
|
+
/** Bound on any subprocess (git / patch) stdout+stderr surfaced to the caller. */
|
|
67
|
+
export const CMD_OUTPUT_MAX = 12_000;
|
|
68
|
+
export const LIST_DEFAULT_ENTRIES = 500;
|
|
69
|
+
export const LIST_MAX_ENTRIES = 2_000;
|
|
70
|
+
export const SEARCH_DEFAULT_RESULTS = 50;
|
|
71
|
+
export const SEARCH_MAX_RESULTS = 100;
|
|
72
|
+
/** Skip files larger than this in the builtin search walker. */
|
|
73
|
+
const SEARCH_MAX_FILE_BYTES = 1024 * 1024;
|
|
74
|
+
/** Hard cap on files the builtin walker will open in one search. */
|
|
75
|
+
const SEARCH_MAX_SCANNED_FILES = 20_000;
|
|
76
|
+
const GIT_TIMEOUT_MS = 60_000;
|
|
77
|
+
const GIT_PUSH_TIMEOUT_MS = 180_000;
|
|
78
|
+
const SKIP_DIRS = new Set([".git", "__pycache__", "node_modules"]);
|
|
79
|
+
function defaultSpawn(cmd, args, opts) {
|
|
80
|
+
const res = spawnSync(cmd, args, {
|
|
81
|
+
cwd: opts.cwd,
|
|
82
|
+
encoding: "utf-8",
|
|
83
|
+
timeout: opts.timeoutMs,
|
|
84
|
+
input: opts.input,
|
|
85
|
+
env: opts.env,
|
|
86
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
87
|
+
windowsHide: true,
|
|
88
|
+
});
|
|
89
|
+
if (res.error) {
|
|
90
|
+
const e = res.error;
|
|
91
|
+
if (e.code === "ENOENT") {
|
|
92
|
+
throw new NodeDevError(`"${cmd}" was not found on PATH. Install ${cmd} to use this operation.`);
|
|
93
|
+
}
|
|
94
|
+
throw new NodeDevError(`Failed to execute ${cmd}: ${e.message}`);
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
status: res.status,
|
|
98
|
+
stdout: res.stdout ?? "",
|
|
99
|
+
stderr: res.stderr ?? "",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
export const defaultDeps = {
|
|
103
|
+
existsSync,
|
|
104
|
+
isDirectory: (p) => {
|
|
105
|
+
try {
|
|
106
|
+
return statSync(p).isDirectory();
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
isFile: (p) => {
|
|
113
|
+
try {
|
|
114
|
+
return statSync(p).isFile();
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
fileSize: (p) => {
|
|
121
|
+
try {
|
|
122
|
+
return statSync(p).size;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
listDir: (p) => readdirSync(p, { withFileTypes: true }).map((d) => ({
|
|
129
|
+
name: d.name,
|
|
130
|
+
isDir: d.isDirectory(),
|
|
131
|
+
})),
|
|
132
|
+
readFileText: (p) => readFileSync(p, "utf-8"),
|
|
133
|
+
readFileBuffer: (p) => readFileSync(p),
|
|
134
|
+
writeFileText: (p, contents) => writeFileSync(p, contents, "utf-8"),
|
|
135
|
+
mkdirp: (p) => {
|
|
136
|
+
mkdirSync(p, { recursive: true });
|
|
137
|
+
},
|
|
138
|
+
realpath: (p) => realpathSync.native(p),
|
|
139
|
+
hasRipgrep: () => {
|
|
140
|
+
try {
|
|
141
|
+
const res = spawnSync("rg", ["--version"], {
|
|
142
|
+
stdio: "ignore",
|
|
143
|
+
timeout: 5_000,
|
|
144
|
+
windowsHide: true,
|
|
145
|
+
});
|
|
146
|
+
return res.status === 0;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
runGit: (args, opts) => defaultSpawn("git", args, { ...opts, env: nonInteractiveGitEnv() }),
|
|
153
|
+
runRipgrep: (args, opts) => defaultSpawn("rg", args, opts),
|
|
154
|
+
};
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Path jail
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
const WIN_RESERVED = new Set([
|
|
159
|
+
"CON",
|
|
160
|
+
"PRN",
|
|
161
|
+
"AUX",
|
|
162
|
+
"NUL",
|
|
163
|
+
...Array.from({ length: 9 }, (_, i) => `COM${i + 1}`),
|
|
164
|
+
...Array.from({ length: 9 }, (_, i) => `LPT${i + 1}`),
|
|
165
|
+
]);
|
|
166
|
+
/**
|
|
167
|
+
* Reject inputs whose SHAPE is dangerous on Windows (NTFS alternate data
|
|
168
|
+
* streams, reserved device names, trailing dots/spaces, UNC paths, and
|
|
169
|
+
* drive-relative paths like "C:x"). Applied to raw input on every platform so
|
|
170
|
+
* behavior is uniform and a repo synced from Windows can't smuggle a hazard.
|
|
171
|
+
*/
|
|
172
|
+
function assertNoWindowsHazards(raw) {
|
|
173
|
+
// UNC path (\\server\share or //server/share).
|
|
174
|
+
if (/^[\\/]{2}/.test(raw)) {
|
|
175
|
+
throw new NodeDevError(`Refusing UNC path "${raw}": paths must stay inside custom_nodes/.`);
|
|
176
|
+
}
|
|
177
|
+
// Drive-relative path: a drive letter + colon NOT followed by a separator
|
|
178
|
+
// ("C:x" resolves against the drive's CWD, escaping the jail).
|
|
179
|
+
if (/^[a-zA-Z]:(?![\\/])/.test(raw)) {
|
|
180
|
+
throw new NodeDevError(`Refusing drive-relative path "${raw}": it is ambiguous and can escape ` +
|
|
181
|
+
`custom_nodes/. Use a path relative to custom_nodes/ or a full absolute path.`);
|
|
182
|
+
}
|
|
183
|
+
// Strip a leading absolute drive prefix ("C:") before scanning segments so
|
|
184
|
+
// the drive's own colon isn't flagged as an ADS.
|
|
185
|
+
const afterDrive = /^[a-zA-Z]:[\\/]/.test(raw) ? raw.slice(2) : raw;
|
|
186
|
+
for (const seg of afterDrive.split(/[\\/]/)) {
|
|
187
|
+
if (!seg)
|
|
188
|
+
continue;
|
|
189
|
+
if (seg.includes(":")) {
|
|
190
|
+
throw new NodeDevError(`Refusing NTFS alternate data stream in "${seg}": ':' is not allowed in a path segment.`);
|
|
191
|
+
}
|
|
192
|
+
const stem = seg.split(".")[0].toUpperCase();
|
|
193
|
+
if (WIN_RESERVED.has(seg.toUpperCase()) || WIN_RESERVED.has(stem)) {
|
|
194
|
+
throw new NodeDevError(`Refusing reserved Windows device name "${seg}".`);
|
|
195
|
+
}
|
|
196
|
+
if (/[ .]$/.test(seg)) {
|
|
197
|
+
throw new NodeDevError(`Refusing path segment "${seg}": trailing dot or space is unsafe on Windows.`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
export function customNodesRoot() {
|
|
202
|
+
if (!config.comfyuiPath) {
|
|
203
|
+
throw new NodeDevError("This operation requires a local ComfyUI install, but config.comfyuiPath " +
|
|
204
|
+
"is not set (running in remote --comfyui-url mode). Set COMFYUI_PATH to " +
|
|
205
|
+
"your local ComfyUI directory to read, search, or edit custom-node source.");
|
|
206
|
+
}
|
|
207
|
+
return resolve(config.comfyuiPath, "custom_nodes");
|
|
208
|
+
}
|
|
209
|
+
function isEscape(root, candidate) {
|
|
210
|
+
const rel = relative(root, candidate);
|
|
211
|
+
return (rel.startsWith("..") ||
|
|
212
|
+
isAbsolute(rel) ||
|
|
213
|
+
rel.split(/[\\/]/).includes(".."));
|
|
214
|
+
}
|
|
215
|
+
/** Realpath the deepest existing ancestor, re-appending the not-yet-existing tail. */
|
|
216
|
+
function realpathDeepestExisting(abs, deps) {
|
|
217
|
+
let cur = abs;
|
|
218
|
+
const tail = [];
|
|
219
|
+
// Guard against pathological loops.
|
|
220
|
+
for (let i = 0; i < 4096; i++) {
|
|
221
|
+
if (deps.existsSync(cur)) {
|
|
222
|
+
const real = deps.realpath(cur);
|
|
223
|
+
return tail.length ? join(real, ...tail.reverse()) : real;
|
|
224
|
+
}
|
|
225
|
+
const parent = dirname(cur);
|
|
226
|
+
if (parent === cur)
|
|
227
|
+
return abs;
|
|
228
|
+
tail.push(basename(cur));
|
|
229
|
+
cur = parent;
|
|
230
|
+
}
|
|
231
|
+
return abs;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The single auditable jail resolver. Returns the realpath'd absolute path and
|
|
235
|
+
* its path relative to the realpath'd custom_nodes root. Throws NodeDevError on
|
|
236
|
+
* any lexical- or symlink-based escape. rel === "" denotes the root itself;
|
|
237
|
+
* callers that must not touch the root reject an empty rel.
|
|
238
|
+
*/
|
|
239
|
+
export function resolveInJail(input, deps = defaultDeps) {
|
|
240
|
+
const raw = (input ?? "").trim();
|
|
241
|
+
if (!raw)
|
|
242
|
+
throw new NodeDevError("A path is required (received an empty string).");
|
|
243
|
+
assertNoWindowsHazards(raw);
|
|
244
|
+
const root = customNodesRoot();
|
|
245
|
+
const candidate = isAbsolute(raw) ? resolve(raw) : resolve(root, raw);
|
|
246
|
+
// 1. Lexical containment on the un-resolved candidate.
|
|
247
|
+
if (isEscape(root, candidate)) {
|
|
248
|
+
throw new NodeDevError(`Refusing "${input}": resolves to "${candidate}", which is outside custom_nodes/.`);
|
|
249
|
+
}
|
|
250
|
+
// 2. Symlink/junction safety: re-check containment on realpaths.
|
|
251
|
+
const realRoot = deps.existsSync(root) ? deps.realpath(root) : root;
|
|
252
|
+
const realCandidate = realpathDeepestExisting(candidate, deps);
|
|
253
|
+
if (isEscape(realRoot, realCandidate)) {
|
|
254
|
+
throw new NodeDevError(`Refusing "${input}": its real path "${realCandidate}" escapes custom_nodes/ ` +
|
|
255
|
+
`(via a symlink or junction).`);
|
|
256
|
+
}
|
|
257
|
+
return { abs: realCandidate, rel: relative(realRoot, realCandidate) };
|
|
258
|
+
}
|
|
259
|
+
/** Resolve a pack folder: name validated + jailed, must be a non-root dir. */
|
|
260
|
+
function resolvePackDir(pack, deps) {
|
|
261
|
+
const name = (pack ?? "").trim();
|
|
262
|
+
assertSafeRepoName(name);
|
|
263
|
+
const { abs, rel } = resolveInJail(name, deps);
|
|
264
|
+
if (!rel) {
|
|
265
|
+
throw new NodeDevError("Refusing to operate on the custom_nodes root itself.");
|
|
266
|
+
}
|
|
267
|
+
return { abs, name };
|
|
268
|
+
}
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// Bounded-text helpers (pure — exported for direct testing)
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
/** Break any line longer than `width` into multiple lines of at most `width`. */
|
|
273
|
+
export function chunkLongLines(lines, width = LONG_LINE_CHUNK) {
|
|
274
|
+
const out = [];
|
|
275
|
+
for (const line of lines) {
|
|
276
|
+
if (line.length <= width) {
|
|
277
|
+
out.push(line);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
for (let i = 0; i < line.length; i += width) {
|
|
281
|
+
out.push(line.slice(i, i + width));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
/** Clip text to maxChars, appending a truncation notice when clipped. */
|
|
287
|
+
export function boundText(text, maxChars, notice = "\n\n[... output truncated — request a narrower range ...]") {
|
|
288
|
+
if (text.length <= maxChars)
|
|
289
|
+
return { text, truncated: false };
|
|
290
|
+
return { text: text.slice(0, maxChars) + notice, truncated: true };
|
|
291
|
+
}
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// glob → RegExp (minimal: **, *, ?)
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
function globToRegExp(glob) {
|
|
296
|
+
let re = "";
|
|
297
|
+
for (let i = 0; i < glob.length; i++) {
|
|
298
|
+
const c = glob[i];
|
|
299
|
+
if (c === "*") {
|
|
300
|
+
if (glob[i + 1] === "*") {
|
|
301
|
+
re += ".*";
|
|
302
|
+
i++;
|
|
303
|
+
if (glob[i + 1] === "/")
|
|
304
|
+
i++;
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
re += "[^/\\\\]*";
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
else if (c === "?") {
|
|
311
|
+
re += "[^/\\\\]";
|
|
312
|
+
}
|
|
313
|
+
else if ("\\^$.|+()[]{}".includes(c)) {
|
|
314
|
+
re += "\\" + c;
|
|
315
|
+
}
|
|
316
|
+
else if (c === "/") {
|
|
317
|
+
re += "[/\\\\]";
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
re += c;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return new RegExp(`^${re}$`, "i");
|
|
324
|
+
}
|
|
325
|
+
export function listNodePackFiles(options, deps = defaultDeps) {
|
|
326
|
+
const { abs: packDir, name } = resolvePackDir(options.pack, deps);
|
|
327
|
+
if (!deps.isDirectory(packDir)) {
|
|
328
|
+
throw new NodeDevError(`Pack "${name}" does not exist under custom_nodes/.`);
|
|
329
|
+
}
|
|
330
|
+
const cap = Math.min(Math.max(1, options.maxEntries ?? LIST_DEFAULT_ENTRIES), LIST_MAX_ENTRIES);
|
|
331
|
+
const matcher = options.glob ? globToRegExp(options.glob) : null;
|
|
332
|
+
const entries = [];
|
|
333
|
+
let truncated = false;
|
|
334
|
+
const walk = (dir) => {
|
|
335
|
+
if (truncated)
|
|
336
|
+
return;
|
|
337
|
+
let items;
|
|
338
|
+
try {
|
|
339
|
+
items = deps.listDir(dir);
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
for (const item of items) {
|
|
345
|
+
if (truncated)
|
|
346
|
+
return;
|
|
347
|
+
const full = join(dir, item.name);
|
|
348
|
+
const rel = relative(packDir, full).split(/[\\/]/).join("/");
|
|
349
|
+
if (item.isDir) {
|
|
350
|
+
if (SKIP_DIRS.has(item.name))
|
|
351
|
+
continue;
|
|
352
|
+
if (!matcher || matcher.test(rel)) {
|
|
353
|
+
entries.push({ path: rel, size: 0, dir: true });
|
|
354
|
+
if (entries.length >= cap) {
|
|
355
|
+
truncated = true;
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
walk(full);
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
if (matcher && !matcher.test(rel))
|
|
363
|
+
continue;
|
|
364
|
+
entries.push({ path: rel, size: deps.fileSize(full), dir: false });
|
|
365
|
+
if (entries.length >= cap) {
|
|
366
|
+
truncated = true;
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
walk(packDir);
|
|
373
|
+
return {
|
|
374
|
+
pack: name,
|
|
375
|
+
root: packDir,
|
|
376
|
+
entries,
|
|
377
|
+
truncated,
|
|
378
|
+
is_git_repo: deps.isDirectory(join(packDir, ".git")),
|
|
379
|
+
has_pyproject: deps.isFile(join(packDir, "pyproject.toml")),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
export function readNodeFile(options, deps = defaultDeps) {
|
|
383
|
+
const { abs, rel } = resolveInJail(options.path, deps);
|
|
384
|
+
if (!rel)
|
|
385
|
+
throw new NodeDevError("Refusing to read the custom_nodes root itself.");
|
|
386
|
+
if (!deps.existsSync(abs) || !deps.isFile(abs)) {
|
|
387
|
+
throw new NodeDevError(`File not found under custom_nodes/: "${options.path}".`);
|
|
388
|
+
}
|
|
389
|
+
const size = deps.fileSize(abs);
|
|
390
|
+
const raw = deps.readFileText(abs);
|
|
391
|
+
// Split on CRLF or LF so total_lines is correct on Windows files.
|
|
392
|
+
const allLines = raw.split(/\r\n|\n/);
|
|
393
|
+
const totalLines = allLines.length;
|
|
394
|
+
const startLine = Math.max(1, Math.floor(options.startLine ?? 1));
|
|
395
|
+
const lineCount = Math.min(Math.max(1, Math.floor(options.lineCount ?? READ_DEFAULT_LINES)), READ_MAX_LINES);
|
|
396
|
+
const maxChars = Math.min(Math.max(1, Math.floor(options.maxChars ?? READ_DEFAULT_CHARS)), READ_MAX_CHARS);
|
|
397
|
+
const startIdx = startLine - 1;
|
|
398
|
+
const slice = allLines.slice(startIdx, startIdx + lineCount);
|
|
399
|
+
const endLine = Math.min(totalLines, startIdx + slice.length);
|
|
400
|
+
const chunked = chunkLongLines(slice);
|
|
401
|
+
const bounded = boundText(chunked.join("\n"), maxChars);
|
|
402
|
+
return {
|
|
403
|
+
path: rel.split(/[\\/]/).join("/"),
|
|
404
|
+
content: bounded.text,
|
|
405
|
+
start_line: startLine,
|
|
406
|
+
end_line: endLine,
|
|
407
|
+
total_lines: totalLines,
|
|
408
|
+
size,
|
|
409
|
+
truncated: bounded.truncated || endLine < totalLines,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
/** Resolve the directory a search runs over (default "." = the whole jail root). */
|
|
413
|
+
function resolveSearchDir(path, deps) {
|
|
414
|
+
const p = (path ?? ".").trim();
|
|
415
|
+
if (p === "." || p === "")
|
|
416
|
+
return customNodesRoot();
|
|
417
|
+
const { abs } = resolveInJail(p, deps);
|
|
418
|
+
return abs;
|
|
419
|
+
}
|
|
420
|
+
export function searchNodePacks(options, deps = defaultDeps) {
|
|
421
|
+
const query = options.query ?? "";
|
|
422
|
+
if (!query)
|
|
423
|
+
throw new NodeDevError("A non-empty search query is required.");
|
|
424
|
+
const cap = Math.min(Math.max(1, options.maxResults ?? SEARCH_DEFAULT_RESULTS), SEARCH_MAX_RESULTS);
|
|
425
|
+
const searchDir = resolveSearchDir(options.path, deps);
|
|
426
|
+
if (!deps.isDirectory(searchDir)) {
|
|
427
|
+
throw new NodeDevError(`Search path does not exist under custom_nodes/.`);
|
|
428
|
+
}
|
|
429
|
+
if (deps.hasRipgrep()) {
|
|
430
|
+
return searchWithRipgrep(query, searchDir, cap, options, deps);
|
|
431
|
+
}
|
|
432
|
+
return searchBuiltin(query, searchDir, cap, options, deps);
|
|
433
|
+
}
|
|
434
|
+
function searchWithRipgrep(query, searchDir, cap, options, deps) {
|
|
435
|
+
const args = [
|
|
436
|
+
"--line-number",
|
|
437
|
+
"--no-heading",
|
|
438
|
+
"--color",
|
|
439
|
+
"never",
|
|
440
|
+
"--path-separator",
|
|
441
|
+
"/",
|
|
442
|
+
"--max-count",
|
|
443
|
+
String(cap),
|
|
444
|
+
];
|
|
445
|
+
if (!options.caseSensitive)
|
|
446
|
+
args.push("-i");
|
|
447
|
+
if (options.glob)
|
|
448
|
+
args.push("-g", options.glob);
|
|
449
|
+
for (const d of SKIP_DIRS)
|
|
450
|
+
args.push("-g", `!${d}/`);
|
|
451
|
+
args.push("--regexp", query, "--", ".");
|
|
452
|
+
const res = deps.runRipgrep(args, { cwd: searchDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
453
|
+
// rg exits 1 when there are no matches — not an error for us.
|
|
454
|
+
if (res.status !== 0 && res.status !== 1) {
|
|
455
|
+
throw new NodeDevError(`ripgrep failed (exit ${res.status}): ${res.stderr.slice(0, 500)}`);
|
|
456
|
+
}
|
|
457
|
+
const matches = [];
|
|
458
|
+
let truncated = false;
|
|
459
|
+
for (const raw of res.stdout.split(/\r?\n/)) {
|
|
460
|
+
if (!raw)
|
|
461
|
+
continue;
|
|
462
|
+
if (matches.length >= cap) {
|
|
463
|
+
truncated = true;
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
const m = /^(.*?):(\d+):(.*)$/.exec(raw);
|
|
467
|
+
if (!m)
|
|
468
|
+
continue;
|
|
469
|
+
matches.push({
|
|
470
|
+
file: m[1],
|
|
471
|
+
line: Number(m[2]),
|
|
472
|
+
text: m[3].slice(0, SEARCH_LINE_MAX),
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
return { engine: "ripgrep", matches, truncated };
|
|
476
|
+
}
|
|
477
|
+
function searchBuiltin(query, searchDir, cap, options, deps) {
|
|
478
|
+
const re = new RegExp(query, options.caseSensitive ? "" : "i");
|
|
479
|
+
const globMatcher = options.glob ? globToRegExp(options.glob) : null;
|
|
480
|
+
const matches = [];
|
|
481
|
+
let truncated = false;
|
|
482
|
+
let scanned = 0;
|
|
483
|
+
const walk = (dir) => {
|
|
484
|
+
if (truncated)
|
|
485
|
+
return;
|
|
486
|
+
let items;
|
|
487
|
+
try {
|
|
488
|
+
items = deps.listDir(dir);
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
for (const item of items) {
|
|
494
|
+
if (truncated)
|
|
495
|
+
return;
|
|
496
|
+
const full = join(dir, item.name);
|
|
497
|
+
if (item.isDir) {
|
|
498
|
+
if (SKIP_DIRS.has(item.name) || item.name.startsWith("."))
|
|
499
|
+
continue;
|
|
500
|
+
walk(full);
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
const rel = relative(searchDir, full).split(/[\\/]/).join("/");
|
|
504
|
+
if (globMatcher && !globMatcher.test(rel))
|
|
505
|
+
continue;
|
|
506
|
+
if (deps.fileSize(full) > SEARCH_MAX_FILE_BYTES)
|
|
507
|
+
continue;
|
|
508
|
+
if (++scanned > SEARCH_MAX_SCANNED_FILES) {
|
|
509
|
+
truncated = true;
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
let buf;
|
|
513
|
+
try {
|
|
514
|
+
buf = deps.readFileBuffer(full);
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (buf.includes(0))
|
|
520
|
+
continue; // binary — skip
|
|
521
|
+
const lines = buf.toString("utf-8").split(/\r\n|\n/);
|
|
522
|
+
for (let i = 0; i < lines.length; i++) {
|
|
523
|
+
if (re.test(lines[i])) {
|
|
524
|
+
if (matches.length >= cap) {
|
|
525
|
+
truncated = true;
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
matches.push({
|
|
529
|
+
file: rel,
|
|
530
|
+
line: i + 1,
|
|
531
|
+
text: lines[i].slice(0, SEARCH_LINE_MAX),
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
walk(searchDir);
|
|
538
|
+
return { engine: "builtin", matches, truncated };
|
|
539
|
+
}
|
|
540
|
+
export function writeNodeFile(options, deps = defaultDeps) {
|
|
541
|
+
const { abs, rel } = resolveInJail(options.path, deps);
|
|
542
|
+
if (!rel)
|
|
543
|
+
throw new NodeDevError("Refusing to write the custom_nodes root itself.");
|
|
544
|
+
const exists = deps.existsSync(abs);
|
|
545
|
+
if (exists && !options.overwrite) {
|
|
546
|
+
throw new NodeDevError(`File already exists: "${options.path}". Pass overwrite:true to replace it.`);
|
|
547
|
+
}
|
|
548
|
+
if (exists && !deps.isFile(abs)) {
|
|
549
|
+
throw new NodeDevError(`Refusing to overwrite non-file path "${options.path}".`);
|
|
550
|
+
}
|
|
551
|
+
const parent = dirname(abs);
|
|
552
|
+
if (!deps.existsSync(parent)) {
|
|
553
|
+
if (options.createDirs === false) {
|
|
554
|
+
throw new NodeDevError(`Parent directory does not exist for "${options.path}" and create_dirs is false.`);
|
|
555
|
+
}
|
|
556
|
+
deps.mkdirp(parent);
|
|
557
|
+
}
|
|
558
|
+
const content = options.content ?? "";
|
|
559
|
+
deps.writeFileText(abs, content);
|
|
560
|
+
logger.info("write_node_file", { path: rel, bytes: Buffer.byteLength(content) });
|
|
561
|
+
return {
|
|
562
|
+
path: rel.split(/[\\/]/).join("/"),
|
|
563
|
+
bytes: Buffer.byteLength(content, "utf-8"),
|
|
564
|
+
created: !exists,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
/** Extract every file path a unified diff touches (from ---/+++ headers). */
|
|
568
|
+
export function parsePatchPaths(patch) {
|
|
569
|
+
const paths = new Set();
|
|
570
|
+
for (const line of patch.split(/\r?\n/)) {
|
|
571
|
+
const m = /^(?:---|\+\+\+) (.+)$/.exec(line);
|
|
572
|
+
if (!m)
|
|
573
|
+
continue;
|
|
574
|
+
let p = m[1].trim();
|
|
575
|
+
if (p === "/dev/null")
|
|
576
|
+
continue;
|
|
577
|
+
// Strip a trailing tab-prefixed timestamp some diff tools append.
|
|
578
|
+
p = p.replace(/\t.*$/, "");
|
|
579
|
+
// Strip a/ or b/ prefix.
|
|
580
|
+
p = p.replace(/^[ab]\//, "");
|
|
581
|
+
if (p)
|
|
582
|
+
paths.add(p);
|
|
583
|
+
}
|
|
584
|
+
return [...paths];
|
|
585
|
+
}
|
|
586
|
+
export function applyNodePatch(patch, deps = defaultDeps) {
|
|
587
|
+
if (!patch || !patch.trim()) {
|
|
588
|
+
throw new NodeDevError("An empty patch was provided.");
|
|
589
|
+
}
|
|
590
|
+
const root = customNodesRoot();
|
|
591
|
+
// Phase 1: jail-check EVERY touched path BEFORE any git call.
|
|
592
|
+
const touched = parsePatchPaths(patch);
|
|
593
|
+
if (touched.length === 0) {
|
|
594
|
+
throw new NodeDevError("Could not find any file headers (---/+++) in the patch. Provide a unified diff.");
|
|
595
|
+
}
|
|
596
|
+
for (const p of touched) {
|
|
597
|
+
const { rel } = resolveInJail(p, deps);
|
|
598
|
+
if (!rel) {
|
|
599
|
+
throw new NodeDevError(`Patch would touch the custom_nodes root itself ("${p}").`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
const input = patch.endsWith("\n") ? patch : patch + "\n";
|
|
603
|
+
// Phase 2a: git apply --check (dry run).
|
|
604
|
+
const check = deps.runGit(["apply", "--check"], {
|
|
605
|
+
cwd: root,
|
|
606
|
+
timeoutMs: GIT_TIMEOUT_MS,
|
|
607
|
+
input,
|
|
608
|
+
});
|
|
609
|
+
if (check.status !== 0) {
|
|
610
|
+
return {
|
|
611
|
+
success: false,
|
|
612
|
+
stage: "check",
|
|
613
|
+
touched,
|
|
614
|
+
stdout: boundText(check.stdout, CMD_OUTPUT_MAX).text,
|
|
615
|
+
stderr: boundText(check.stderr, CMD_OUTPUT_MAX).text,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
// Phase 2b: git apply (real).
|
|
619
|
+
const apply = deps.runGit(["apply"], {
|
|
620
|
+
cwd: root,
|
|
621
|
+
timeoutMs: GIT_TIMEOUT_MS,
|
|
622
|
+
input,
|
|
623
|
+
});
|
|
624
|
+
return {
|
|
625
|
+
success: apply.status === 0,
|
|
626
|
+
stage: "apply",
|
|
627
|
+
touched,
|
|
628
|
+
stdout: boundText(apply.stdout, CMD_OUTPUT_MAX).text,
|
|
629
|
+
stderr: boundText(apply.stderr, CMD_OUTPUT_MAX).text,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
/** Whether git writes (commit/push) are permitted by env flag. Default OFF. */
|
|
633
|
+
export function gitWritesEnabled() {
|
|
634
|
+
const v = (process.env.COMFYUI_MCP_ALLOW_GIT_WRITES ?? "").trim().toLowerCase();
|
|
635
|
+
return v === "1" || v === "true";
|
|
636
|
+
}
|
|
637
|
+
/** Jail-check a caller-supplied path and return it relative to the pack dir. */
|
|
638
|
+
function packRelativePath(packDir, p, deps) {
|
|
639
|
+
const { abs } = resolveInJail(p, deps);
|
|
640
|
+
const rel = relative(packDir, abs);
|
|
641
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
642
|
+
throw new NodeDevError(`Path "${p}" is outside the target pack.`);
|
|
643
|
+
}
|
|
644
|
+
return rel.split(/[\\/]/).join("/") || ".";
|
|
645
|
+
}
|
|
646
|
+
export function nodePackGit(options, deps = defaultDeps) {
|
|
647
|
+
const { abs: packDir, name } = resolvePackDir(options.pack, deps);
|
|
648
|
+
if (!deps.isDirectory(packDir)) {
|
|
649
|
+
throw new NodeDevError(`Pack "${name}" does not exist under custom_nodes/.`);
|
|
650
|
+
}
|
|
651
|
+
const action = options.action;
|
|
652
|
+
const maxChars = Math.min(Math.max(1, options.maxChars ?? CMD_OUTPUT_MAX), READ_MAX_CHARS);
|
|
653
|
+
const relPaths = (options.paths ?? []).map((p) => packRelativePath(packDir, p, deps));
|
|
654
|
+
let argv;
|
|
655
|
+
let timeoutMs = GIT_TIMEOUT_MS;
|
|
656
|
+
switch (action) {
|
|
657
|
+
case "status":
|
|
658
|
+
argv = ["status", "--short", "--branch"];
|
|
659
|
+
if (relPaths.length)
|
|
660
|
+
argv.push("--", ...relPaths);
|
|
661
|
+
break;
|
|
662
|
+
case "diff":
|
|
663
|
+
argv = ["diff"];
|
|
664
|
+
if (relPaths.length)
|
|
665
|
+
argv.push("--", ...relPaths);
|
|
666
|
+
break;
|
|
667
|
+
case "log":
|
|
668
|
+
argv = ["log", "--max-count=20", "--pretty=format:%h %an %ad %s", "--date=short"];
|
|
669
|
+
if (relPaths.length)
|
|
670
|
+
argv.push("--", ...relPaths);
|
|
671
|
+
break;
|
|
672
|
+
case "commit": {
|
|
673
|
+
if (!gitWritesEnabled())
|
|
674
|
+
throw new GitWritesDisabledError("commit");
|
|
675
|
+
const message = (options.message ?? "").trim();
|
|
676
|
+
if (!message) {
|
|
677
|
+
throw new NodeDevError("commit requires a non-empty message.");
|
|
678
|
+
}
|
|
679
|
+
// Stage first (selective or all), then commit.
|
|
680
|
+
const addArgs = relPaths.length
|
|
681
|
+
? ["add", "--end-of-options", "--", ...relPaths]
|
|
682
|
+
: ["add", "-A"];
|
|
683
|
+
const add = deps.runGit(addArgs, { cwd: packDir, timeoutMs });
|
|
684
|
+
if (add.status !== 0) {
|
|
685
|
+
return {
|
|
686
|
+
pack: name,
|
|
687
|
+
action,
|
|
688
|
+
argv: addArgs,
|
|
689
|
+
status: add.status,
|
|
690
|
+
stdout: boundText(add.stdout, maxChars).text,
|
|
691
|
+
stderr: boundText(add.stderr, maxChars).text,
|
|
692
|
+
success: false,
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
argv = ["commit", "-m", message];
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
case "push":
|
|
699
|
+
if (!gitWritesEnabled())
|
|
700
|
+
throw new GitWritesDisabledError("push");
|
|
701
|
+
argv = ["push"];
|
|
702
|
+
timeoutMs = GIT_PUSH_TIMEOUT_MS;
|
|
703
|
+
break;
|
|
704
|
+
default:
|
|
705
|
+
throw new NodeDevError(`Unknown git action "${String(action)}".`);
|
|
706
|
+
}
|
|
707
|
+
const res = deps.runGit(argv, { cwd: packDir, timeoutMs });
|
|
708
|
+
return {
|
|
709
|
+
pack: name,
|
|
710
|
+
action,
|
|
711
|
+
argv,
|
|
712
|
+
status: res.status,
|
|
713
|
+
stdout: boundText(res.stdout, maxChars).text,
|
|
714
|
+
stderr: boundText(res.stderr, maxChars).text,
|
|
715
|
+
success: res.status === 0,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
//# sourceMappingURL=node-dev.js.map
|