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