File size: 42,241 Bytes
429ecf7
 
73868f5
 
 
 
 
 
 
b330390
429ecf7
 
 
 
 
73868f5
 
1230504
63dcaed
b330390
 
 
429ecf7
73868f5
 
c9c1685
 
 
 
 
 
429ecf7
 
 
 
be1c0b3
 
 
 
 
 
1230504
 
429ecf7
73868f5
 
 
 
 
429ecf7
 
 
 
 
 
 
 
be1c0b3
 
429ecf7
be1c0b3
 
429ecf7
be1c0b3
429ecf7
 
 
 
 
 
 
73868f5
1230504
73868f5
429ecf7
 
1230504
2769579
73868f5
429ecf7
1230504
 
 
429ecf7
 
 
b330390
73868f5
429ecf7
 
 
73868f5
 
 
 
429ecf7
 
 
 
 
 
 
 
 
 
 
 
 
1230504
429ecf7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73868f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429ecf7
 
 
 
 
 
 
 
 
 
 
 
 
 
73868f5
 
 
 
 
 
 
429ecf7
 
f8a7528
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73868f5
 
 
 
 
2769579
 
 
 
73868f5
 
2769579
 
 
 
 
 
 
 
 
 
 
73868f5
 
 
2769579
 
73868f5
 
2769579
73868f5
2769579
 
 
 
 
 
 
 
 
73868f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2769579
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73868f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1230504
c9c1685
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1230504
73868f5
 
 
 
c9c1685
 
 
 
 
 
 
 
 
1230504
73868f5
 
 
 
 
1230504
 
 
 
 
73868f5
1230504
 
 
 
 
73868f5
 
 
1230504
 
73868f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2769579
73868f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429ecf7
 
b330390
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429ecf7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
be1c0b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1230504
 
 
 
 
 
be1c0b3
1230504
 
 
 
 
b330390
 
 
 
be1c0b3
b330390
be1c0b3
b330390
 
 
 
 
 
be1c0b3
 
 
 
 
 
 
 
b330390
be1c0b3
 
 
b330390
be1c0b3
b330390
be1c0b3
 
 
b330390
 
 
 
be1c0b3
 
 
 
 
 
 
 
b330390
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
be1c0b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429ecf7
b330390
429ecf7
73868f5
1230504
429ecf7
 
139a78d
 
 
 
 
 
 
 
 
 
63dcaed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139a78d
429ecf7
 
 
 
b330390
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73868f5
429ecf7
 
 
73868f5
 
 
 
f8a7528
 
 
 
429ecf7
 
73868f5
 
429ecf7
73868f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429ecf7
 
73868f5
429ecf7
 
1230504
73868f5
 
 
 
 
 
429ecf7
 
 
73868f5
 
 
 
 
 
 
 
 
 
 
b330390
 
 
429ecf7
 
 
73868f5
 
 
 
 
 
 
429ecf7
 
 
 
 
 
 
 
 
73868f5
 
 
429ecf7
 
 
 
 
 
 
 
1230504
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
#!/usr/bin/env bash
# download_missing_models.sh
# Interactively pick workflows from ./workflows, then:
#   1. Find every custom-node pack the workflows depend on (via each node's
#      cnr_id/aux_id metadata, with Comfy Registry + ComfyUI-Manager DB lookups
#      as fallbacks), git-clone them into ComfyUI/custom_nodes, and install
#      their Python requirements β€” so they're live on the next ComfyUI restart.
#   2. Find every model they reference, locate those models in the HF repo
#      aleph65/ComfyUI (which mirrors ComfyUI/models/), and download the
#      missing ones into place, with progress and automatic stall recovery.
#
# Usage:
#   ./download_missing_models.sh                 # interactive workflow picker
#   ./download_missing_models.sh --all           # select every workflow
#   ./download_missing_models.sh qwen-edit.json  # select specific workflow(s)
#   ./download_missing_models.sh --dry-run ...   # show the plan, install/download nothing
#   ./download_missing_models.sh --skip-nodes .. # models only, skip custom-node installs
#   ./download_missing_models.sh --yes ...       # skip the "Proceed?" confirmation
#   ./download_missing_models.sh --no-refresh .. # don't re-sync ./workflows from the HF repo first
#   ./download_missing_models.sh --last          # reuse the workflow selection from the previous run
#   ./download_missing_models.sh --parallel true # download up to 8 files at once (also accepts a
#                                                # count, e.g. --parallel 4; default: one at a time)
#
# Auth: uses the HUGGING_FACE_ACCESS_TOKEN environment variable if set
# (falls back to HF_TOKEN), otherwise prompts for a token.
# If GITHUB_TOKEN (or GH_TOKEN) is set, git clones of github.com repos are
# authenticated with it β€” datacenter IPs (e.g. RunPod) often get 403s on
# anonymous github.com traffic. Use a fine-grained PAT with no repo
# permissions; the token is sent as a one-shot header, never written to disk.
# With or without a token, a failed clone falls back to a repo tarball from
# codeload.github.com, which is typically not IP-blocked.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# The pod image exports HF_HUB_ENABLE_HF_TRANSFER=1 and HF_XET_HIGH_PERFORMANCE=1.
# hf_transfer is deprecated in huggingface_hub 1.x, and the Xet high-performance
# mode has deadlocked mid-download on this pod (~115 parallel connections, all
# data buffered in RAM, zero bytes written, no error). Stock Xet concurrency is
# still several hundred MB/s here, which is what the network volume tops out at.
unset HF_HUB_ENABLE_HF_TRANSFER HF_XET_HIGH_PERFORMANCE
# never let git sit on an interactive credential/hostkey prompt
export GIT_TERMINAL_PROMPT=0

