rusty_stack/installers/components/
comfyui.rs1use crate::installers::common::command_exists;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ShellCommand {
21 pub program: String,
23 pub args: Vec<String>,
25 pub env: Vec<(String, String)>,
27}
28
29impl ShellCommand {
30 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#[derive(Debug, Clone)]
53pub struct ComfyuiConfig {
54 pub install_dir: String,
56 pub python_bin: String,
58 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
72pub const REPO_URL: &str = "https://github.com/comfyanonymous/ComfyUI.git";
74
75pub const DEFAULT_BRANCH: &str = "master";
77
78pub const WEB_PORT: u16 = 8188;
80
81pub const EXCLUDED_PACKAGES: &[&str] = &[
84 "torch",
85 "torchvision",
86 "torchaudio",
87 "torchsde",
88 "sentencepiece",
89];
90
91pub const PRESERVE_DIRS: &[&str] = &["models", "input", "output", "user"];
93
94pub fn default_install_dir() -> String {
96 format!("{}/ComfyUI", std::env::var("HOME").unwrap_or_default())
97}
98
99pub struct ComfyuiInstaller {
101 config: ComfyuiConfig,
102}
103
104impl ComfyuiInstaller {
105 pub fn new(config: ComfyuiConfig) -> Self {
107 Self { config }
108 }
109
110 pub fn with_defaults() -> Self {
112 Self::new(ComfyuiConfig::default())
113 }
114
115 pub fn dependencies() -> Vec<&'static str> {
121 vec!["pytorch"]
122 }
123
124 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 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 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 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 pub fn requirements_filter_pattern() -> String {
271 EXCLUDED_PACKAGES
272 .iter()
273 .map(|p| format!("^{}", p))
274 .collect::<Vec<_>>()
275 .join("|")
276 }
277
278 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 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 pub fn detect_gpu_devices(&self) -> String {
312 if command_exists("rocm-smi") {
313 "0,1".to_string()
316 } else {
317 "0,1".to_string()
318 }
319 }
320
321 pub fn install_dir(&self) -> &str {
323 &self.config.install_dir
324 }
325
326 pub fn repo_url(&self) -> &str {
328 REPO_URL
329 }
330
331 pub fn default_branch(&self) -> &str {
333 DEFAULT_BRANCH
334 }
335}
336
337#[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 #[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 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 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 assert_eq!(DEFAULT_BRANCH, "master");
424 }
425
426 #[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 #[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 assert_eq!(cmds.len(), 4);
465 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 assert_eq!(cmds.len(), 6);
476 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 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}