comfyui-mcp 0.17.0 → 0.17.1
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/package.json +3 -1
- package/scripts/check-model-urls.mjs +160 -0
- package/scripts/check-pack-models.mjs +155 -0
- package/scripts/gen-pack-installers.mjs +151 -0
- package/scripts/gen-tool-docs.ts +363 -0
- package/scripts/generation-stats.mjs +136 -0
- package/scripts/mock-panel.mjs +156 -0
- package/scripts/panel-sim.mjs +56 -0
- package/scripts/postinstall.mjs +29 -0
- package/scripts/probe-bridge.mjs +39 -0
- package/scripts/probe-models.mjs +18 -0
- package/scripts/slice-pipeline.mjs +79 -0
- package/scripts/smoke-install.mjs +70 -0
- package/scripts/sync-agents.mjs +147 -0
- package/scripts/test-agent.mjs +313 -0
- package/scripts/test-packs.sh +87 -0
- package/scripts/validate-manifests.mjs +29 -0
- package/scripts/verify-render.mjs +103 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "comfyui-mcp",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.1",
|
|
4
4
|
"mcpName": "io.github.artokun/comfyui-mcp",
|
|
5
5
|
"description": "Claude Code plugin + MCP server for ComfyUI - 96 tools, 16 AI skills (Flux, WAN, LTX video, Qwen, Civitai), live graph editing from your Claude session. Generate images, video & audio, manage models and custom nodes.",
|
|
6
6
|
"homepage": "https://comfyui-mcp.artokun.io/docs",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"dist",
|
|
14
14
|
"plugin",
|
|
15
15
|
"packs",
|
|
16
|
+
"scripts",
|
|
16
17
|
"model-settings.json",
|
|
17
18
|
"model-settings.user.jsonc.example"
|
|
18
19
|
],
|
|
@@ -23,6 +24,7 @@
|
|
|
23
24
|
"dev:agent-poc": "tsx src/experimental/run.ts",
|
|
24
25
|
"start": "node dist/index.js",
|
|
25
26
|
"test": "vitest run --passWithNoTests",
|
|
27
|
+
"smoke": "npm run build && node scripts/smoke-install.mjs",
|
|
26
28
|
"test:watch": "vitest",
|
|
27
29
|
"test:integration": "cross-env COMFYUI_INTEGRATION=true vitest run",
|
|
28
30
|
"test:agent": "npm run build && node scripts/test-agent.mjs",
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Validate every model URL in every pack manifest WITHOUT downloading the file:
|
|
3
|
+
// - the link resolves (HTTP 200 via HEAD, or 206 via a 1-byte ranged GET)
|
|
4
|
+
// - Content-Length falls in a sane band for the model type (folder), so a dead
|
|
5
|
+
// link returning a small HTML error page is caught as "too small".
|
|
6
|
+
//
|
|
7
|
+
// node scripts/check-model-urls.mjs # all packs
|
|
8
|
+
// node scripts/check-model-urls.mjs packs/anima
|
|
9
|
+
//
|
|
10
|
+
// Exit non-zero if any URL is unreachable or implausibly sized.
|
|
11
|
+
|
|
12
|
+
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
|
13
|
+
import { join, basename, dirname } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { parse } from "yaml";
|
|
16
|
+
|
|
17
|
+
const MB = 1024 * 1024;
|
|
18
|
+
const GB = 1024 * MB;
|
|
19
|
+
|
|
20
|
+
// Minimum plausible size per model category (top folder under models/).
|
|
21
|
+
// Generous on the ceiling; the floor is the real signal (catches error pages).
|
|
22
|
+
const FLOOR = {
|
|
23
|
+
diffusion_models: 100 * MB,
|
|
24
|
+
unet: 80 * MB,
|
|
25
|
+
checkpoints: 100 * MB,
|
|
26
|
+
text_encoders: 40 * MB,
|
|
27
|
+
clip: 40 * MB,
|
|
28
|
+
vae: 8 * MB,
|
|
29
|
+
loras: 512 * 1024,
|
|
30
|
+
controlnet: 512 * 1024,
|
|
31
|
+
upscale_models: 1 * MB,
|
|
32
|
+
ultralytics: 1 * MB,
|
|
33
|
+
sams: 40 * MB,
|
|
34
|
+
};
|
|
35
|
+
const DEFAULT_FLOOR = 256 * 1024;
|
|
36
|
+
const CEILING = 80 * GB;
|
|
37
|
+
const CONCURRENCY = 8;
|
|
38
|
+
const TIMEOUT_MS = 30_000;
|
|
39
|
+
|
|
40
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
41
|
+
const packsRoot = join(repoRoot, "packs");
|
|
42
|
+
|
|
43
|
+
function packDirs() {
|
|
44
|
+
const arg = process.argv[2];
|
|
45
|
+
if (arg) return [join(repoRoot, arg)];
|
|
46
|
+
return readdirSync(packsRoot)
|
|
47
|
+
.map((n) => join(packsRoot, n))
|
|
48
|
+
.filter((p) => statSync(p).isDirectory() && existsSync(join(p, "manifest.yaml")));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function category(m) {
|
|
52
|
+
if (m.local_path) return m.local_path.split(/[\\/]/)[0];
|
|
53
|
+
return m.model_type || "checkpoints";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function human(n) {
|
|
57
|
+
if (!Number.isFinite(n)) return "?";
|
|
58
|
+
if (n >= GB) return (n / GB).toFixed(2) + "GB";
|
|
59
|
+
if (n >= MB) return (n / MB).toFixed(1) + "MB";
|
|
60
|
+
return (n / 1024).toFixed(0) + "KB";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const RETRIES = 4;
|
|
64
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
65
|
+
|
|
66
|
+
// Fetch with retries: transient network errors, timeouts, 429s and 5xx are
|
|
67
|
+
// retried with linear backoff so one hiccup against a CDN doesn't fail CI.
|
|
68
|
+
async function fetchRetry(url, opts) {
|
|
69
|
+
let lastErr;
|
|
70
|
+
for (let attempt = 0; attempt < RETRIES; attempt++) {
|
|
71
|
+
const ac = new AbortController();
|
|
72
|
+
const t = setTimeout(() => ac.abort(), TIMEOUT_MS);
|
|
73
|
+
try {
|
|
74
|
+
const r = await fetch(url, { ...opts, signal: ac.signal });
|
|
75
|
+
if (r.status === 429 || r.status >= 500) {
|
|
76
|
+
lastErr = new Error(`HTTP ${r.status}`);
|
|
77
|
+
} else {
|
|
78
|
+
return r;
|
|
79
|
+
}
|
|
80
|
+
} catch (e) {
|
|
81
|
+
lastErr = new Error(e?.name === "AbortError" ? "timeout" : e?.message ?? String(e));
|
|
82
|
+
} finally {
|
|
83
|
+
clearTimeout(t);
|
|
84
|
+
}
|
|
85
|
+
if (attempt < RETRIES - 1) await sleep(600 * (attempt + 1));
|
|
86
|
+
}
|
|
87
|
+
throw lastErr ?? new Error("request failed");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function probe(url) {
|
|
91
|
+
try {
|
|
92
|
+
const r = await fetchRetry(url, { method: "HEAD", redirect: "follow" });
|
|
93
|
+
// Gated repos (HF 🔒) answer 401/403 without a token — the link is valid,
|
|
94
|
+
// it just needs auth. Treat as OK (size unverifiable without the token).
|
|
95
|
+
if (r.status === 401 || r.status === 403) return { ok: true, gated: true, status: r.status, size: NaN };
|
|
96
|
+
const len = r.headers.get("content-length");
|
|
97
|
+
if (r.ok && len) return { ok: true, status: r.status, size: Number(len) };
|
|
98
|
+
// Fallback: a 1-byte ranged GET yields the total via Content-Range, no download.
|
|
99
|
+
const g = await fetchRetry(url, { headers: { Range: "bytes=0-0" }, redirect: "follow" });
|
|
100
|
+
try { await g.body?.cancel(); } catch { /* ignore */ }
|
|
101
|
+
const cr = g.headers.get("content-range"); // "bytes 0-0/12345"
|
|
102
|
+
if (cr && cr.includes("/")) {
|
|
103
|
+
const total = Number(cr.split("/").pop());
|
|
104
|
+
if (Number.isFinite(total)) return { ok: g.status === 206 || g.ok, status: g.status, size: total };
|
|
105
|
+
}
|
|
106
|
+
const len2 = g.headers.get("content-length");
|
|
107
|
+
return { ok: r.ok || g.ok, status: g.ok ? g.status : r.status, size: len2 ? Number(len2) : NaN };
|
|
108
|
+
} catch (e) {
|
|
109
|
+
return { ok: false, status: 0, size: NaN, error: e?.message ?? String(e) };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function mapLimit(items, limit, fn) {
|
|
114
|
+
const out = new Array(items.length);
|
|
115
|
+
let i = 0;
|
|
116
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
117
|
+
while (i < items.length) {
|
|
118
|
+
const idx = i++;
|
|
119
|
+
out[idx] = await fn(items[idx], idx);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
await Promise.all(workers);
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const tasks = [];
|
|
127
|
+
for (const dir of packDirs()) {
|
|
128
|
+
const manifest = parse(readFileSync(join(dir, "manifest.yaml"), "utf8")) || {};
|
|
129
|
+
for (const m of manifest.models || []) {
|
|
130
|
+
tasks.push({ pack: basename(dir), model: m, cat: category(m) });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.log(`Checking ${tasks.length} model URLs across ${new Set(tasks.map((t) => t.pack)).size} packs...\n`);
|
|
135
|
+
|
|
136
|
+
let bad = 0;
|
|
137
|
+
const results = await mapLimit(tasks, CONCURRENCY, async (t) => {
|
|
138
|
+
const r = await probe(t.model.url);
|
|
139
|
+
const floor = FLOOR[t.cat] ?? DEFAULT_FLOOR;
|
|
140
|
+
let verdict = "OK";
|
|
141
|
+
if (!r.gated) {
|
|
142
|
+
if (!r.ok) verdict = `BAD LINK (${r.error || "HTTP " + r.status})`;
|
|
143
|
+
else if (!Number.isFinite(r.size)) verdict = "NO SIZE (server gave no length)";
|
|
144
|
+
else if (r.size < floor) verdict = `TOO SMALL (${human(r.size)} < floor ${human(floor)} for ${t.cat})`;
|
|
145
|
+
else if (r.size > CEILING) verdict = `TOO BIG (${human(r.size)})`;
|
|
146
|
+
}
|
|
147
|
+
if (verdict !== "OK") bad++;
|
|
148
|
+
return { ...t, ...r, verdict };
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
let curPack = "";
|
|
152
|
+
for (const r of results) {
|
|
153
|
+
if (r.pack !== curPack) { curPack = r.pack; console.log(`# ${curPack}`); }
|
|
154
|
+
const name = r.model.local_path ? basename(r.model.local_path) : basename(new URL(r.model.url).pathname);
|
|
155
|
+
const mark = r.verdict === "OK" ? "ok " : "ERR";
|
|
156
|
+
console.log(` [${mark}] ${name.padEnd(48)} ${human(r.size).padStart(8)} ${r.verdict === "OK" ? "" : "<- " + r.verdict}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
console.log(`\n${results.length - bad}/${results.length} OK`);
|
|
160
|
+
if (bad) { console.error(`${bad} model URL(s) failed validation.`); process.exit(1); }
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Cross-check every pack's workflow.json against its manifest.yaml so a loader
|
|
3
|
+
// node can't reference a model file the pack never downloads (and vice versa).
|
|
4
|
+
//
|
|
5
|
+
// This is the guard that was missing when two packs shipped a VAELoader pointing
|
|
6
|
+
// at `ae.safetensors` while their installers only fetched a different VAE — the
|
|
7
|
+
// node throws a missing-model error on load, but nothing caught it in review.
|
|
8
|
+
//
|
|
9
|
+
// node scripts/check-pack-models.mjs # all packs
|
|
10
|
+
// node scripts/check-pack-models.mjs packs/ernie # one pack
|
|
11
|
+
//
|
|
12
|
+
// Exit non-zero if any workflow references a model the manifest doesn't provide.
|
|
13
|
+
// Dead downloads (provided but never referenced) are reported as warnings only.
|
|
14
|
+
|
|
15
|
+
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
|
16
|
+
import { join, basename, dirname } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { parse } from "yaml";
|
|
19
|
+
|
|
20
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
21
|
+
const packsRoot = join(repoRoot, "packs");
|
|
22
|
+
|
|
23
|
+
// File extensions that denote a model weight the pack must provide.
|
|
24
|
+
const MODEL_EXT = /\.(safetensors|sft|gguf|ckpt|pt|pth|bin|onnx)$/i;
|
|
25
|
+
|
|
26
|
+
// Loader widget values are sometimes a sentinel rather than a real file.
|
|
27
|
+
const SENTINELS = new Set(["none", "undefined", "null", ""]);
|
|
28
|
+
|
|
29
|
+
// Node types that fetch their own weights on first run (the model is NOT the
|
|
30
|
+
// manifest's responsibility): controlnet-aux preprocessors, RIFE interpolation,
|
|
31
|
+
// and the kijai DownloadAndLoad* nodes. A reference from one of these is never
|
|
32
|
+
// a manifest gap.
|
|
33
|
+
const SELF_MANAGING = /(^DownloadAndLoad)|Preprocessor$|RIFE|InsightFace/i;
|
|
34
|
+
const isSelfManaging = (type) => SELF_MANAGING.test(String(type || ""));
|
|
35
|
+
|
|
36
|
+
function packDirs() {
|
|
37
|
+
const arg = process.argv[2];
|
|
38
|
+
if (arg) return [join(repoRoot, arg)];
|
|
39
|
+
return readdirSync(packsRoot)
|
|
40
|
+
.map((n) => join(packsRoot, n))
|
|
41
|
+
.filter((p) => statSync(p).isDirectory() && existsSync(join(p, "manifest.yaml")));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Compare by basename, lowercased — manifest local_paths and workflow widget
|
|
45
|
+
// values disagree on subfolders (e.g. "loras\\style\\x.safetensors" vs
|
|
46
|
+
// "loras/x.safetensors"), but the filename ComfyUI loads is the same.
|
|
47
|
+
const norm = (p) => basename(String(p).replace(/\\/g, "/")).toLowerCase();
|
|
48
|
+
|
|
49
|
+
// Files a manifest downloads (basename of local_path, else filename, else URL).
|
|
50
|
+
function providedFiles(manifest) {
|
|
51
|
+
const out = new Map(); // norm -> display name
|
|
52
|
+
for (const m of manifest.models || []) {
|
|
53
|
+
let name;
|
|
54
|
+
if (m.local_path) name = basename(m.local_path);
|
|
55
|
+
else if (m.filename) name = m.filename;
|
|
56
|
+
else if (m.url) {
|
|
57
|
+
try {
|
|
58
|
+
name = basename(new URL(m.url).pathname);
|
|
59
|
+
} catch {
|
|
60
|
+
name = m.url;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (name) out.set(norm(name), name);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Model files referenced by a workflow's loader nodes. We scan widgets_values
|
|
69
|
+
// (the value ComfyUI actually loads) for strings ending in a model extension —
|
|
70
|
+
// NOT node.properties.models, which is a download hint that is often stale.
|
|
71
|
+
function referencedFiles(workflow) {
|
|
72
|
+
const refs = new Map(); // norm -> { name, nodes: [{type,id,bypassed}] }
|
|
73
|
+
const record = (val, node) => {
|
|
74
|
+
if (typeof val !== "string") return;
|
|
75
|
+
if (SENTINELS.has(val.toLowerCase())) return;
|
|
76
|
+
if (!MODEL_EXT.test(val)) return;
|
|
77
|
+
const key = norm(val);
|
|
78
|
+
if (!refs.has(key)) refs.set(key, { name: val, nodes: [] });
|
|
79
|
+
refs.get(key).nodes.push({
|
|
80
|
+
type: node.type,
|
|
81
|
+
id: node.id,
|
|
82
|
+
bypassed: node.mode === 2 || node.mode === 4,
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
const walk = (v, node) => {
|
|
86
|
+
if (Array.isArray(v)) v.forEach((x) => walk(x, node));
|
|
87
|
+
else if (v && typeof v === "object") Object.values(v).forEach((x) => walk(x, node));
|
|
88
|
+
else record(v, node);
|
|
89
|
+
};
|
|
90
|
+
for (const node of workflow.nodes || []) walk(node.widgets_values, node);
|
|
91
|
+
return refs;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let errors = 0;
|
|
95
|
+
let warnings = 0;
|
|
96
|
+
|
|
97
|
+
for (const dir of packDirs()) {
|
|
98
|
+
const name = basename(dir);
|
|
99
|
+
const manifest = parse(readFileSync(join(dir, "manifest.yaml"), "utf8")) || {};
|
|
100
|
+
const packPath = join(dir, "pack.yaml");
|
|
101
|
+
const pack = existsSync(packPath) ? parse(readFileSync(packPath, "utf8")) || {} : {};
|
|
102
|
+
const wfFile = pack.workflow || "workflow.json";
|
|
103
|
+
const wfPath = join(dir, wfFile);
|
|
104
|
+
if (!existsSync(wfPath)) {
|
|
105
|
+
console.log(`SKIP ${name} — no ${wfFile}`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const provided = providedFiles(manifest);
|
|
110
|
+
const referenced = referencedFiles(JSON.parse(readFileSync(wfPath, "utf8")));
|
|
111
|
+
// Files the workflow legitimately references but the pack intentionally does
|
|
112
|
+
// not download (user-supplied LoRAs, etc.). Declared in pack.yaml so the gap
|
|
113
|
+
// is explicit and reviewed, not silent. Compared by basename.
|
|
114
|
+
const allowed = new Set((pack.external_models || []).map(norm));
|
|
115
|
+
|
|
116
|
+
// An ACTIVE loader pointing at a file the manifest doesn't provide is a hard
|
|
117
|
+
// error (ComfyUI flags it missing on load). The same from a bypassed node, a
|
|
118
|
+
// self-managing node, or an allow-listed file is a warning at most.
|
|
119
|
+
const hardMissing = [];
|
|
120
|
+
const softMissing = [];
|
|
121
|
+
for (const [key, info] of referenced) {
|
|
122
|
+
if (provided.has(key)) continue;
|
|
123
|
+
if (allowed.has(key)) continue;
|
|
124
|
+
const liveLoaders = info.nodes.filter((n) => !n.bypassed && !isSelfManaging(n.type));
|
|
125
|
+
(liveLoaders.length ? hardMissing : softMissing).push(info);
|
|
126
|
+
}
|
|
127
|
+
// Dead downloads: provided but no loader references them. Often intentional
|
|
128
|
+
// (alt quants, optional upscalers), so this is a warning, not an error.
|
|
129
|
+
const unused = [];
|
|
130
|
+
for (const [key, disp] of provided) {
|
|
131
|
+
if (!referenced.has(key)) unused.push(disp);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (hardMissing.length === 0) {
|
|
135
|
+
console.log(`OK ${name} — ${referenced.size} model refs, no live gaps`);
|
|
136
|
+
}
|
|
137
|
+
for (const info of hardMissing) {
|
|
138
|
+
const where = info.nodes
|
|
139
|
+
.map((n) => `${n.type}#${n.id}${n.bypassed ? "(bypassed)" : ""}`)
|
|
140
|
+
.join(", ");
|
|
141
|
+
console.error(`FAIL ${name} — workflow references "${info.name}" not downloaded by manifest [${where}]`);
|
|
142
|
+
errors++;
|
|
143
|
+
}
|
|
144
|
+
for (const info of softMissing) {
|
|
145
|
+
console.warn(`warn ${name} — "${info.name}" referenced only by bypassed/self-managing nodes, not in manifest`);
|
|
146
|
+
warnings++;
|
|
147
|
+
}
|
|
148
|
+
for (const disp of unused) {
|
|
149
|
+
console.warn(`warn ${name} — manifest downloads "${disp}" but no loader references it`);
|
|
150
|
+
warnings++;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
console.log(`\n${errors} error(s), ${warnings} warning(s)`);
|
|
155
|
+
process.exit(errors ? 1 : 0);
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Generate one-click install-windows.bat + install-runpod.sh for each pack from
|
|
3
|
+
// its pack.yaml + manifest.yaml. The manifest is the single source of truth
|
|
4
|
+
// (also consumed by apply_manifest); never hand-edit the generated scripts.
|
|
5
|
+
//
|
|
6
|
+
// node scripts/gen-pack-installers.mjs # all packs/*
|
|
7
|
+
// node scripts/gen-pack-installers.mjs packs/anima # one pack
|
|
8
|
+
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
10
|
+
import { join, basename, dirname } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { parse } from "yaml";
|
|
13
|
+
|
|
14
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
|
+
const packsRoot = join(repoRoot, "packs");
|
|
16
|
+
|
|
17
|
+
function packDirsFromArgs() {
|
|
18
|
+
const arg = process.argv[2];
|
|
19
|
+
if (arg) return [join(repoRoot, arg)];
|
|
20
|
+
return readdirSync(packsRoot)
|
|
21
|
+
.map((n) => join(packsRoot, n))
|
|
22
|
+
.filter((p) => statSync(p).isDirectory() && existsSync(join(p, "manifest.yaml")));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Folder a `git clone <url>` lands in (basename, minus .git / trailing ref).
|
|
26
|
+
function repoFolder(url) {
|
|
27
|
+
const noRef = url.replace(/(?<!^)@[^@/]+$/, "").replace(/[?#].*$/, "").replace(/\/+$/, "");
|
|
28
|
+
return basename(noRef).replace(/\.git$/i, "");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Target path of a model, relative to models/.
|
|
32
|
+
function modelRel(m) {
|
|
33
|
+
if (m.local_path) return m.local_path;
|
|
34
|
+
const fn = m.filename || basename(new URL(m.url).pathname);
|
|
35
|
+
return `${m.model_type || "checkpoints"}/${fn}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function genBat(pack, manifest) {
|
|
39
|
+
const nodes = manifest.custom_nodes || [];
|
|
40
|
+
const models = manifest.models || [];
|
|
41
|
+
const pip = manifest.pip || [];
|
|
42
|
+
const workflow = pack.workflow || "workflow.json";
|
|
43
|
+
const L = [];
|
|
44
|
+
L.push("@echo off");
|
|
45
|
+
L.push("setlocal EnableExtensions EnableDelayedExpansion");
|
|
46
|
+
L.push("chcp 65001 >nul");
|
|
47
|
+
L.push(`rem ===== Auto-generated by scripts/gen-pack-installers.mjs — do not edit =====`);
|
|
48
|
+
L.push(`rem Pack: ${pack.display_name || pack.name}`);
|
|
49
|
+
L.push("rem Run from your ComfyUI root (the folder containing custom_nodes\\ and models\\).");
|
|
50
|
+
L.push('if not exist "custom_nodes" ( echo [ERROR] Run from your ComfyUI root ^(custom_nodes\\ not found^). & pause & exit /b 1 )');
|
|
51
|
+
L.push('where git >nul 2>&1 || ( echo [ERROR] git not found in PATH. & pause & exit /b 1 )');
|
|
52
|
+
L.push('where curl >nul 2>&1 || ( echo [ERROR] curl not found in PATH. & pause & exit /b 1 )');
|
|
53
|
+
L.push("rem Resolve the ComfyUI python: venv first, then portable embed, then PATH python.");
|
|
54
|
+
L.push('set "PY=%CD%\\.venv\\Scripts\\python.exe"');
|
|
55
|
+
L.push('if not exist "%PY%" set "PY=%CD%\\venv\\Scripts\\python.exe"');
|
|
56
|
+
L.push('if not exist "%PY%" set "PY=%CD%\\..\\python_embeded\\python.exe"');
|
|
57
|
+
L.push('if not exist "%PY%" set "PY=python"');
|
|
58
|
+
L.push('echo using python: %PY%');
|
|
59
|
+
L.push("");
|
|
60
|
+
L.push("echo -------- custom nodes --------");
|
|
61
|
+
for (const url of nodes) L.push(`call :clone "${repoFolder(url)}" "${url}"`);
|
|
62
|
+
if (pip.length) {
|
|
63
|
+
L.push("");
|
|
64
|
+
L.push("echo -------- pip (manifest extras) --------");
|
|
65
|
+
for (const p of pip) L.push(`"%PY%" -m pip install "${p}"`);
|
|
66
|
+
}
|
|
67
|
+
L.push("");
|
|
68
|
+
L.push("echo -------- models --------");
|
|
69
|
+
for (const m of models) L.push(`call :grab "models\\${modelRel(m).replace(/\//g, "\\")}" "${m.url}"`);
|
|
70
|
+
L.push("");
|
|
71
|
+
L.push(`echo DONE. Restart ComfyUI, then load ${workflow}.`);
|
|
72
|
+
L.push("pause");
|
|
73
|
+
L.push("exit /b");
|
|
74
|
+
L.push("");
|
|
75
|
+
L.push(":clone");
|
|
76
|
+
L.push('if not exist "custom_nodes\\%~1" (');
|
|
77
|
+
L.push(" echo cloning %~1");
|
|
78
|
+
L.push(' git clone --depth 1 "%~2" "custom_nodes\\%~1"');
|
|
79
|
+
L.push(' if exist "custom_nodes\\%~1\\requirements.txt" ( echo installing %~1 requirements.txt & "%PY%" -m pip install -r "custom_nodes\\%~1\\requirements.txt" )');
|
|
80
|
+
L.push(") else ( echo %~1 present - skip )");
|
|
81
|
+
L.push("goto :eof");
|
|
82
|
+
L.push("");
|
|
83
|
+
L.push(":grab");
|
|
84
|
+
L.push('if not exist "%~dp1" mkdir "%~dp1"');
|
|
85
|
+
L.push('if not exist "%~1" ( echo downloading %~nx1 & curl -L --ssl-no-revoke -o "%~1" "%~2" ) else ( echo %~nx1 present - skip )');
|
|
86
|
+
L.push("goto :eof");
|
|
87
|
+
return L.join("\r\n") + "\r\n";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function genSh(pack, manifest) {
|
|
91
|
+
const nodes = manifest.custom_nodes || [];
|
|
92
|
+
const models = manifest.models || [];
|
|
93
|
+
const pip = manifest.pip || [];
|
|
94
|
+
const workflow = pack.workflow || "workflow.json";
|
|
95
|
+
const L = [];
|
|
96
|
+
L.push("#!/usr/bin/env bash");
|
|
97
|
+
L.push("set -euo pipefail");
|
|
98
|
+
L.push("# ===== Auto-generated by scripts/gen-pack-installers.mjs — do not edit =====");
|
|
99
|
+
L.push(`# Pack: ${pack.display_name || pack.name} (Linux / RunPod)`);
|
|
100
|
+
L.push("# Run from your ComfyUI root (the folder containing custom_nodes/ and models/).");
|
|
101
|
+
L.push('[ -d custom_nodes ] || { echo "[ERROR] run from your ComfyUI root (custom_nodes/ not found)"; exit 1; }');
|
|
102
|
+
L.push("# Resolve the ComfyUI python: its venv first, then portable embed, then \\$PYTHON/python3.");
|
|
103
|
+
L.push('# Installing requirements into the wrong interpreter is why nodes silently fail to load.');
|
|
104
|
+
L.push('if [ -n "${PYTHON:-}" ]; then PY="$PYTHON";');
|
|
105
|
+
L.push('elif [ -x ".venv/bin/python" ]; then PY=".venv/bin/python";');
|
|
106
|
+
L.push('elif [ -x "venv/bin/python" ]; then PY="venv/bin/python";');
|
|
107
|
+
L.push('elif [ -x "../python_embeded/python" ]; then PY="../python_embeded/python";');
|
|
108
|
+
L.push('else PY="python3"; fi');
|
|
109
|
+
L.push('echo "using python: $PY"');
|
|
110
|
+
L.push("");
|
|
111
|
+
L.push("clone() { # folder url");
|
|
112
|
+
L.push(' if [ ! -d "custom_nodes/$1" ]; then');
|
|
113
|
+
L.push(' echo " cloning $1"; git clone --depth 1 "$2" "custom_nodes/$1"');
|
|
114
|
+
L.push(' if [ -f "custom_nodes/$1/requirements.txt" ]; then echo " installing $1 requirements.txt"; "$PY" -m pip install -r "custom_nodes/$1/requirements.txt"; fi');
|
|
115
|
+
L.push(' else echo " $1 present - skip"; fi');
|
|
116
|
+
L.push("}");
|
|
117
|
+
L.push("grab() { # relpath url");
|
|
118
|
+
L.push(' mkdir -p "$(dirname "$1")"');
|
|
119
|
+
L.push(' if [ ! -f "$1" ]; then echo " downloading $(basename "$1")"; curl -L -o "$1" "$2"; else echo " $(basename "$1") present - skip"; fi');
|
|
120
|
+
L.push("}");
|
|
121
|
+
L.push("");
|
|
122
|
+
L.push('echo "-------- custom nodes --------"');
|
|
123
|
+
for (const url of nodes) L.push(`clone "${repoFolder(url)}" "${url}"`);
|
|
124
|
+
if (pip.length) {
|
|
125
|
+
L.push("");
|
|
126
|
+
L.push('echo "-------- pip (manifest extras) --------"');
|
|
127
|
+
for (const p of pip) L.push(`"$PY" -m pip install "${p}"`);
|
|
128
|
+
}
|
|
129
|
+
L.push("");
|
|
130
|
+
L.push('echo "-------- models --------"');
|
|
131
|
+
for (const m of models) L.push(`grab "models/${modelRel(m)}" "${m.url}"`);
|
|
132
|
+
L.push("");
|
|
133
|
+
L.push(`echo "DONE. Restart ComfyUI, then load ${workflow}."`);
|
|
134
|
+
return L.join("\n") + "\n";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const dirs = packDirsFromArgs();
|
|
138
|
+
if (!dirs.length) {
|
|
139
|
+
console.error("No packs found under packs/.");
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
for (const dir of dirs) {
|
|
143
|
+
const manifest = parse(readFileSync(join(dir, "manifest.yaml"), "utf8")) || {};
|
|
144
|
+
const packPath = join(dir, "pack.yaml");
|
|
145
|
+
const pack = existsSync(packPath) ? parse(readFileSync(packPath, "utf8")) || {} : { name: basename(dir) };
|
|
146
|
+
writeFileSync(join(dir, "install-windows.bat"), genBat(pack, manifest));
|
|
147
|
+
writeFileSync(join(dir, "install-runpod.sh"), genSh(pack, manifest));
|
|
148
|
+
const n = (manifest.custom_nodes || []).length;
|
|
149
|
+
const m = (manifest.models || []).length;
|
|
150
|
+
console.log(`${basename(dir)}: wrote install-windows.bat + install-runpod.sh (${n} nodes, ${m} models)`);
|
|
151
|
+
}
|