Skip to main content

rusty_stack/installers/components/
comfyui.rs

1//! ComfyUI installer — ports `scripts/install_comfyui.sh`.
2//!
3//! Clones the ComfyUI repository, installs Python dependencies (excluding
4//! torch packages), and creates launcher shim. ComfyUI depends on PyTorch.
5//!
6//! # Validation Assertions
7//!
8//! - **VAL-INSTALL-023**: ComfyUI correct git clone and pip install
9//! - **VAL-INSTALL-025**: App installers correct target directory
10//! - **VAL-INSTALL-047**: ComfyUI declares dependency on PyTorch
11
12// ===========================================================================
13// Types
14// ===========================================================================
15
16/// A constructed shell command.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct ShellCommand {
19    /// The program to run.
20    pub program: String,
21    /// Arguments to pass.
22    pub args: Vec<String>,
23    /// Environment variables to set.
24    pub env: Vec<(String, String)>,
25}
26
27impl ShellCommand {
28    /// Format as a shell command string.
29    pub fn to_command_string(&self) -> String {
30        let env_prefix = self
31            .env
32            .iter()
33            .map(|(k, v)| format!("{k}={v}"))
34            .collect::<Vec<_>>()
35            .join(" ");
36        let cmd = if self.args.is_empty() {
37            self.program.clone()
38        } else {
39            format!("{} {}", self.program, self.args.join(" "))
40        };
41        if env_prefix.is_empty() {
42            cmd
43        } else {
44            format!("{env_prefix} {cmd}")
45        }
46    }
47}
48
49/// Configuration for the ComfyUI installer.
50#[derive(Debug, Clone)]
51pub struct ComfyuiConfig {
52    /// Target installation directory.
53    pub install_dir: String,
54    /// Python binary to use.
55    pub python_bin: String,
56    /// Whether to run in dry-run mode.
57    pub dry_run: bool,
58}
59
60impl Default for ComfyuiConfig {
61    fn default() -> Self {
62        Self {
63            install_dir: default_install_dir(),
64            python_bin: "python3".to_string(),
65            dry_run: false,
66        }
67    }
68}
69
70/// ComfyUI Git repository URL.
71pub const REPO_URL: &str = "https://github.com/comfyanonymous/ComfyUI.git";
72
73/// Default branch for ComfyUI.
74pub const DEFAULT_BRANCH: &str = "master";
75
76/// Default web port.
77pub const WEB_PORT: u16 = 8188;
78
79/// Packages to exclude from requirements.txt filtering.
80/// The original script excludes: torch, torchvision, torchaudio, torchsde, sentencepiece.
81pub const EXCLUDED_PACKAGES: &[&str] = &[
82    "torch",
83    "torchvision",
84    "torchaudio",
85    "torchsde",
86    "sentencepiece",
87];
88
89/// Directories to preserve during git update.
90pub const PRESERVE_DIRS: &[&str] = &["models", "input", "output", "user"];
91
92/// Default ComfyUI installation directory.
93pub fn default_install_dir() -> String {
94    format!("{}/ComfyUI", std::env::var("HOME").unwrap_or_default())
95}
96
97/// The ComfyUI installer.
98pub struct ComfyuiInstaller {
99    config: ComfyuiConfig,
100}
101
102impl ComfyuiInstaller {
103    /// Create a new ComfyUI installer with the given config.
104    pub fn new(config: ComfyuiConfig) -> Self {
105        Self { config }
106    }
107
108    /// Create with default config.
109    pub fn with_defaults() -> Self {
110        Self::new(ComfyuiConfig::default())
111    }
112
113    // -----------------------------------------------------------------------
114    // Dependency declaration (VAL-INSTALL-047)
115    // -----------------------------------------------------------------------
116
117    /// ComfyUI depends on PyTorch with ROCm support.
118    pub fn dependencies() -> Vec<&'static str> {
119        vec!["pytorch"]
120    }
121
122    /// Check if PyTorch is available (via import check command).
123    pub fn build_pytorch_check_command(&self) -> ShellCommand {
124        ShellCommand {
125            program: self.config.python_bin.clone(),
126            args: vec!["-c".to_string(), "import torch".to_string()],
127            env: vec![],
128        }
129    }
130
131    // -----------------------------------------------------------------------
132    // Command construction (VAL-INSTALL-023, VAL-INSTALL-025)
133    // -----------------------------------------------------------------------
134
135    /// Construct the git clone command for ComfyUI.
136    ///
137    /// The original script runs:
138    /// `git clone "https://github.com/comfyanonymous/ComfyUI.git" "$COMFYUI_DIR"`
139    pub fn build_git_clone_command(&self) -> ShellCommand {
140        ShellCommand {
141            program: "git".to_string(),
142            args: vec![
143                "clone".to_string(),
144                REPO_URL.to_string(),
145                self.config.install_dir.clone(),
146            ],
147            env: vec![],
148        }
149    }
150
151    /// Construct the git update commands for an existing clone.
152    ///
153    /// The original script runs:
154    /// 1. `git -C "$DIR" remote set-head origin -a`
155    /// 2. Detect default branch
156    /// 3. `git -C "$DIR" checkout "$DEFAULT_BRANCH"`
157    /// 4. `git -C "$DIR" fetch --all`
158    /// 5. `git -C "$DIR" stash push -u` (if user data exists)
159    /// 6. `git -C "$DIR" reset --hard "origin/$DEFAULT_BRANCH"`
160    /// 7. `git -C "$DIR" stash pop` (if user data was stashed)
161    pub fn build_git_update_commands(&self, has_user_data: bool) -> Vec<ShellCommand> {
162        let dir = &self.config.install_dir;
163        let mut cmds = vec![
164            ShellCommand {
165                program: "git".to_string(),
166                args: vec![
167                    "-C".to_string(),
168                    dir.clone(),
169                    "remote".to_string(),
170                    "set-head".to_string(),
171                    "origin".to_string(),
172                    "-a".to_string(),
173                ],
174                env: vec![],
175            },
176            ShellCommand {
177                program: "git".to_string(),
178                args: vec![
179                    "-C".to_string(),
180                    dir.clone(),
181                    "checkout".to_string(),
182                    DEFAULT_BRANCH.to_string(),
183                ],
184                env: vec![],
185            },
186            ShellCommand {
187                program: "git".to_string(),
188                args: vec![
189                    "-C".to_string(),
190                    dir.clone(),
191                    "fetch".to_string(),
192                    "--all".to_string(),
193                ],
194                env: vec![],
195            },
196        ];
197
198        if has_user_data {
199            cmds.push(ShellCommand {
200                program: "git".to_string(),
201                args: vec![
202                    "-C".to_string(),
203                    dir.clone(),
204                    "stash".to_string(),
205                    "push".to_string(),
206                    "-u".to_string(),
207                    "-m".to_string(),
208                    "rusty-stack-preserve-user-data".to_string(),
209                    "--".to_string(),
210                ]
211                .into_iter()
212                .chain(PRESERVE_DIRS.iter().map(|s| s.to_string()))
213                .collect(),
214                env: vec![],
215            });
216        }
217
218        cmds.push(ShellCommand {
219            program: "git".to_string(),
220            args: vec![
221                "-C".to_string(),
222                dir.clone(),
223                "reset".to_string(),
224                "--hard".to_string(),
225                format!("origin/{DEFAULT_BRANCH}"),
226            ],
227            env: vec![],
228        });
229
230        if has_user_data {
231            cmds.push(ShellCommand {
232                program: "git".to_string(),
233                args: vec![
234                    "-C".to_string(),
235                    dir.clone(),
236                    "stash".to_string(),
237                    "pop".to_string(),
238                ],
239                env: vec![],
240            });
241        }
242
243        cmds
244    }
245
246    /// Construct the pip install command for filtered requirements.
247    ///
248    /// The original script filters out torch/torchvision/torchaudio/torchsde/sentencepiece
249    /// from requirements.txt then runs:
250    /// `python3 -m pip install -r "$FILTERED_REQS"`
251    pub fn build_pip_install_command(&self, requirements_path: &str) -> ShellCommand {
252        ShellCommand {
253            program: self.config.python_bin.clone(),
254            args: vec![
255                "-m".to_string(),
256                "pip".to_string(),
257                "install".to_string(),
258                "--break-system-packages".to_string(),
259                "-r".to_string(),
260                requirements_path.to_string(),
261            ],
262            env: vec![],
263        }
264    }
265
266    /// Get the grep filter pattern for excluding packages.
267    /// Returns the regex pattern used to filter requirements.txt.
268    pub fn requirements_filter_pattern() -> String {
269        EXCLUDED_PACKAGES
270            .iter()
271            .map(|p| format!("^{}", p))
272            .collect::<Vec<_>>()
273            .join("|")
274    }
275
276    /// Construct the launcher shim content.
277    ///
278    /// Creates a shell script at `~/.local/bin/comfy` that runs:
279    /// `cd "$COMFYUI_DIR" && HIP_VISIBLE_DEVICES=$GPUS ... python3 main.py --enable-manager`
280    pub fn build_launcher_content(&self, gpu_devices: &str) -> String {
281        format!(
282            "#!/bin/bash\n# ComfyUI launcher for Stan's ML Stack\n# Auto-detected GPU devices: {}\ncd \"{}\" && \\\n    HIP_VISIBLE_DEVICES={} \\\n    CUDA_VISIBLE_DEVICES={} \\\n    {} main.py --enable-manager \"$@\"\n",
283            gpu_devices,
284            self.config.install_dir,
285            gpu_devices,
286            gpu_devices,
287            self.config.python_bin,
288        )
289    }
290
291    /// Construct the systemd service unit content.
292    pub fn build_systemd_service(&self, gpu_devices: &str) -> String {
293        format!(
294            "[Unit]\nDescription=ComfyUI - Node-based UI for Stable Diffusion\nAfter=network.target\n\n\
295             [Service]\nType=simple\nWorkingDirectory=\"{}\"\n\
296             Environment=\"HIP_VISIBLE_DEVICES={}\"\n\
297             Environment=\"CUDA_VISIBLE_DEVICES={}\"\n\
298             ExecStart={} \"{}\" --enable-manager\nRestart=on-failure\n\n\
299             [Install]\nWantedBy=default.target\n",
300            self.config.install_dir,
301            gpu_devices,
302            gpu_devices,
303            self.config.python_bin,
304            self.config.install_dir,
305        )
306    }
307
308    /// Detect GPU devices — the canonical, iGPU-filtered discrete list.
309    ///
310    /// Routes through `crate::installer::detect_gpu_list` (Stage 1) so the
311    /// integrated GPU is never included and no dGPU is missed. Baked into the
312    /// launcher shim + systemd unit as `HIP_VISIBLE_DEVICES`/`CUDA_VISIBLE_DEVICES`.
313    pub fn detect_gpu_devices(&self) -> String {
314        crate::installer::detect_gpu_list()
315    }
316
317    /// Get the install directory.
318    pub fn install_dir(&self) -> &str {
319        &self.config.install_dir
320    }
321
322    /// Get the repository URL.
323    pub fn repo_url(&self) -> &str {
324        REPO_URL
325    }
326
327    /// Get the default branch.
328    pub fn default_branch(&self) -> &str {
329        DEFAULT_BRANCH
330    }
331}
332
333// ===========================================================================
334// Tests
335// ===========================================================================
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    fn make_installer(dir: &str) -> ComfyuiInstaller {
342        ComfyuiInstaller::new(ComfyuiConfig {
343            install_dir: dir.to_string(),
344            python_bin: "python3".to_string(),
345            dry_run: false,
346        })
347    }
348
349    // -----------------------------------------------------------------------
350    // VAL-INSTALL-023: ComfyUI correct git clone and pip install
351    // -----------------------------------------------------------------------
352
353    #[test]
354    fn test_git_clone_url_matches_original_script() {
355        assert_eq!(
356            REPO_URL, "https://github.com/comfyanonymous/ComfyUI.git",
357            "URL must match install_comfyui.sh REPO_URL"
358        );
359    }
360
361    #[test]
362    fn test_git_clone_command() {
363        let installer = make_installer("/home/user/ComfyUI");
364        let cmd = installer.build_git_clone_command();
365        assert_eq!(cmd.program, "git");
366        assert!(cmd.args.contains(&"clone".to_string()));
367        assert!(cmd.args.contains(&REPO_URL.to_string()));
368        assert!(cmd.args.contains(&"/home/user/ComfyUI".to_string()));
369    }
370
371    #[test]
372    fn test_git_clone_command_string() {
373        let installer = make_installer("/home/user/ComfyUI");
374        let cmd = installer.build_git_clone_command();
375        let s = cmd.to_command_string();
376        assert!(s.contains("git clone"));
377        assert!(s.contains(REPO_URL));
378    }
379
380    #[test]
381    fn test_pip_install_command() {
382        let installer = make_installer("/home/user/ComfyUI");
383        let cmd = installer.build_pip_install_command("/tmp/filtered_reqs.txt");
384        assert_eq!(cmd.program, "python3");
385        assert!(cmd.args.contains(&"-m".to_string()));
386        assert!(cmd.args.contains(&"pip".to_string()));
387        assert!(cmd.args.contains(&"install".to_string()));
388        assert!(cmd.args.contains(&"-r".to_string()));
389        assert!(cmd.args.contains(&"/tmp/filtered_reqs.txt".to_string()));
390    }
391
392    #[test]
393    fn test_requirements_filter_excludes_torch_packages() {
394        let pattern = ComfyuiInstaller::requirements_filter_pattern();
395        // Pattern should match all excluded packages
396        for pkg in EXCLUDED_PACKAGES {
397            assert!(pattern.contains(pkg), "Pattern should contain '{pkg}'");
398        }
399    }
400
401    #[test]
402    fn test_excluded_packages_match_original_script() {
403        // Original script excludes: torch, torchvision, torchaudio, torchsde, sentencepiece
404        assert_eq!(
405            EXCLUDED_PACKAGES,
406            [
407                "torch",
408                "torchvision",
409                "torchaudio",
410                "torchsde",
411                "sentencepiece"
412            ]
413        );
414    }
415
416    #[test]
417    fn test_default_branch_is_master() {
418        // ComfyUI uses 'master' as default branch
419        assert_eq!(DEFAULT_BRANCH, "master");
420    }
421
422    // -----------------------------------------------------------------------
423    // VAL-INSTALL-025: App installers correct target directory
424    // -----------------------------------------------------------------------
425
426    #[test]
427    fn test_default_install_dir_is_home_comfyui() {
428        let config = ComfyuiConfig::default();
429        assert!(
430            config.install_dir.ends_with("/ComfyUI"),
431            "Default dir must end with /ComfyUI, got: {}",
432            config.install_dir,
433        );
434    }
435
436    #[test]
437    fn test_custom_install_dir() {
438        let installer = make_installer("/opt/apps/ComfyUI");
439        assert_eq!(installer.install_dir(), "/opt/apps/ComfyUI");
440    }
441
442    // -----------------------------------------------------------------------
443    // VAL-INSTALL-047: ComfyUI declares dependency on PyTorch
444    // -----------------------------------------------------------------------
445
446    #[test]
447    fn test_declares_pytorch_dependency() {
448        let deps = ComfyuiInstaller::dependencies();
449        assert!(
450            deps.contains(&"pytorch"),
451            "ComfyUI must declare pytorch as a dependency"
452        );
453    }
454
455    #[test]
456    fn test_git_update_without_user_data() {
457        let installer = make_installer("/home/user/ComfyUI");
458        let cmds = installer.build_git_update_commands(false);
459        // Should have: set-head, checkout, fetch, reset (4 commands, no stash)
460        assert_eq!(cmds.len(), 4);
461        // Last command should be reset
462        let last = cmds.last().unwrap();
463        assert!(last.args.contains(&"reset".to_string()));
464    }
465
466    #[test]
467    fn test_git_update_with_user_data() {
468        let installer = make_installer("/home/user/ComfyUI");
469        let cmds = installer.build_git_update_commands(true);
470        // Should have: set-head, checkout, fetch, stash, reset, stash pop (6 commands)
471        assert_eq!(cmds.len(), 6);
472        // Should contain stash commands
473        let cmd_strings: Vec<String> = cmds.iter().map(|c| c.to_command_string()).collect();
474        let has_stash = cmd_strings.iter().any(|s| s.contains("stash"));
475        assert!(
476            has_stash,
477            "Should contain stash commands when user data exists"
478        );
479    }
480
481    #[test]
482    fn test_launcher_content() {
483        let installer = make_installer("/home/user/ComfyUI");
484        let content = installer.build_launcher_content("0,1");
485        assert!(content.starts_with("#!/bin/bash"));
486        assert!(content.contains("/home/user/ComfyUI"));
487        assert!(content.contains("HIP_VISIBLE_DEVICES=0,1"));
488        assert!(content.contains("python3 main.py --enable-manager"));
489    }
490
491    #[test]
492    fn test_preserve_dirs_match_original_script() {
493        // Original script preserves: models, input, output, user
494        assert_eq!(PRESERVE_DIRS, ["models", "input", "output", "user"]);
495    }
496
497    #[test]
498    fn test_systemd_service_content() {
499        let installer = make_installer("/home/user/ComfyUI");
500        let svc = installer.build_systemd_service("0,1");
501        assert!(svc.contains("[Unit]"));
502        assert!(svc.contains("ComfyUI"));
503        assert!(svc.contains("HIP_VISIBLE_DEVICES=0,1"));
504        assert!(svc.contains("--enable-manager"));
505    }
506}