# 1) Hugging Face token: HUGGING_FACE_ACCESS_TOKEN, then HF_TOKEN, then prompt.
if [[ -n "${HUGGING_FACE_ACCESS_TOKEN:-}" ]]; then
    HF_TOKEN="$HUGGING_FACE_ACCESS_TOKEN"
    echo "Using HF token from environment (HUGGING_FACE_ACCESS_TOKEN)."
elif [[ -n "${HF_TOKEN:-}" ]]; then
    echo "Using HF token from environment (HF_TOKEN)."
else
    read -r -s -p "Enter your Hugging Face token (input hidden): " HF_TOKEN
    echo
    [[ -n "$HF_TOKEN" ]] || { echo "No token provided β€” aborting."; exit 1; }
fi
export HF_TOKEN

# 2) Ensure deps. huggingface_hub 1.x downloads Xet-backed repos with hf_xet;
# without it the hub falls back to slower, less robust code paths.
if ! python3 -c "import huggingface_hub" >/dev/null 2>&1; then
    echo "Installing huggingface_hub..."
    pip install -q -U huggingface_hub
fi
python3 -c "import hf_xet" >/dev/null 2>&1 || pip install -q hf_xet >/dev/null 2>&1 || true

# 3) Run the downloader.
# Load the Python code into a variable (NOT via stdin, which must stay attached
# to the terminal for the interactive prompts).
PYCODE=$(cat <<'PYEOF'
import json
import os
import re
import socket
import subprocess
import sys
import glob
import threading
import time
import urllib.request

# belt-and-braces: no bare socket (HF API pagination etc.) may stall forever
socket.setdefaulttimeout(60)

WORKSPACE = os.environ.get("COMFY_WORKSPACE", "/workspace")
WORKFLOWS_DIR = os.path.join(WORKSPACE, "workflows")
COMFY_DIR = os.path.join(WORKSPACE, "ComfyUI")
LAST_FILE = os.path.join(WORKSPACE, ".cache", "last-workflows.json")
CUSTOM_NODES_DIR = os.path.join(COMFY_DIR, "custom_nodes")
REPO_ID = "aleph65/ComfyUI"
MODEL_EXTS = (".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".gguf", ".sft", ".onnx")
SKIP_NODE_TYPES = {"MarkdownNote", "Note", "PrimitiveString", "String"}
# node types that never imply a custom-node pack
DEP_SKIP_TYPES = {"MarkdownNote", "Note", "Reroute", "PrimitiveNode"}
REGISTRY_URL = "https://api.comfy.org/nodes/{}"
NODE_MAP_URL = "https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/extension-node-map.json"

C_RESET, C_GREEN, C_YELLOW, C_RED, C_CYAN, C_BOLD = "\033[0m", "\033[32m", "\033[33m", "\033[31m", "\033[36m", "\033[1m"

def human(n):
    for unit in ("B", "KB", "MB", "GB", "TB"):
        if n < 1024 or unit == "TB":
            return f"{n:.1f} {unit}" if unit != "B" else f"{int(n)} B"
        n /= 1024

# ---------------- workflow selection ----------------

def pick_workflows(files, argv):
    names = [os.path.basename(f) for f in files]
    args = [a for a in argv if not a.startswith("-")]
    if "--all" in argv:
        return files
    if args:
        chosen = []
        for a in args:
            matches = [f for f in files if os.path.basename(f) == a or os.path.basename(f) == a + ".json"]
            if not matches:
                sys.exit(f"No workflow named '{a}' in {WORKFLOWS_DIR}")
            chosen += matches
        return chosen
    print(f"\n{C_BOLD}Available workflows:{C_RESET}")
    for i, name in enumerate(names, 1):
        print(f"  {i}) {name}")
    print()
    while True:
        try:
            raw = input("Select workflows (e.g. '1 3', '1,3', or 'all'; q to quit): ").strip().lower()
        except EOFError:
            sys.exit("\nNo input available. Run interactively, or pass workflow names or --all.")
        if raw in ("q", "quit", "exit"):
            sys.exit("Aborted.")
        if raw in ("all", "a", "*"):
            return files
        picks = raw.replace(",", " ").split()
        if picks and all(p.isdigit() and 1 <= int(p) <= len(names) for p in picks):
            return [files[int(p) - 1] for p in dict.fromkeys(picks)]
        print(f"  Invalid selection β€” enter numbers 1-{len(names)}, or 'all'.")

# ---------------- model extraction ----------------

def looks_like_model(v):
    if not isinstance(v, str):
        return False
    v = v.strip()
    if not v.lower().endswith(MODEL_EXTS):
        return False
    if "\n" in v or "http://" in v or "https://" in v or len(v) > 300:
        return False
    return True

def iter_graph_nodes(data):
    """Yield every node dict in a workflow, including subgraph instances/definitions."""
    def walk(nodes):
        for n in nodes:
            if not isinstance(n, dict):
                continue
            yield n
            sub = n.get("subgraph")
            if isinstance(sub, dict) and isinstance(sub.get("nodes"), list):
                yield from walk(sub["nodes"])
    if isinstance(data, dict) and isinstance(data.get("nodes"), list):
        yield from walk(data["nodes"])
        for sg in (data.get("definitions") or {}).get("subgraphs", []) or []:
            if isinstance(sg, dict) and isinstance(sg.get("nodes"), list):
                yield from walk(sg["nodes"])
    elif isinstance(data, dict):
        # API format: {"1": {"class_type": ..., "inputs": {...}}, ...}
        for n in data.values():
            if isinstance(n, dict) and "class_type" in n:
                yield n

def extract_models(data):
    """Return set of model refs (may include subdirs like 'flux2/klein/x.safetensors')."""
    refs = set()

    def scan_values(values):
        stack = [values]
        while stack:
            v = stack.pop()
            if isinstance(v, str) and looks_like_model(v):
                refs.add(v.strip().replace("\\", "/"))
            elif isinstance(v, list):
                stack.extend(v)
            elif isinstance(v, dict):
                stack.extend(v.values())

    for n in iter_graph_nodes(data):
        if n.get("type") in SKIP_NODE_TYPES or n.get("class_type") in SKIP_NODE_TYPES:
            continue
        if "widgets_values" in n:
            scan_values(n["widgets_values"])
        if isinstance(n.get("inputs"), dict):
            scan_values(list(n["inputs"].values()))
    return refs

# ---------------- implicit models (IPAdapterUnifiedLoader) ----------------
#
# IPAdapterUnifiedLoader never names its model files in the workflow JSON: at
# runtime it scans models/clip_vision and models/ipadapter for filenames
# hardcoded per preset (see ComfyUI_IPAdapter_plus/utils.py) and raises
# "ClipVision model not found" if they're absent β€” so the widget scan above
# can't see them. Map each preset to the files the loader will look for.
# Both SD1.5 and SDXL variants are listed where they exist, because the
# checkpoint architecture is only known at runtime; the unused variant costs
# little (SD1.5 ipadapters are ~100 MB).
CLIP_VIT_H = "clip_vision/CLIP-ViT-H-14-laion2B-s32B-b79K.safetensors"
CLIP_VIT_BIGG = "clip_vision/CLIP-ViT-bigG-14-laion2B-39B-b160k.safetensors"
UNIFIED_LOADER_PRESETS = {
    "LIGHT - SD1.5 only (low strength)": [CLIP_VIT_H, "ipadapter/ip-adapter_sd15_light_v11.bin"],
    "STANDARD (medium strength)": [CLIP_VIT_H, "ipadapter/ip-adapter_sd15.safetensors",
                                   "ipadapter/ip-adapter_sdxl_vit-h.safetensors"],
    "VIT-G (medium strength)": [CLIP_VIT_BIGG, "ipadapter/ip-adapter_sd15_vit-G.safetensors",
                                "ipadapter/ip-adapter_sdxl.safetensors"],
    "PLUS (high strength)": [CLIP_VIT_H, "ipadapter/ip-adapter-plus_sd15.safetensors",
                             "ipadapter/ip-adapter-plus_sdxl_vit-h.safetensors"],
    "PLUS FACE (portraits)": [CLIP_VIT_H, "ipadapter/ip-adapter-plus-face_sd15.safetensors",
                              "ipadapter/ip-adapter-plus-face_sdxl_vit-h.safetensors"],
    "FULL FACE - SD1.5 only (portraits stronger)": [CLIP_VIT_H, "ipadapter/ip-adapter-full-face_sd15.safetensors"],
}

def extract_implicit_models(data):
    """Refs for models that nodes locate on disk at runtime without naming
    them in the JSON. Currently covers IPAdapterUnifiedLoader; the FaceID
    unified loader (insightface + lora files) is not handled."""
    refs = set()
    for n in iter_graph_nodes(data):
        if (n.get("type") or n.get("class_type")) != "IPAdapterUnifiedLoader":
            continue
        inputs = n.get("inputs")
        if isinstance(inputs, dict) and isinstance(inputs.get("preset"), str):
            preset = inputs["preset"]  # API format
        else:  # UI format: the preset is one of the widget values
            wv = n.get("widgets_values") or []
            preset = next((v for v in wv if isinstance(v, str) and v in UNIFIED_LOADER_PRESETS), None)
        if preset in UNIFIED_LOADER_PRESETS:
            refs.update(UNIFIED_LOADER_PRESETS[preset])
        else:
            print(f"  {C_YELLOW}⚠ IPAdapterUnifiedLoader with unrecognized preset {preset!r} β€” "
                  f"cannot plan its clip_vision/ipadapter files{C_RESET}")
    return refs

# ---------------- custom-node dependency extraction ----------------

def norm_name(s):
    return re.sub(r"[-_.\s]", "", (s or "").lower())

def fetch_json(url, timeout=30, deadline=None):
    """GET a JSON URL. `timeout` bounds each socket op; `deadline` bounds the
    whole download (a slow-dripping CDN can otherwise stretch a 2 MB fetch
    to many silent minutes without ever tripping the per-read timeout)."""
    req = urllib.request.Request(url, headers={"User-Agent": "comfy-dep-installer"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        if deadline is None:
            return json.load(r)
        start, chunks = time.time(), []
        while True:
            if time.time() - start > deadline:
                raise TimeoutError(f"download exceeded {deadline}s")
            chunk = r.read(65536)
            if not chunk:
                break
            chunks.append(chunk)
        return json.loads(b"".join(chunks))

_node_map_cache = None
def node_map():
    """ComfyUI-Manager's extension-node-map: repo URL -> list of node types.
    Cached on disk for a day β€” the fetch can crawl on pod networks."""
    global _node_map_cache
    if _node_map_cache is None:
        cache_file = os.path.join(WORKSPACE, ".cache", "extension-node-map.json")
        try:
            if os.path.exists(cache_file) and time.time() - os.path.getmtime(cache_file) < 86400:
                with open(cache_file) as fh:
                    raw = json.load(fh)
            else:
                print(f"  {C_CYAN}fetching ComfyUI-Manager node map (~2 MB β€” can take a minute on slow networks) ...{C_RESET}")
                raw = fetch_json(NODE_MAP_URL, deadline=180)
                os.makedirs(os.path.dirname(cache_file), exist_ok=True)
                with open(cache_file, "w") as fh:
                    json.dump(raw, fh)
            _node_map_cache = {url: entry[0] for url, entry in raw.items()
                               if isinstance(entry, list) and entry and isinstance(entry[0], list)}
        except Exception as e:
            print(f"  {C_YELLOW}⚠ could not fetch ComfyUI-Manager node map ({e}){C_RESET}")
            _node_map_cache = {}
    return _node_map_cache

def extract_node_deps(data):
    """Return ({pack_key: dep}, {propertyless node types}).
    dep = {'cnr_id', 'aux_id', 'ver', 'types': set}"""
    deps, orphan_types = {}, set()
    uuid_re = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I)
    for n in iter_graph_nodes(data):
        ntype = n.get("type") or n.get("class_type") or ""
        if ntype in DEP_SKIP_TYPES or uuid_re.match(ntype):
            continue
        props = n.get("properties") or {}
        cnr_id, aux_id, ver = props.get("cnr_id"), props.get("aux_id"), props.get("ver")
        if cnr_id == "comfy-core":
            continue
        if cnr_id or aux_id:
            key = norm_name(cnr_id or aux_id.split("/")[-1])
            dep = deps.setdefault(key, {"cnr_id": cnr_id, "aux_id": aux_id, "ver": ver, "types": set()})
            dep["types"].add(ntype)
            # a commit-hash ver is more specific than semver; keep the hash if seen
            if ver and re.fullmatch(r"[0-9a-f]{40}", str(dep.get("ver") or ""), re.I) is None:
                dep["ver"] = ver
        elif ntype:
            orphan_types.add(ntype)
    return deps, orphan_types

def grep_defines(ntype, paths):
    """True if any python file under paths mentions the node type as a whole word."""
    targets = [p for p in paths if os.path.exists(p)]
    if not targets:
        return False
    r = subprocess.run(["grep", "-rlwF", "--include=*.py", "-e", ntype] + targets,
                       stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    return r.returncode == 0

def is_core_node(ntype):
    return grep_defines(ntype, [
        os.path.join(COMFY_DIR, "nodes.py"),
        os.path.join(COMFY_DIR, "comfy_extras"),
        os.path.join(COMFY_DIR, "comfy_api_nodes"),
    ])

def installed_packs():
    """norm_name(dir) -> dir for every pack already in custom_nodes."""
    out = {}
    if os.path.isdir(CUSTOM_NODES_DIR):
        for d in os.listdir(CUSTOM_NODES_DIR):
            full = os.path.join(CUSTOM_NODES_DIR, d)
            if os.path.isdir(full) and not d.startswith((".", "__")):
                out[norm_name(d)] = d
    return out

def provided_by_installed(ntype):
    """True if an installed pack plausibly defines this node type. Display
    names like 'Power Lora Loader (rgthree)' are often built dynamically, so
    the exact string never appears in .py source β€” also try the base name and
    match a parenthetical suffix against installed pack dir names."""
    if grep_defines(ntype, [CUSTOM_NODES_DIR]):
        return True
    m = re.match(r"^(.+?)\s*\(([^()]+)\)$", ntype)
    if m:
        base, suffix = m.group(1), norm_name(m.group(2))
        if suffix and any(suffix in key for key in installed_packs()):
            return True
        if grep_defines(base, [CUSTOM_NODES_DIR]):
            return True
    return False

def resolve_repo_url(dep):
    """Find the git URL for a dep. Registry first, then aux_id, then Manager DB."""
    if dep.get("cnr_id"):
        try:
            info = fetch_json(REGISTRY_URL.format(dep["cnr_id"]))
            if info.get("repository"):
                return info["repository"], "comfy registry"
        except Exception:
            pass
    if dep.get("aux_id") and "/" in dep["aux_id"]:
        return f"https://github.com/{dep['aux_id']}", "aux_id"
    # fall back to ComfyUI-Manager's node map: which repos provide these node types?
    want_key = norm_name(dep.get("cnr_id") or "")
    candidates = [url for url, types in node_map().items()
                  if dep["types"] & set(types)]
    if candidates:
        # prefer a repo whose name matches the cnr_id
        exact = [u for u in candidates if norm_name(u.rstrip("/").split("/")[-1]) == want_key]
        return (exact or candidates)[0], "ComfyUI-Manager node map"
    return None, None

GIT_ENV = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")

def github_owner_repo(url):
    m = re.match(r"https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$", url)
    return (m.group(1), m.group(2)) if m else None

def gh_headers():
    h = {"User-Agent": "comfy-dep-installer"}
    if GITHUB_TOKEN:
        h["Authorization"] = f"Bearer {GITHUB_TOKEN}"
    return h

def git_clone(url, dest):
    """Clone, authenticating github.com with GITHUB_TOKEN when available.
    The token goes in a per-invocation header (git -c), so it is never
    persisted in the clone's .git/config."""
    cmd = ["git"]
    if GITHUB_TOKEN and github_owner_repo(url):
        import base64
        basic = base64.b64encode(f"x-access-token:{GITHUB_TOKEN}".encode()).decode()
        cmd += ["-c", f"http.https://github.com/.extraheader=Authorization: basic {basic}"]
    cmd += ["clone", "--recursive", "--progress", url, dest]
    subprocess.run(cmd, check=True, env=GIT_ENV, timeout=900, stdin=subprocess.DEVNULL)

def install_from_tarball(url, ver, dest):
    """Fallback when git clone is refused (github.com 403s anonymous requests
    from many datacenter IPs): fetch the repo tarball from codeload.github.com,
    which sits on separate infrastructure and is typically not blocked.
    Caveats vs a real clone: no .git (ComfyUI-Manager can't update the pack)
    and no submodules."""
    owner, repo = github_owner_repo(url)
    if ver and re.fullmatch(r"[0-9a-f]{40}", str(ver), re.I):
        ref = str(ver)
    else:
        info = fetch_json_gh(f"https://api.github.com/repos/{owner}/{repo}")
        ref = info.get("default_branch") or "main"
    print(f"  {C_YELLOW}falling back to tarball of {owner}/{repo}@{ref[:12]} via codeload.github.com ...{C_RESET}")
    import shutil, tarfile, tempfile
    req = urllib.request.Request(
        f"https://codeload.github.com/{owner}/{repo}/tar.gz/{ref}", headers=gh_headers())
    os.makedirs(CUSTOM_NODES_DIR, exist_ok=True)
    # tmp dir lives next to dest so the final rename stays on one filesystem;
    # "." prefix keeps installed_packs() from ever seeing it
    with tempfile.TemporaryDirectory(dir=CUSTOM_NODES_DIR, prefix=".tarball-") as tmp:
        tar_path = os.path.join(tmp, "repo.tar.gz")
        with urllib.request.urlopen(req, timeout=120) as r, open(tar_path, "wb") as fh:
            shutil.copyfileobj(r, fh)
        with tarfile.open(tar_path) as tar:
            try:
                tar.extractall(tmp, filter="data")
            except TypeError:  # Python < 3.12 has no filter=
                tar.extractall(tmp)
        tops = [e for e in os.listdir(tmp)
                if os.path.isdir(os.path.join(tmp, e))]
        if len(tops) != 1:
            raise RuntimeError(f"unexpected tarball layout: {tops}")
        os.rename(os.path.join(tmp, tops[0]), dest)

def fetch_json_gh(url):
    req = urllib.request.Request(url, headers=gh_headers())
    with urllib.request.urlopen(req, timeout=30) as r:
        return json.load(r)

def install_pack(name, url, ver):
    dest = os.path.join(CUSTOM_NODES_DIR, url.rstrip("/").split("/")[-1].removesuffix(".git"))
    print(f"\n{C_CYAN}Installing {name} from {url} ...{C_RESET}")
    if not os.path.isdir(dest):
        try:
            git_clone(url, dest)
        except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
            if not github_owner_repo(url):
                raise
            print(f"  {C_YELLOW}⚠ git clone failed ({e}){C_RESET}")
            install_from_tarball(url, ver, dest)
    if ver and re.fullmatch(r"[0-9a-f]{40}", str(ver), re.I) \
            and os.path.isdir(os.path.join(dest, ".git")):
        r = subprocess.run(["git", "-C", dest, "checkout", ver], env=GIT_ENV, timeout=120,
                           stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        print(f"  pinned to commit {ver[:12]}" if r.returncode == 0
              else f"  {C_YELLOW}⚠ could not pin to {ver[:12]}, staying on latest{C_RESET}")
    req = os.path.join(dest, "requirements.txt")
    if os.path.exists(req):
        print("  installing python requirements (can take a few minutes) ...")
        r = subprocess.run([sys.executable, "-m", "pip", "install", "-r", req,
                            "--progress-bar", "off"],
                           timeout=1800, stdin=subprocess.DEVNULL,
                           stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
        if r.returncode != 0:
            print(r.stdout)
            print(f"  {C_YELLOW}⚠ pip install had errors β€” see output above{C_RESET}")
        else:
            installed = [l for l in r.stdout.splitlines() if l.startswith("Successfully installed")]
            print(f"  {installed[0] if installed else 'requirements already satisfied'}")
    inst = os.path.join(dest, "install.py")
    if os.path.exists(inst):
        print("  running install.py ...")
        r = subprocess.run([sys.executable, inst], cwd=dest, timeout=1800,
                           stdin=subprocess.DEVNULL)
        if r.returncode != 0:
            print(f"  {C_YELLOW}⚠ install.py exited with {r.returncode}{C_RESET}")
    return dest

def plan_node_deps(chosen_data, dry_run):
    """Collect deps across workflows, resolve, and return list of (name, url, ver) to install."""
    all_deps, all_orphans = {}, set()
    for wf_name, data in chosen_data:
        deps, orphans = extract_node_deps(data)
        for k, d in deps.items():
            tgt = all_deps.setdefault(k, d)
            if tgt is not d:
                tgt["types"] |= d["types"]
                # prefer a commit-hash pin over a semver, else keep the first seen
                if d["ver"] and not re.fullmatch(r"[0-9a-f]{40}", str(tgt["ver"] or ""), re.I):
                    tgt["ver"] = d["ver"]
        all_orphans |= orphans

    installed = installed_packs()

    # nodes with no pack metadata: fine if core or provided by an installed pack,
    # otherwise try to identify the pack via the Manager node map
    unresolved_types = []
    for t in sorted(all_orphans):
        if any(t in d["types"] for d in all_deps.values()):
            continue
        if is_core_node(t) or provided_by_installed(t):
            continue
        hits = [url for url, types in node_map().items() if t in types]
        if hits:
            key = norm_name(hits[0].rstrip("/").split("/")[-1])
            dep = all_deps.setdefault(key, {"cnr_id": None, "aux_id": None, "ver": None, "types": set()})
            dep["types"].add(t)
            dep["_url"] = hits[0]
        else:
            unresolved_types.append(t)

    to_install, already, failed = [], [], []
    for key, dep in sorted(all_deps.items()):
        name = dep.get("cnr_id") or dep.get("aux_id") or key
        if key in installed:
            already.append((name, installed[key]))
            continue
        url, source = (dep.get("_url"), "ComfyUI-Manager node map") if dep.get("_url") else resolve_repo_url(dep)
        if url:
            repo_dir = norm_name(url.rstrip("/").split("/")[-1].removesuffix(".git"))
            if repo_dir in installed:
                already.append((name, installed[repo_dir]))
            else:
                to_install.append((name, url, dep.get("ver"), source))
        else:
            failed.append((name, dep["types"]))

    print(f"\n{C_BOLD}Custom-node dependencies:{C_RESET}")
    if not (to_install or already or failed):
        print(f"  {C_GREEN}βœ” none needed β€” selected workflows only use core nodes.{C_RESET}")
    for name, d in already:
        print(f"  {C_GREEN}βœ” installed{C_RESET}  {name}  (custom_nodes/{d})")
    for name, url, ver, source in to_install:
        pin = f", pin {str(ver)[:12]}" if ver and re.fullmatch(r"[0-9a-f]{40}", str(ver), re.I) else ""
        print(f"  {C_YELLOW}↓ install{C_RESET}    {name}  ({url}{pin}, via {source})")
    for name, types in failed:
        print(f"  {C_RED}✘ unknown{C_RESET}    {name}  β€” could not find a repo (nodes: {', '.join(sorted(types))})")
    for t in unresolved_types:
        print(f"  {C_RED}✘ unknown{C_RESET}    node type '{t}' β€” not core, not installed, not in the Manager DB")
    return to_install

# ---------------- main ----------------

def parse_parallel(argv):
    """Consume --parallel false|true|<N> (or --parallel=<val>) from argv and
    return (remaining argv, max_workers). Default is one file at a time β€”
    pass --parallel true (or a count) for concurrent files. Must run before
    pick_workflows, which would otherwise mistake the flag's value for a
    workflow name."""
    out, max_workers, i = [], 1, 0
    while i < len(argv):
        a = argv[i]
        if a == "--parallel" or a.startswith("--parallel="):
            if "=" in a:
                val = a.split("=", 1)[1]
            else:
                i += 1
                val = argv[i] if i < len(argv) else ""
            v = val.strip().lower()
            if v in ("false", "no", "off"):
                max_workers = 1
            elif v in ("true", "yes", "on", ""):
                max_workers = 8
            elif v.isdigit() and int(v) > 0:
                max_workers = int(v)
            else:
                sys.exit(f"--parallel expects true/false or a positive worker count, got '{val}'")
        else:
            out.append(a)
        i += 1
    return out, max_workers

def cleanup_debris():
    """Remove partial-download fragments and stale locks left by interrupted runs."""
    cache = os.path.join(COMFY_DIR, ".cache", "huggingface", "download")
    freed, count = 0, 0
    for root, _, names in os.walk(cache):
        for name in names:
            if name.endswith((".incomplete", ".lock")):
                p = os.path.join(root, name)
                try:
                    freed += os.path.getsize(p)
                    os.remove(p)
                    count += 1
                except OSError:
                    pass
    if count:
        print(f"{C_YELLOW}Cleaned up {count} leftover partial-download file(s) ({human(freed)} freed).{C_RESET}")

def bytes_on_disk(to_download):
    """Bytes actually landed for the planned downloads: finished files,
    hub .incomplete fragments, and our curl .part files."""
    done = 0
    cache = os.path.join(COMFY_DIR, ".cache", "huggingface", "download")
    for _, _, size, dest in to_download:
        if os.path.exists(dest):
            done += min(os.path.getsize(dest), size)
        if os.path.exists(dest + ".part"):
            done += os.path.getsize(dest + ".part")
    for root, _, names in os.walk(cache):
        for n in names:
            if n.endswith(".incomplete"):
                try:
                    done += os.path.getsize(os.path.join(root, n))
                except OSError:
                    pass
    return done

def download_heartbeat(to_download):
    """Print bytes-on-disk every 20s so a big download never looks hung."""
    total = sum(s for _, _, s, _ in to_download)
    stop = threading.Event()
    def run():
        while not stop.wait(20):
            done = bytes_on_disk(to_download)
            print(f"  ... still downloading: ~{human(min(done, total))} of {human(total)} on disk", flush=True)
    t = threading.Thread(target=run, daemon=True)
    t.start()
    return stop

STALL_SECS = 10          # once bytes are flowing, a freeze this long = wedged, kill it
STARTUP_GRACE_SECS = 60  # each (re)launch gets this long to land its first bytes (metadata resolution etc.)
RESTART_WAIT_SECS = 2    # pause between killing a wedged download and relaunching it
MAX_RESTARTS = 3         # kill-and-restart attempts before falling back to curl

def hf_download_with_watchdog(patterns, to_download, max_workers):
    """Run snapshot_download in a child process and kill it if bytes-on-disk
    freeze. Both hf_transfer and the Xet backend have wedged mid-file on this
    pod (data buffered in RAM, file frozen, no error raised), so a blocking
    in-process call can hang forever. Any change in bytes-on-disk counts as
    activity (a restarted attempt may truncate a leftover .incomplete
    fragment, briefly shrinking the total). Returns True if the hub download
    finished."""
    import multiprocessing as mp
    def child():
        from huggingface_hub import snapshot_download
        snapshot_download(
            repo_id=REPO_ID,
            allow_patterns=patterns,
            local_dir=COMFY_DIR,
            token=os.environ.get("HF_TOKEN"),
            max_workers=max_workers,
        )
    p = mp.Process(target=child)
    p.start()
    last, last_t, started = bytes_on_disk(to_download), time.time(), False
    while p.is_alive():
        p.join(timeout=2)
        if not p.is_alive():
            break
        done = bytes_on_disk(to_download)
        if done != last:
            last, last_t, started = done, time.time(), True
        elif time.time() - last_t > (STALL_SECS if started else STARTUP_GRACE_SECS):
            print(f"\n  {C_YELLOW}⚠ no bytes written for {int(time.time() - last_t)}s β€” hub download is wedged, killing it{C_RESET}", flush=True)
            p.terminate()
            p.join(10)
            if p.is_alive():
                p.kill()
                p.join()
            return False
    return p.exitcode == 0

def still_missing(to_download):
    return [t for t in to_download
            if not (os.path.exists(t[3]) and (t[2] == 0 or os.path.getsize(t[3]) == t[2]))]

def download_models(to_download, max_workers):
    """Hub download with automatic stall recovery: a wedged download is
    killed, we wait RESTART_WAIT_SECS, and relaunch the same hub download
    (files that already finished are skipped). After MAX_RESTARTS relaunches,
    whatever is still missing goes through plain resumable HTTP with curl."""
    patterns = [m for _, m, _, _ in to_download]
    heartbeat = download_heartbeat(to_download)
    try:
        for attempt in range(MAX_RESTARTS + 1):
            if attempt:
                left = still_missing(to_download)
                print(f"  {C_CYAN}waiting {RESTART_WAIT_SECS}s, then restarting the hub download "
                      f"({len(left)} file(s) to go) β€” retry {attempt} of {MAX_RESTARTS} ...{C_RESET}", flush=True)
                time.sleep(RESTART_WAIT_SECS)
            if hf_download_with_watchdog(patterns, to_download, max_workers) or not still_missing(to_download):
                return
    finally:
        heartbeat.set()
    print(f"\n{C_YELLOW}Hub download still incomplete after {MAX_RESTARTS} restart(s) β€” switching to plain resumable HTTP.{C_RESET}")
    curl_fallback(to_download)

def curl_fallback(to_download):
    """Plain resumable HTTP for whatever is still missing. --speed-limit makes
    curl abort any transfer that drops below 1 MB/s for 30s (instead of
    hanging on a dead connection), and -C - resumes from the .part file."""
    token = os.environ.get("HF_TOKEN", "")
    for _, match, size, dest in to_download:
        if os.path.exists(dest) and (size == 0 or os.path.getsize(dest) == size):
            continue
        url = f"https://huggingface.co/{REPO_ID}/resolve/main/{match}"
        part = dest + ".part"
        os.makedirs(os.path.dirname(dest), exist_ok=True)
        print(f"  {C_CYAN}fetching {match} with curl (resumable) ...{C_RESET}", flush=True)
        for attempt in range(1, 11):
            r = subprocess.run(
                ["curl", "-L", "--fail", "-C", "-",
                 "-H", f"Authorization: Bearer {token}",
                 "--speed-limit", "1000000", "--speed-time", "30",
                 "--progress-bar", "-o", part, url],
                stdin=subprocess.DEVNULL)
            have = os.path.getsize(part) if os.path.exists(part) else 0
            if r.returncode == 0 and (size == 0 or have == size):
                os.replace(part, dest)
                break
            print(f"  {C_YELLOW}… attempt {attempt} stopped at {human(have)} of {human(size)} β€” resuming{C_RESET}", flush=True)
        else:
            print(f"  {C_RED}✘ gave up on {match} after 10 attempts{C_RESET}")

def main():
    argv, max_workers = parse_parallel(sys.argv[1:])
    dry_run = "--dry-run" in argv
    skip_nodes = "--skip-nodes" in argv
    assume_yes = "--yes" in argv or "-y" in argv

    cleanup_debris()

    if not os.path.isdir(WORKFLOWS_DIR):
        print(f"{C_YELLOW}{WORKFLOWS_DIR} not found β€” downloading workflows from hf.co/{REPO_ID} ...{C_RESET}")
        from huggingface_hub import snapshot_download
        snapshot_download(
            repo_id=REPO_ID,
            allow_patterns=["workflows/*.json"],
            local_dir=WORKSPACE,
            token=os.environ.get("HF_TOKEN"),
        )
    elif "--no-refresh" not in argv:
        # The workflows baked into the pod image can lag the HF repo, and a
        # stale copy makes the model plan silently miss anything added to a
        # workflow since the image was built. Sync workflows/*.json from the
        # repo before planning; the repo wins over local edits, so pass
        # --no-refresh to plan against the local copies instead.
        print(f"{C_CYAN}Syncing workflows from hf.co/{REPO_ID} (--no-refresh to skip) ...{C_RESET}")
        try:
            from huggingface_hub import snapshot_download
            snapshot_download(
                repo_id=REPO_ID,
                allow_patterns=["workflows/*.json"],
                local_dir=WORKSPACE,
                token=os.environ.get("HF_TOKEN"),
            )
        except Exception as e:
            print(f"{C_YELLOW}⚠ workflow sync failed ({e}) β€” planning against the local copies.{C_RESET}")

    files = sorted(glob.glob(os.path.join(WORKFLOWS_DIR, "*.json")))
    if not files:
        sys.exit(f"No workflow JSONs found in {WORKFLOWS_DIR}")

    if "--last" in argv:
        try:
            with open(LAST_FILE) as fh:
                last_names = json.load(fh)
        except (OSError, ValueError):
            last_names = None
        if not last_names:
            sys.exit("--last: no saved selection found β€” run once and pick workflows first.")
        by_name = {os.path.basename(f): f for f in files}
        gone = [n for n in last_names if n not in by_name]
        if gone:
            sys.exit(f"--last: saved workflow(s) no longer in {WORKFLOWS_DIR}: {', '.join(gone)}")
        chosen = [by_name[n] for n in last_names]
        print(f"{C_CYAN}Reusing last selection (--last).{C_RESET}")
    else:
        chosen = pick_workflows(files, argv)
        # remember the selection so a retry can just use --last
        try:
            os.makedirs(os.path.dirname(LAST_FILE), exist_ok=True)
            with open(LAST_FILE, "w") as fh:
                json.dump([os.path.basename(f) for f in chosen], fh)
        except OSError:
            pass
    chosen_data = []
    print(f"\n{C_BOLD}Selected workflows:{C_RESET}")
    wanted = {}  # ref -> [workflow names]
    for f in chosen:
        with open(f) as fh:
            data = json.load(fh)
        chosen_data.append((os.path.basename(f), data))
        refs = extract_models(data)
        implicit = extract_implicit_models(data) - refs
        suffix = f" + {len(implicit)} implicit via IPAdapterUnifiedLoader" if implicit else ""
        print(f"  β€’ {os.path.basename(f)}  ({len(refs)} model refs{suffix})")
        for r in refs | implicit:
            wanted.setdefault(r, []).append(os.path.basename(f))

    # -------- custom-node packs --------
    node_installs = [] if skip_nodes else plan_node_deps(chosen_data, dry_run)

    # -------- models --------
    to_download, have, not_found = [], [], []
    if wanted:
        print(f"\n{C_CYAN}Listing files in hf.co/{REPO_ID} ...{C_RESET}")
        from huggingface_hub import HfApi
        api = HfApi(token=os.environ.get("HF_TOKEN"))
        repo_files = {}  # repo path -> size
        for entry in api.list_repo_tree(REPO_ID, recursive=True):
            if hasattr(entry, "size") and entry.path.startswith("models/"):
                repo_files[entry.path] = entry.size or 0

        # index by basename and by suffix path
        by_basename = {}
        for p in repo_files:
            by_basename.setdefault(os.path.basename(p), []).append(p)

        for ref, wfs in sorted(wanted.items()):
            base = os.path.basename(ref)
            candidates = by_basename.get(base, [])
            # prefer a repo path that ends with the workflow's relative path (handles lora subdirs)
            match = next((p for p in candidates if p.endswith("/" + ref) or p == "models/" + ref), None)
            if match is None and candidates:
                match = candidates[0]
            if match is None:
                not_found.append((ref, wfs))
                continue
            dest = os.path.join(COMFY_DIR, match)
            size = repo_files[match]
            if os.path.exists(dest) and (size == 0 or os.path.getsize(dest) == size):
                have.append((ref, match, size))
            else:
                to_download.append((ref, match, size, dest))

        print(f"\n{C_BOLD}Model plan:{C_RESET}")
        for ref, match, size in have:
            print(f"  {C_GREEN}βœ” have{C_RESET}     {match}  ({human(size)})")
        for ref, match, size, dest in to_download:
            print(f"  {C_YELLOW}↓ fetch{C_RESET}    {match}  ({human(size)})")
        for ref, wfs in not_found:
            print(f"  {C_RED}✘ missing{C_RESET}  {ref}  β€” not in hf.co/{REPO_ID} (used by {', '.join(wfs)})")

        total = sum(s for _, _, s, _ in to_download)
        print(f"\n{len(have)} model(s) already present, {len(to_download)} to download ({human(total)}), "
              f"{len(not_found)} not in repo; {len(node_installs)} node pack(s) to install.")
    else:
        total = 0
        print(f"\nNo model references found; {len(node_installs)} node pack(s) to install.")

    if not to_download and not node_installs:
        print(f"{C_GREEN}Nothing to do β€” all set!{C_RESET}")
        return
    if dry_run:
        print("(dry run β€” nothing installed or downloaded)")
        return

    if sys.stdin.isatty() and not assume_yes:
        parts = []
        if node_installs:
            parts.append(f"install {len(node_installs)} node pack(s)")
        if to_download:
            parts.append(f"download {len(to_download)} file(s) ({human(total)})")
        resp = input(f"\nProceed: {' and '.join(parts)}? [Y/n] ").strip().lower()
        if resp not in ("", "y", "yes"):
            sys.exit("Aborted.")

    # -------- install custom-node packs --------
    pack_failures = []
    for name, url, ver, _source in node_installs:
        try:
            install_pack(name, url, ver)
        except Exception as e:
            pack_failures.append((name, str(e)))
            print(f"  {C_RED}✘ failed to install {name}: {e}{C_RESET}")

    # -------- download models --------
    if to_download:
        mode = "one file at a time" if max_workers == 1 else f"up to {max_workers} files in parallel"
        print(f"\n{C_CYAN}Downloading with the Hugging Face hub (Xet backend, {mode})...{C_RESET}\n")
        download_models(to_download, max_workers)

    print(f"\n{C_BOLD}Verifying:{C_RESET}")
    ok = True
    for name, url, ver, _source in node_installs:
        dest = os.path.join(CUSTOM_NODES_DIR, url.rstrip("/").split("/")[-1].removesuffix(".git"))
        if os.path.isdir(dest) and not any(name == f[0] for f in pack_failures):
            print(f"  {C_GREEN}βœ”{C_RESET} custom_nodes/{os.path.basename(dest)}")
        else:
            ok = False
            print(f"  {C_RED}✘{C_RESET} {name}  (install failed)")
    for _, match, size, dest in to_download:
        if os.path.exists(dest) and (size == 0 or os.path.getsize(dest) == size):
            print(f"  {C_GREEN}βœ”{C_RESET} {dest}  ({human(os.path.getsize(dest))})")
        else:
            ok = False
            print(f"  {C_RED}✘{C_RESET} {dest}  (incomplete or missing)")
    if not_found:
        print(f"\n{C_YELLOW}Note:{C_RESET} {len(not_found)} model(s) were not found in the repo (listed above) β€” "
              f"you'll need to source those elsewhere.")
    if node_installs and ok:
        print(f"\n{C_CYAN}Restart ComfyUI to load the newly installed node packs.{C_RESET}")
    print(f"\n{C_GREEN if ok else C_RED}{'Done β€” all installs and downloads verified.' if ok else 'Done, but some steps failed β€” re-run to retry.'}{C_RESET}")

if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        sys.exit("\nInterrupted.")
PYEOF
)
# -u: unbuffered stdout, so progress lines appear immediately even when piped (e.g. | tee)
exec python3 -u -c "$PYCODE" "$@"